forked from c-fu/arah2
chore(01-design-tokens-rams-foundation-01): commit design baseline — Rams docs, mockups, agent skills
- Root Rams design docs (DESIGN.md, IDEAS.md) and GSD install-fix notes (GSD_FIX.md, gsd-fix.sh) - Four Telemetry Rams mockup screens + telemetry_rams/DESIGN.md (canonical token frontmatter) - .agents/ project skills (design-taste-frontend) + skills-lock.json - .gitignore: node_modules/dist/env secrets/OS junk; .planning stays local-only; Zone.Identifier scrubbed (9 files deleted)
This commit is contained in:
@@ -0,0 +1,865 @@
|
||||
#!/usr/bin/env node
|
||||
// tasteskill / assets: find real, openly licensed pictures of the subject's world, and keep their credits.
|
||||
//
|
||||
// A page without photographs of its own does not have to draw everything. Archives, museums and open
|
||||
// collections hold the subject's city and street, its maps, its materials, its trade's history and the
|
||||
// museum objects of its kind, free to use with a credit. This searches them without keys and prints what
|
||||
// an agent needs to use a picture honestly: a direct image URL, the page it comes from, its size, its
|
||||
// creator, its licence and a one-line credit. Then it downloads the chosen ones into the project and
|
||||
// records their credits, so the page works offline and credits every picture.
|
||||
//
|
||||
// node assets.mjs "<query>" search every source, licences safe for commercial use
|
||||
// [--source all|openverse|commons|aic|met|museums] one or several, comma-separated
|
||||
// [--license safe|pd|all|cc0,pd,by,by-sa,...] safe = CC0, public domain, CC BY, CC BY-SA
|
||||
// [--orientation landscape|portrait|square] [--min-width 1000] [--width 1920] [--limit 5]
|
||||
// [--no-check] skip checking that each image loads (faster)
|
||||
// [--json] machine-readable results
|
||||
// node assets.mjs "<query>" --video search for real footage instead (same options, plus
|
||||
// [--res 720] the largest rendition to pick, by its short side
|
||||
// [--max-mb 25] clips over this are flagged, and --get refuses them
|
||||
// node assets.mjs --get <ref>[,<ref>...] [--to assets] [--name <file name>] [--width 1920] [--res 720] [--max-mb 25]
|
||||
// download into the project and record the credit
|
||||
// node assets.mjs --credits [--to assets] print the credits for the footer and the report
|
||||
//
|
||||
// Image sources: Openverse (api.openverse.org: Flickr, Wikimedia Commons, museums and libraries in one
|
||||
// index), Wikimedia Commons, the Art Institute of Chicago (public-domain works over IIIF) and The
|
||||
// Metropolitan Museum of Art (Open Access). Video sources: Wikimedia Commons (with its web-sized webm
|
||||
// transcodes), the Internet Archive (its curated Prelinger Archives of films, public domain or Creative
|
||||
// Commons) and the NASA Image and Video Library. Zero dependencies, no API keys. Node 18+ (built-in fetch).
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, createWriteStream, unlinkSync } from 'node:fs';
|
||||
import { join, resolve, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const UA = 'taste-skill-assets/2.0 (+https://github.com/Leonxlnx/taste-skill)';
|
||||
const SELF = fileURLToPath(import.meta.url).split('\\').join('/');
|
||||
const CMD = `node ${/\s/.test(SELF) ? `"${SELF}"` : SELF}`;
|
||||
const SOURCES = {
|
||||
openverse: { media: 'image', name: 'Openverse', note: 'one index over Flickr, Wikimedia Commons, museums and libraries' },
|
||||
commons: { media: 'both', name: 'Wikimedia Commons', note: 'archival photographs, old maps, scans of prints and books, places as they look now', videoNote: 'video files with web-sized webm transcodes: places, crafts, processes, nature' },
|
||||
aic: { media: 'image', name: 'Art Institute of Chicago', note: 'public-domain paintings, prints, photographs and textiles, served large' },
|
||||
met: { media: 'image', name: 'The Metropolitan Museum of Art', note: 'public-domain objects, prints, drawings and photographs (Open Access)' },
|
||||
archive: { media: 'video', name: 'Internet Archive', note: 'the Prelinger Archives: industrial, educational, city and home films from the 1920s to the 1970s, mostly long' },
|
||||
nasa: { media: 'video', name: 'NASA Image and Video Library', note: 'footage of the Earth, space, aircraft, launches and laboratories' },
|
||||
};
|
||||
const SOURCE_ALIAS = { museums: ['aic', 'met'], wikimedia: ['commons'], artic: ['aic'], chicago: ['aic'], metmuseum: ['met'], ia: ['archive'], internetarchive: ['archive'] };
|
||||
const VIDEO_TYPES = { 'video/mp4': 'mp4', 'video/webm': 'webm' }; // what every current browser plays; Ogg Theora and QuickTime are left out
|
||||
const LICENCES = ['cc0', 'pd', 'by', 'by-sa', 'by-nc', 'by-nd', 'by-nc-sa', 'by-nc-nd', 'other'];
|
||||
const SAFE = ['cc0', 'pd', 'by', 'by-sa'];
|
||||
const LICENCE_ALIAS = { pdm: 'pd', 'public-domain': 'pd', publicdomain: 'pd', 'cc-by': 'by', 'cc-by-sa': 'by-sa', 'cc-zero': 'cc0' };
|
||||
const WEB_TYPES = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif', 'image/svg+xml': 'svg' };
|
||||
const PROVIDERS = { flickr: 'Flickr', wikimedia: 'Wikimedia Commons', met: 'The Metropolitan Museum of Art', nypl: 'New York Public Library', rawpixel: 'rawpixel', stocksnap: 'StockSnap', clevelandmuseum: 'Cleveland Museum of Art', brooklynmuseum: 'Brooklyn Museum', sciencemuseum: 'Science Museum Group', europeana: 'Europeana', geographorguk: 'Geograph Britain and Ireland', inaturalist: 'iNaturalist', wordpress: 'WordPress Photo Directory' };
|
||||
const AIC_FIELDS = ['id', 'title', 'artist_title', 'artist_display', 'date_display', 'image_id', 'thumbnail', 'medium_display', 'artwork_type_title', 'is_public_domain'];
|
||||
const AIC_HEADERS = { 'AIC-User-Agent': UA };
|
||||
|
||||
const USAGE = `Find real, openly licensed pictures of the subject's world, download them, keep their credits.
|
||||
|
||||
${CMD} "<query>" [options]
|
||||
--source all|openverse|commons|aic|met|museums comma-separated; default all
|
||||
--license safe|pd|all|cc0,pd,by,by-sa,... default safe: CC0, public domain, CC BY, CC BY-SA
|
||||
--orientation landscape|portrait|square
|
||||
--min-width <px> smallest acceptable full width, default 1000
|
||||
--width <px> width of the image URL to use or download, default 1920
|
||||
--limit <n> results per source, default 5 (at most 20)
|
||||
--no-check do not check that every image loads (faster, less reliable)
|
||||
--json machine-readable results
|
||||
${CMD} "<query>" --video [options] real footage: Wikimedia Commons, Internet Archive, NASA
|
||||
--source all|commons|archive|nasa
|
||||
--res <p> largest rendition to pick, by its short side, default 720
|
||||
--max-mb <n> clips over this are flagged and --get refuses them, default 25
|
||||
--min-width <px> default 640 for video
|
||||
${CMD} --get <ref>[,<ref>...] [--to assets] [--name <file name>] [--width 1920] [--res 720] [--max-mb 25]
|
||||
${CMD} --credits [--to assets]
|
||||
|
||||
Queries work best in the subject's own words and a medium: "ghent postcard", "ghent old map", "azulejo tiles",
|
||||
"botanical engraving fig", "rotterdam harbour 1920", "basalt texture". The museum sources match one or two
|
||||
words best ("fig", "harbour"). For footage, name the action or the process: "coffee roasting", "timber
|
||||
construction", "river", "city street night".`;
|
||||
|
||||
// ---------- arguments ----------
|
||||
const VALUED = new Set(['source', 'sources', 'license', 'licence', 'orientation', 'min-width', 'width', 'limit', 'get', 'to', 'name', 'type', 'res', 'max-mb']);
|
||||
const FLAGS = new Set(['no-check', 'json', 'credits', 'help', 'video']);
|
||||
const args = {}; const words = [];
|
||||
const argv = process.argv.slice(2);
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '-h') { args.help = true; continue; }
|
||||
if (!a.startsWith('--')) { words.push(a); continue; }
|
||||
const eq = a.indexOf('=');
|
||||
const key = a.slice(2, eq === -1 ? undefined : eq);
|
||||
if (VALUED.has(key)) {
|
||||
const v = eq === -1 ? argv[++i] : a.slice(eq + 1);
|
||||
if (v === undefined || v === '' || (eq === -1 && v.startsWith('--'))) fail(`--${key} needs a value.`);
|
||||
args[key] = v;
|
||||
} else if (FLAGS.has(key)) args[key] = true;
|
||||
else fail(`Unknown option --${key}.`);
|
||||
}
|
||||
function fail(message) { console.error(`${message}\n\n${USAGE}`); process.exit(2); }
|
||||
function number(key, def, min, max) {
|
||||
if (args[key] === undefined) return def;
|
||||
const n = Number(args[key]);
|
||||
if (!Number.isFinite(n) || n < min || n > max) fail(`--${key} must be a number from ${min} to ${max}.`);
|
||||
return Math.round(n);
|
||||
}
|
||||
if (args.type !== undefined && !['image', 'images', 'video', 'videos'].includes(String(args.type).toLowerCase())) fail('--type is image or video.');
|
||||
const VIDEO = Boolean(args.video) || /^videos?$/i.test(String(args.type || ''));
|
||||
function pickSources(value) {
|
||||
const media = VIDEO ? 'video' : 'image';
|
||||
const usable = Object.keys(SOURCES).filter((k) => SOURCES[k].media === media || SOURCES[k].media === 'both');
|
||||
const out = [];
|
||||
for (const s of String(value).toLowerCase().split(',').map((x) => x.trim()).filter(Boolean)) {
|
||||
const found = s === 'all' ? usable : SOURCE_ALIAS[s] || (SOURCES[s] ? [s] : null);
|
||||
if (!found) fail(`Unknown source "${s}". ${VIDEO ? 'Video' : 'Image'} sources: ${usable.join(', ')}${VIDEO ? '' : ', museums (aic and met)'}, all.`);
|
||||
const wrong = found.filter((x) => !usable.includes(x));
|
||||
if (wrong.length) fail(`${wrong.join(', ')} has no ${media === 'video' ? 'video' : 'still images'} here. ${VIDEO ? 'Video' : 'Image'} sources: ${usable.join(', ')}.`);
|
||||
for (const x of found) if (!out.includes(x)) out.push(x);
|
||||
}
|
||||
return out.length ? out : usable;
|
||||
}
|
||||
function pickLicences(value) {
|
||||
const v = String(value).toLowerCase().trim();
|
||||
if (v === 'safe') return SAFE;
|
||||
if (v === 'pd') return ['cc0', 'pd'];
|
||||
if (v === 'all' || v === 'any') return LICENCES;
|
||||
const out = v.split(',').map((x) => x.trim()).filter(Boolean).map((x) => LICENCE_ALIAS[x] || x);
|
||||
const bad = out.filter((x) => !LICENCES.includes(x));
|
||||
if (bad.length || !out.length) fail(`Unknown licence "${bad.join(', ') || v}". Use safe, pd, all, or a list of: ${LICENCES.join(', ')}.`);
|
||||
return out;
|
||||
}
|
||||
function pickOrientation(value) {
|
||||
if (value === undefined) return null;
|
||||
const v = String(value).toLowerCase();
|
||||
const o = { landscape: 'landscape', wide: 'landscape', horizontal: 'landscape', portrait: 'portrait', tall: 'portrait', vertical: 'portrait', square: 'square' }[v];
|
||||
if (!o) fail('--orientation is landscape, portrait or square.');
|
||||
return o;
|
||||
}
|
||||
const o = {
|
||||
sources: pickSources(args.source ?? args.sources ?? 'all'),
|
||||
licences: pickLicences(args.license ?? args.licence ?? 'safe'),
|
||||
orientation: pickOrientation(args.orientation),
|
||||
minWidth: number('min-width', VIDEO ? 640 : 1000, 0, 20000),
|
||||
width: number('width', 1920, 200, 4000),
|
||||
limit: number('limit', 5, 1, 20),
|
||||
video: VIDEO,
|
||||
res: number('res', 720, 144, 2160),
|
||||
maxMb: number('max-mb', 25, 1, 2000),
|
||||
check: !args['no-check'],
|
||||
to: resolve(String(args.to || 'assets')),
|
||||
toArg: args.to ? ` --to ${args.to}` : '',
|
||||
};
|
||||
|
||||
// ---------- small helpers ----------
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const short = (s, n) => { const t = String(s || ''); return t.length > n ? t.slice(0, n - 1).replace(/[\s,;:.-]+$/, '') + '…' : t; };
|
||||
const noQuery = (u) => (u ? String(u).split('?')[0] : u);
|
||||
const rel = (p) => relative(process.cwd(), p).split('\\').join('/') || '.';
|
||||
const size = (bytes) => (bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`);
|
||||
const shape = (w, h) => (!w || !h ? null : w / h >= 1.15 ? 'landscape' : w / h <= 0.87 ? 'portrait' : 'square');
|
||||
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
function text(v) {
|
||||
if (v === undefined || v === null) return '';
|
||||
return String(v).replace(/<(style|script)\b[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]*>/g, ' ').replace(/ /g, ' ').replace(/"/g, '"').replace(/�?39;|'/g, "'")
|
||||
.replace(/</g, '<').replace(/>/g, '>').replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&/g, '&')
|
||||
.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
function keyOf(u) { let s = noQuery(u) || ''; try { s = decodeURIComponent(s); } catch {} return s.toLowerCase().replace(/^https?:\/\//, '').replace(/ /g, '_'); }
|
||||
function slug(s) {
|
||||
return String(s || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
.split('-').slice(0, 7).join('-').slice(0, 60).replace(/-+$/, '');
|
||||
}
|
||||
// The words of a query that carry meaning, and whether a record's own description holds them
|
||||
// (all of them, or all but one of three or more). Used to keep the museum sources on the subject.
|
||||
function queryWords(q) { return String(q).toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w && !['the', 'a', 'an', 'of', 'in', 'on', 'at', 'and', 'or', 'for', 'with', 'from', 'to', 'by'].includes(w)); }
|
||||
function onSubject(wanted, description) {
|
||||
if (!wanted.length) return true;
|
||||
const loose = (s) => String(s).toLowerCase().normalize('NFKD').replace(/\p{M}/gu, '').replace(/our\b/g, 'or'); // harbour = harbor
|
||||
const hay = loose(description);
|
||||
const hits = wanted.filter((w) => { const x = loose(w); return hay.includes(x) || (x.length > 4 && hay.includes(x.replace(/(es|s)$/, ''))); }).length;
|
||||
return hits >= (wanted.length >= 3 ? wanted.length - 1 : wanted.length);
|
||||
}
|
||||
async function mapLimit(items, n, fn) {
|
||||
const out = new Array(items.length); let next = 0;
|
||||
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => { while (next < items.length) { const k = next++; out[k] = await fn(items[k], k); } }));
|
||||
return out;
|
||||
}
|
||||
async function getJson(url, ms = 20000, headers = {}) {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
let r;
|
||||
try { r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(ms) }); }
|
||||
catch (e) {
|
||||
const timedOut = e.name === 'TimeoutError' || e.name === 'AbortError';
|
||||
if (!timedOut && attempt < 2) { await sleep(800); continue; }
|
||||
throw new Error(timedOut ? `no answer within ${Math.round(ms / 1000)} s` : `could not connect (${e.cause?.code || e.message})`);
|
||||
}
|
||||
if ([502, 503, 504].includes(r.status) && attempt < 2) { await sleep(1200); continue; }
|
||||
if (r.status === 429) throw new Error('rate limited (HTTP 429): wait a minute, or search another --source');
|
||||
if (r.status === 410) throw new Error('this API endpoint has been retired (HTTP 410); assets.mjs needs updating for it');
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}${r.status >= 500 ? ', the service is having trouble' : ''}`);
|
||||
try { return await r.json(); } catch { throw new Error('the answer was not JSON'); }
|
||||
}
|
||||
}
|
||||
// Width and height from the first bytes of a JPEG, PNG, GIF or WebP file.
|
||||
function dims(b) {
|
||||
if (!b || b.length < 26) return {};
|
||||
if (b[0] === 0x89 && b.toString('ascii', 1, 4) === 'PNG') return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) };
|
||||
if (b.toString('ascii', 0, 3) === 'GIF') return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) };
|
||||
if (b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP' && b.length >= 30) {
|
||||
const f = b.toString('ascii', 12, 16);
|
||||
if (f === 'VP8X') return { w: 1 + b.readUIntLE(24, 3), h: 1 + b.readUIntLE(27, 3) };
|
||||
if (f === 'VP8L') { const bits = b.readUInt32LE(21); return { w: 1 + (bits & 0x3fff), h: 1 + ((bits >>> 14) & 0x3fff) }; }
|
||||
if (f === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff };
|
||||
}
|
||||
if (b[0] === 0xff && b[1] === 0xd8) {
|
||||
let i = 2;
|
||||
while (i + 9 < b.length) {
|
||||
if (b[i] !== 0xff) { i++; continue; }
|
||||
const m = b[i + 1];
|
||||
if (m === 0xff) { i++; continue; }
|
||||
if (m === 0x01 || (m >= 0xd0 && m <= 0xd8)) { i += 2; continue; }
|
||||
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) };
|
||||
i += 2 + b.readUInt16BE(i + 2);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
// Width, height and duration from the head of an MP4 whose index comes first (what web renditions use).
|
||||
function mp4Info(b) {
|
||||
const out = {}; let i = -1; let area = 0;
|
||||
while ((i = b.indexOf('tkhd', i + 1)) > 3) { // one per track; the video track is the one with a size
|
||||
const end = i - 4 + b.readUInt32BE(i - 4);
|
||||
if (end <= b.length && end - (i - 4) >= 84) { const w = b.readUInt32BE(end - 8) >>> 16; const h = b.readUInt32BE(end - 4) >>> 16; if (w * h > area) { area = w * h; out.w = w; out.h = h; } }
|
||||
}
|
||||
const m = b.indexOf('mvhd');
|
||||
if (m > 3 && m + 36 <= b.length) {
|
||||
const v1 = b[m + 4] === 1; const scale = b.readUInt32BE(m + (v1 ? 24 : 16)); const d = v1 ? Number(b.readBigUInt64BE(m + 28)) : b.readUInt32BE(m + 20);
|
||||
if (scale && d) out.duration = d / scale;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const clock = (s) => { if (!s && s !== 0) return null; const t = Math.round(s); return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`; };
|
||||
// The Art Institute's image server turns away scripts that do not name themselves in this header.
|
||||
const imageHeaders = (url) => ({ 'User-Agent': UA, Accept: 'image/*', ...(/^https?:\/\/[^/]*artic\.edu\//.test(url) ? AIC_HEADERS : {}) });
|
||||
// Does the file load, is it something a browser shows, and how large is it? Reads only the first
|
||||
// 128 KB of an image, or 512 KB of a video (enough for an MP4's size and duration).
|
||||
async function probe(url, ms = 15000, video = false, again = true) {
|
||||
const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), ms);
|
||||
const want = video ? 524288 : 131072;
|
||||
try {
|
||||
const headers = video ? { 'User-Agent': UA, Accept: 'video/*' } : imageHeaders(url);
|
||||
const r = await fetch(url, { headers: { ...headers, Range: `bytes=0-${want - 1}` }, signal: ac.signal, redirect: 'follow' });
|
||||
if (!r.ok) {
|
||||
r.body?.cancel().catch(() => {});
|
||||
if (r.status >= 500 && again) { clearTimeout(timer); await sleep(1000); return probe(url, ms, video, false); } // file servers stumble now and then
|
||||
return { ok: false, why: `HTTP ${r.status}` };
|
||||
}
|
||||
const type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
||||
if (!(video ? VIDEO_TYPES : WEB_TYPES)[type]) { r.body?.cancel().catch(() => {}); return { ok: false, why: type ? `not a web ${video ? 'video' : 'image'} (${type})` : 'no content type' }; }
|
||||
const reader = r.body.getReader(); const chunks = []; let n = 0;
|
||||
while (n < want) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); n += value.length; }
|
||||
reader.cancel().catch(() => {});
|
||||
const bytes = r.status === 206 ? Number((r.headers.get('content-range') || '').split('/')[1]) || null : Number(r.headers.get('content-length')) || null;
|
||||
const head = Buffer.concat(chunks);
|
||||
return { ok: true, type, bytes, ...(video ? (type === 'video/mp4' ? mp4Info(head) : {}) : dims(head)) };
|
||||
} catch (e) {
|
||||
return { ok: false, why: e.name === 'AbortError' ? 'timed out' : e.cause?.code || e.message };
|
||||
} finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
// ---------- licences and credits ----------
|
||||
function licenceLabel(it) {
|
||||
if (it.licenseName) return it.licenseName; // set where the plain code would mislead: museums, informal statements, unusual licences
|
||||
if (it.license === 'cc0') return 'CC0';
|
||||
if (it.license === 'pd') return 'public domain';
|
||||
if (it.license === 'other') return 'licence on the source page';
|
||||
return `CC ${it.license.toUpperCase()}${it.licenseVersion ? ' ' + it.licenseVersion : ''}`;
|
||||
}
|
||||
function licenceUrl(it) {
|
||||
if (it.licenseUrl) return String(it.licenseUrl).replace(/^http:/, 'https:');
|
||||
if (it.license === 'cc0') return 'https://creativecommons.org/publicdomain/zero/1.0/';
|
||||
if (it.license === 'pd') return 'https://creativecommons.org/publicdomain/mark/1.0/';
|
||||
if (it.license.startsWith('by')) return `https://creativecommons.org/licenses/${it.license}/${it.licenseVersion || '4.0'}/`;
|
||||
return null;
|
||||
}
|
||||
const creditRequired = (it) => it.license !== 'cc0' && it.license !== 'pd';
|
||||
function licenceNotes(it) {
|
||||
const n = [];
|
||||
if (/-sa$/.test(it.license)) n.push('Share-alike: an altered version (recoloured, combined with other pictures) must be shared under the same licence.');
|
||||
if (/-nc/.test(it.license)) n.push('Non-commercial only: not for a business, a shop or a client site.');
|
||||
if (/-nd$/.test(it.license)) n.push('No derivatives: show it whole and unaltered, no crops or treatments.');
|
||||
if (it.license === 'other') n.push('A licence outside the usual set: read the source page before using it.');
|
||||
return n;
|
||||
}
|
||||
function credit(it) {
|
||||
const by = it.creator && !/^(unknown|anonymous)\b/i.test(it.creator) ? ` by ${short(it.creator, 60)}` : '';
|
||||
const date = (it.source === 'aic' || it.source === 'met') && it.date ? `, ${short(it.date, 30)}` : '';
|
||||
return `"${short(it.title, 90)}"${by}${date}, ${licenceLabel(it)}, via ${it.via}`;
|
||||
}
|
||||
function base(source, id) {
|
||||
return { ref: `${source}:${id}`, source, media: 'image', via: SOURCES[source].name, title: '', creator: '', creatorUrl: null, date: null, kind: null, about: null, license: 'other', licenseVersion: null, licenseName: null, licenseUrl: null, page: null, image: null, original: null, smaller: null, width: null, height: null, imageWidth: null, imageHeight: null, bytes: null, estimated: false, duration: null, poster: null, alt: null, notes: [], hint: null };
|
||||
}
|
||||
// Licence code and version from a licence URL (Creative Commons licences and marks).
|
||||
function licenceFromUrl(u) {
|
||||
const s = String(u || '').toLowerCase();
|
||||
if (/publicdomain\/zero/.test(s)) return { code: 'cc0', version: '1.0' };
|
||||
if (/publicdomain\/mark|publicdomain\/?$|\/publicdomain\b/.test(s)) return { code: 'pd', version: null };
|
||||
const m = s.match(/creativecommons\.org\/licenses\/(by(?:-nc)?(?:-sa|-nd)?)\/(\d\.\d)/);
|
||||
return m ? { code: m[1], version: m[2] } : { code: 'other', version: null };
|
||||
}
|
||||
// Of the renditions a source offers, the largest whose short side is at most `res` (720p by default),
|
||||
// in a format every browser plays. If all are larger, the smallest one.
|
||||
function pickRendition(list, res) {
|
||||
const playable = list.filter((r) => r.url && VIDEO_TYPES[r.type]);
|
||||
const side = (r) => (r.w && r.h ? Math.min(r.w, r.h) : Infinity);
|
||||
const fit = playable.filter((r) => side(r) <= res).sort((a, b) => side(b) - side(a) || (a.bytes ?? 1e15) - (b.bytes ?? 1e15));
|
||||
return fit[0] || playable.sort((a, b) => side(a) - side(b) || (a.bytes ?? 1e15) - (b.bytes ?? 1e15))[0] || null;
|
||||
}
|
||||
function useRendition(it, pick, largest) {
|
||||
Object.assign(it, { media: 'video', image: pick.url, mime: pick.type, imageWidth: pick.w || null, imageHeight: pick.h || null, bytes: pick.bytes || null, estimated: Boolean(pick.estimated), duration: it.duration || pick.duration || null });
|
||||
if (largest) { it.width = largest.w || it.width; it.height = largest.h || it.height; }
|
||||
return it;
|
||||
}
|
||||
|
||||
// ---------- Openverse ----------
|
||||
async function searchOpenverse(q, opts) {
|
||||
const lic = opts.licences.filter((l) => l !== 'other').map((l) => (l === 'pd' ? 'pdm' : l));
|
||||
if (!lic.length) return { total: 0, items: [], note: 'Openverse filters by Creative Commons licences and marks only.' };
|
||||
const call = (large) => {
|
||||
const p = new URLSearchParams({ q, license: lic.join(','), page_size: '20' });
|
||||
if (opts.orientation) p.set('aspect_ratio', { landscape: 'wide', portrait: 'tall', square: 'square' }[opts.orientation]);
|
||||
if (large) p.set('size', 'large');
|
||||
return getJson(`https://api.openverse.org/v1/images/?${p}`, 30000);
|
||||
};
|
||||
// Its default order is led by 1024px Flickr files. Ask for the large ones first, and fill up from the rest only if needed.
|
||||
const first = await call(true);
|
||||
let rows = first.results || []; let total = first.result_count ?? null;
|
||||
if (rows.filter((r) => !r.width || r.width >= opts.minWidth).length < opts.limit + 2) {
|
||||
const rest = await call(false).catch(() => null);
|
||||
if (rest) { total = rest.result_count ?? total; rows = [...rows, ...(rest.results || []).filter((r) => !rows.some((x) => x.id === r.id))]; }
|
||||
}
|
||||
const items = rows.map(fromOpenverse).filter((it) => it.image);
|
||||
await wikimediaSizes(items, opts.width);
|
||||
return { total, items };
|
||||
}
|
||||
function fromOpenverse(r) {
|
||||
const it = base('openverse', r.id);
|
||||
const via = r.source || r.provider;
|
||||
Object.assign(it, {
|
||||
via: PROVIDERS[via] || (String(via || '').startsWith('smithsonian') ? 'Smithsonian' : via ? String(via).replace(/_/g, ' ') : 'Openverse'),
|
||||
title: text(r.title).replace(/^File:/, '').replace(/\.(jpe?g|png|tiff?|gif|webp)$/i, '') || 'Untitled', creator: text(r.creator), creatorUrl: r.creator_url || null,
|
||||
license: r.license === 'pdm' ? 'pd' : String(r.license || 'other'), licenseVersion: r.license_version || null, licenseUrl: r.license_url || null,
|
||||
page: r.foreign_landing_url || r.detail_url, image: r.url, original: r.url,
|
||||
width: r.width || null, height: r.height || null, imageWidth: r.width || null, imageHeight: r.height || null,
|
||||
});
|
||||
if (!LICENCES.includes(it.license)) it.license = 'other';
|
||||
return it;
|
||||
}
|
||||
// Openverse points at Wikimedia originals, which can be 10 MB or a TIFF. Swap in a web-sized rendition.
|
||||
async function wikimediaSizes(items, width) {
|
||||
const wm = items.filter((it) => /^https?:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/[0-9a-f]\/[0-9a-f]{2}\//.test(it.original || ''));
|
||||
if (!wm.length) return;
|
||||
const title = (it) => { let n = noQuery(it.original).split('/').pop(); try { n = decodeURIComponent(n); } catch {} return 'File:' + n.replace(/_/g, ' '); };
|
||||
const j = await getJson(commonsApi({ titles: wm.map(title).join('|'), prop: 'imageinfo', iiprop: 'url|size|mime', iiurlwidth: String(width) })).catch(() => null);
|
||||
if (!j?.query) return;
|
||||
const normal = new Map((j.query.normalized || []).map((n) => [n.from, n.to]));
|
||||
const info = new Map((j.query.pages || []).map((pg) => [pg.title, pg.imageinfo?.[0]]));
|
||||
for (const it of wm) { const t = title(it); const ii = info.get(normal.get(t) || t); if (ii) applyThumb(it, ii); }
|
||||
}
|
||||
|
||||
// ---------- Wikimedia Commons ----------
|
||||
function commonsApi(params) { return `https://commons.wikimedia.org/w/api.php?${new URLSearchParams({ action: 'query', format: 'json', formatversion: '2', origin: '*', ...params })}`; }
|
||||
function commonsInfo(width) {
|
||||
return { prop: 'imageinfo', iiprop: 'url|size|mime|extmetadata', iiurlwidth: String(width), iiextmetadatalanguage: 'en',
|
||||
iiextmetadatafilter: 'ObjectName|Artist|LicenseShortName|License|LicenseUrl|Restrictions|DateTimeOriginal|ImageDescription' };
|
||||
}
|
||||
async function searchCommons(q, opts) {
|
||||
let s = `${q} filetype:bitmap`;
|
||||
if (opts.minWidth > 1) s += ` filew:>${opts.minWidth - 1}`;
|
||||
const j = await getJson(commonsApi({ generator: 'search', gsrnamespace: '6', gsrsearch: s, gsrlimit: '30', ...commonsInfo(opts.width) }));
|
||||
if (j.error) throw new Error(j.error.info || 'the search failed');
|
||||
const pages = (j.query?.pages || []).sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
|
||||
return { total: null, items: pages.map(fromCommons).filter(Boolean) };
|
||||
}
|
||||
function fromCommons(pg) {
|
||||
const ii = pg?.imageinfo?.[0]; if (!ii) return null;
|
||||
const m = (k) => text(ii.extmetadata?.[k]?.value);
|
||||
const lic = commonsLicence(m('License'), m('LicenseShortName'));
|
||||
const it = base('commons', pg.pageid);
|
||||
Object.assign(it, {
|
||||
title: m('ObjectName').replace(/\s*\b(?:title|label) QS:.*$/s, '') || pg.title.replace(/^File:/, '').replace(/\.[a-z0-9]+$/i, ''), creator: m('Artist'),
|
||||
date: m('DateTimeOriginal').replace(/\s*\bdate QS:.*$/s, '').replace(/^(\d{4}-\d{2}-\d{2})[ T]\d{2}:\d{2}.*$/, '$1') || null, about: m('ImageDescription') || null,
|
||||
license: lic.code, licenseVersion: lic.version, licenseName: lic.name, licenseUrl: m('LicenseUrl') || null, page: ii.descriptionurl,
|
||||
});
|
||||
applyThumb(it, ii);
|
||||
if (lic.informal) it.notes.push('Not a formal licence: the archive that published it states it knows of no copyright restrictions.');
|
||||
if (it.about && it.about.toLowerCase().startsWith(it.title.toLowerCase()) && it.about.length < it.title.length + 12) it.about = null;
|
||||
const restrictions = m('Restrictions');
|
||||
if (restrictions) it.notes.push(`Restrictions named on the source page (${restrictions}): personality rights, a trademark or similar. Read it before use.`);
|
||||
if (/^pd-us/i.test(m('License'))) it.notes.push('Public domain in the United States; check the source page before using it elsewhere.');
|
||||
return it;
|
||||
}
|
||||
function commonsLicence(license, shortName) {
|
||||
const l = String(license || '').toLowerCase(); const s = String(shortName || '').toLowerCase();
|
||||
const version = (shortName.match(/\b(\d\.\d)\b/) || l.match(/(\d\.\d)/) || [])[1] || null;
|
||||
if (/^cc0|^cc-zero/.test(l) || /^cc0/.test(s)) return { code: 'cc0', version: '1.0' };
|
||||
if (/^pd|public domain/.test(l) || /public domain/.test(s)) return { code: 'pd', version: null };
|
||||
// Flickr Commons and similar archives: no formal licence, the institution states it knows of no copyright
|
||||
if (/^no (known copyright )?restrictions/.test(s)) return { code: 'pd', version: null, name: 'no known copyright restrictions', informal: true };
|
||||
const cc = l.match(/^cc-(by(?:-nc)?(?:-sa|-nd)?)(?:-|$)/) || s.match(/^cc (by(?:-nc)?(?:-sa|-nd)?)\b/);
|
||||
if (cc) return { code: cc[1], version };
|
||||
return { code: 'other', version: null, name: shortName || license || null };
|
||||
}
|
||||
// Commons serves thumbnails only at standard widths (1280, 1920, 3840...); the API picks the step.
|
||||
function applyThumb(it, ii) {
|
||||
it.width = ii.width || it.width; it.height = ii.height || it.height; it.original = noQuery(ii.url) || it.original;
|
||||
const tw = Number(String(ii.thumburl || '').split('?')[0].match(/(\d+)px-[^/]*$/)?.[1]) || 0;
|
||||
if (ii.thumburl && tw && ii.width && (tw < ii.width || (ii.mime && !WEB_TYPES[ii.mime]))) {
|
||||
it.image = noQuery(ii.thumburl); it.imageWidth = Math.min(tw, ii.width); it.imageHeight = ii.height ? Math.round((ii.height * it.imageWidth) / ii.width) : null;
|
||||
} else { it.image = it.original; it.imageWidth = ii.width || null; it.imageHeight = ii.height || null; }
|
||||
}
|
||||
|
||||
// ---------- Art Institute of Chicago ----------
|
||||
async function searchAic(q, opts) {
|
||||
const wanted = queryWords(q);
|
||||
const run = (match) => getJson(`https://api.artic.edu/api/v1/artworks/search?params=${encodeURIComponent(JSON.stringify({
|
||||
query: { bool: {
|
||||
// the long description field is left out on purpose: it matches almost anything
|
||||
must: [{ multi_match: { query: wanted.join(' ') || q, type: 'cross_fields', fields: ['title^3', 'subject_titles^2', 'term_titles^2', 'classification_titles', 'medium_display', 'artist_title', 'place_of_origin', 'date_display'], ...match } }],
|
||||
filter: [{ term: { is_public_domain: true } }, { exists: { field: 'image_id' } }],
|
||||
} },
|
||||
fields: AIC_FIELDS, limit: 30,
|
||||
}))}`, 20000, AIC_HEADERS);
|
||||
// every word must match; with three words or more, one may be missing. Looser than that returns noise.
|
||||
let j = await run({ operator: 'and' });
|
||||
if (!(j.data || []).length && wanted.length >= 3) j = await run({ operator: 'or', minimum_should_match: wanted.length - 1 });
|
||||
const iiif = j.config?.iiif_url || 'https://www.artic.edu/iiif/2';
|
||||
return { total: j.pagination?.total ?? null, items: (j.data || []).map((d) => fromAic(d, iiif, opts.width)).filter(Boolean) };
|
||||
}
|
||||
function fromAic(d, iiif, width) {
|
||||
if (!d?.image_id || d.is_public_domain === false) return null;
|
||||
const it = base('aic', d.id);
|
||||
const W = d.thumbnail?.width || null; const H = d.thumbnail?.height || null;
|
||||
const cap = W ? Math.min(W, 3000) : 3000; const w = Math.min(width, cap);
|
||||
Object.assign(it, {
|
||||
title: text(d.title) || 'Untitled', creator: text(d.artist_title) || text(String(d.artist_display || '').split('\n')[0]),
|
||||
date: text(d.date_display) || null, kind: [d.artwork_type_title, d.medium_display].filter(Boolean).map(text).join(', ') || null,
|
||||
license: 'cc0', licenseVersion: '1.0', licenseName: 'public domain (CC0)', licenseUrl: 'https://creativecommons.org/publicdomain/zero/1.0/',
|
||||
page: `https://www.artic.edu/artworks/${d.id}`, image: `${iiif}/${d.image_id}/full/${w},/0/default.jpg`, original: `${iiif}/${d.image_id}/full/${cap},/0/default.jpg`,
|
||||
width: W && H ? cap : null, height: W && H ? Math.round((H * cap) / W) : null, imageWidth: W && H ? w : null, imageHeight: W && H ? Math.round((H * w) / W) : null,
|
||||
alt: text(d.thumbnail?.alt_text) || null,
|
||||
});
|
||||
it.hint = 'Download it with --get: the museum\'s image server answers browsers and this script, and refuses plain curl or wget.';
|
||||
return it;
|
||||
}
|
||||
|
||||
// ---------- The Met ----------
|
||||
async function searchMet(q, opts) {
|
||||
const s = await getJson(`https://collectionapi.metmuseum.org/public/collection/v1.1/search?${new URLSearchParams({ q, hasImages: 'true', limit: String(Math.min(40, opts.limit * 4 + 8)), offset: '0' })}`);
|
||||
const ids = (s.objectIDs || []).slice(0, opts.limit * 4 + 8);
|
||||
const objects = await mapLimit(ids, 8, (id) => getJson(`https://collectionapi.metmuseum.org/public/collection/v1/objects/${id}`, 15000).catch(() => null));
|
||||
// The museum's search reads every field of a record, so much of what it returns is off the subject.
|
||||
// Keep the objects whose own name, material, maker, place or tags carry the words of the query.
|
||||
const wanted = queryWords(q);
|
||||
const about = (x) => [x.title, x.objectName, x.medium, x.classification, x.culture, x.period, x.artistDisplayName, x.department, x.city, x.country, x.region, x.objectDate, ...(x.tags || []).map((t) => t.term)].filter(Boolean).join(' ');
|
||||
return { total: s.total ?? null, items: objects.filter((x) => x && onSubject(wanted, about(x))).map(fromMet).filter(Boolean) };
|
||||
}
|
||||
function fromMet(x) {
|
||||
if (!x || !x.isPublicDomain || !x.primaryImage) return null;
|
||||
const it = base('met', x.objectID);
|
||||
const fix = (u) => (u ? encodeURI(u).replace(/%25([0-9a-f]{2})/gi, '%$1') : null);
|
||||
Object.assign(it, {
|
||||
title: text(x.title) || text(x.objectName) || 'Untitled', creator: text(x.artistDisplayName) || (x.culture ? `${text(x.culture)} maker` : ''),
|
||||
date: text(x.objectDate) || null, kind: [x.objectName || x.classification, x.medium].filter(Boolean).map(text).join(', ') || null,
|
||||
license: 'cc0', licenseVersion: '1.0', licenseName: 'public domain (CC0)', licenseUrl: 'https://creativecommons.org/publicdomain/zero/1.0/',
|
||||
page: x.objectURL || `https://www.metmuseum.org/art/collection/search/${x.objectID}`,
|
||||
image: fix(x.primaryImage), original: fix(x.primaryImage), smaller: x.primaryImageSmall && x.primaryImageSmall !== x.primaryImage ? fix(x.primaryImageSmall) : null,
|
||||
});
|
||||
return it;
|
||||
}
|
||||
|
||||
// ---------- video: Wikimedia Commons ----------
|
||||
async function searchCommonsVideo(q, opts) {
|
||||
const j = await getJson(commonsApi({ generator: 'search', gsrnamespace: '6', gsrsearch: `${q} filetype:video`, gsrlimit: '30', ...commonsInfo(1280), prop: 'imageinfo|videoinfo', viprop: 'derivatives' }));
|
||||
if (j.error) throw new Error(j.error.info || 'the search failed');
|
||||
const pages = (j.query?.pages || []).sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
|
||||
return { total: null, items: pages.map((pg) => fromCommonsVideo(pg, opts)).filter(Boolean) };
|
||||
}
|
||||
function fromCommonsVideo(pg, opts) {
|
||||
const it = fromCommons(pg); const ii = pg?.imageinfo?.[0];
|
||||
if (!it || !ii) return null;
|
||||
it.poster = it.image !== it.original ? it.image : null; // for a video file, the thumbnail is a still frame
|
||||
it.duration = Number(ii.duration) || null;
|
||||
// the original plus the transcodes Commons makes of every video (240p to 1080p webm); sizes from their bit rates
|
||||
const renditions = (pg.videoinfo?.[0]?.derivatives || []).map((d) => {
|
||||
const url = noQuery(d.src); const original = !/\/transcoded\//.test(url);
|
||||
return { url, type: String(d.type || '').split(';')[0].trim(), w: Number(d.width) || null, h: Number(d.height) || null,
|
||||
bytes: original ? Number(ii.size) || null : d.bandwidth && it.duration ? Math.round((d.bandwidth * it.duration) / 8) : null, estimated: !original };
|
||||
});
|
||||
const pick = pickRendition(renditions, opts.res);
|
||||
return pick ? useRendition(it, pick, { w: ii.width, h: ii.height }) : null;
|
||||
}
|
||||
|
||||
// ---------- video: Internet Archive ----------
|
||||
const first = (v) => (Array.isArray(v) ? v[0] : v);
|
||||
const seconds = (s) => {
|
||||
if (s === undefined || s === null || s === '') return null;
|
||||
if (/^\d+(\.\d+)?$/.test(String(s))) return Number(s);
|
||||
const p = String(s).split(':').map(Number); return p.length > 1 && p.every(Number.isFinite) ? p.reduce((a, x) => a * 60 + x, 0) : null;
|
||||
};
|
||||
// Only the Prelinger Archives: curated industrial, educational, advertising and home films, mostly in the
|
||||
// public domain. The rest of the archive's films are uploads by anyone, and their licence claims go
|
||||
// unchecked (an upload called "... all rights reserved" can carry a public-domain mark), so they are left out.
|
||||
const ARCHIVE_LICENCE = { cc0: '*publicdomain\\/zero*', pd: '*publicdomain*', by: '*licenses\\/by\\/*', 'by-sa': '*licenses\\/by-sa\\/*', 'by-nc': '*licenses\\/by-nc\\/*', 'by-nd': '*licenses\\/by-nd\\/*', 'by-nc-sa': '*licenses\\/by-nc-sa\\/*', 'by-nc-nd': '*licenses\\/by-nc-nd\\/*' };
|
||||
async function searchArchive(q, opts) {
|
||||
const words = queryWords(q);
|
||||
const licences = opts.licences.includes('other') ? '' : ` AND licenseurl:(${[...new Set(opts.licences.map((l) => ARCHIVE_LICENCE[l]))].join(' OR ')})`;
|
||||
const p = new URLSearchParams({ q: `(${(words.length ? words : [q]).join(' AND ')}) AND mediatype:(movies) AND collection:(prelinger)${licences}`, rows: '40', output: 'json' });
|
||||
for (const f of ['identifier', 'licenseurl']) p.append('fl[]', f);
|
||||
const j = await getJson(`https://archive.org/advancedsearch.php?${p}`, 30000);
|
||||
if (j.error) throw new Error(`its search is failing right now (${short(text(j.error), 80)}); try again later`);
|
||||
const docs = j.response?.docs || [];
|
||||
const allowed = docs.filter((d) => opts.licences.includes(licenceFromUrl(first(d.licenseurl)).code));
|
||||
const metas = await mapLimit(allowed.slice(0, opts.limit * 2 + 4), 6, (d) => getJson(`https://archive.org/metadata/${encodeURIComponent(d.identifier)}`, 20000).catch(() => null));
|
||||
return { total: j.response?.numFound ?? null, skippedLicence: docs.length - allowed.length, items: metas.map((m) => fromArchive(m, opts)).filter(Boolean) };
|
||||
}
|
||||
function fromArchive(m, opts) {
|
||||
const md = m?.metadata; if (!md?.identifier || !Array.isArray(m.files)) return null;
|
||||
const id = md.identifier;
|
||||
const fileUrl = (name) => `https://archive.org/download/${encodeURIComponent(id)}/${String(name).split('/').map(encodeURIComponent).join('/')}`;
|
||||
const isVideo = (f) => /\.(mp4|m4v|webm|mpe?g|avi|mov|ogv|mkv|wmv|flv)$/i.test(f.name || '');
|
||||
// an item can hold several films: keep the first original and the web copies made from it
|
||||
const originals = m.files.filter((f) => f.source === 'original' && isVideo(f));
|
||||
const stem = originals[0] ? originals[0].name.replace(/\.[^.]+$/, '') : null;
|
||||
const family = m.files.filter((f) => isVideo(f) && (!stem || f.name.startsWith(stem)));
|
||||
const renditions = family.filter((f) => /\.(mp4|webm)$/i.test(f.name)).map((f) => ({ url: fileUrl(f.name), type: /\.webm$/i.test(f.name) ? 'video/webm' : 'video/mp4', w: Number(f.width) || null, h: Number(f.height) || null, bytes: Number(f.size) || null, duration: seconds(f.length) }));
|
||||
const pick = pickRendition(renditions, opts.res); if (!pick) return null;
|
||||
const largest = family.reduce((a, f) => ((Number(f.width) || 0) > (a.w || 0) ? { w: Number(f.width), h: Number(f.height) } : a), { w: pick.w, h: pick.h });
|
||||
const lic = licenceFromUrl(first(md.licenseurl));
|
||||
const it = base('archive', id);
|
||||
Object.assign(it, {
|
||||
title: text(first(md.title)) || id, creator: text(first(md.creator)), date: text(first(md.date) || first(md.year)) || null, about: text(first(md.description)) || null,
|
||||
license: lic.code, licenseVersion: lic.version, licenseUrl: first(md.licenseurl) || null, page: `https://archive.org/details/${id}`,
|
||||
original: fileUrl((originals[0] || family[0]).name), duration: pick.duration || seconds(first(md.runtime)),
|
||||
poster: null, // the archive's own stills are about 160 pixels wide: too small to stand in for a clip
|
||||
});
|
||||
// the same film is often stored in several formats; different names mean different films
|
||||
const films = new Set(originals.map((f) => f.name.split('/').pop().split('.')[0])).size;
|
||||
if (films > 1) it.notes.push(`This item holds ${films} films; the first is shown. The page lists the others.`);
|
||||
return useRendition(it, pick, largest);
|
||||
}
|
||||
|
||||
// ---------- video: NASA Image and Video Library ----------
|
||||
const nasaUrl = (u) => (u ? encodeURI(String(u).replace(/^http:/, 'https:')).replace(/%25([0-9a-f]{2})/gi, '%$1') : null);
|
||||
async function searchNasa(q, opts) {
|
||||
const j = await getJson(`https://images-api.nasa.gov/search?${new URLSearchParams({ q, media_type: 'video', page_size: String(Math.min(100, opts.limit * 3 + 6)) })}`);
|
||||
// its search matches any word anywhere; keep the clips whose own title, description or keywords carry the query
|
||||
const wanted = queryWords(q);
|
||||
const rows = (j.collection?.items || []).filter((x) => { const d = x.data?.[0] || {}; return onSubject(wanted, [d.title, d.description, ...(d.keywords || [])].join(' ')); }).slice(0, opts.limit * 2 + 4);
|
||||
const items = await mapLimit(rows, 6, async (x) => fromNasa(x, await getJson(nasaUrl(x.href), 15000).catch(() => null), opts));
|
||||
return { total: j.collection?.metadata?.total_hits ?? null, items: items.filter(Boolean) };
|
||||
}
|
||||
function fromNasa(x, files, opts) {
|
||||
const d = x?.data?.[0]; if (!d?.nasa_id || !Array.isArray(files)) return null;
|
||||
const find = (suffix) => files.find((f) => String(f).endsWith(suffix));
|
||||
// NASA makes the same renditions of every video; these are their 16:9 sizes, and the check reads the real ones
|
||||
const renditions = [['~large.mp4', 1920, 1080], ['~medium.mp4', 1280, 720], ['~small.mp4', 640, 360], ['~mobile.mp4', 320, 180]]
|
||||
.filter(([s]) => find(s)).map(([s, w, h]) => ({ url: nasaUrl(find(s)), type: 'video/mp4', w, h }));
|
||||
const pick = pickRendition(renditions, opts.res); if (!pick) return null;
|
||||
const it = base('nasa', encodeURIComponent(d.nasa_id));
|
||||
Object.assign(it, {
|
||||
title: text(d.title) || d.nasa_id, creator: text(d.photographer || d.secondary_creator) || `NASA${d.center ? ' ' + d.center : ''}`,
|
||||
date: String(d.date_created || '').slice(0, 10) || null, about: text(d.description) || null,
|
||||
license: 'pd', licenseName: 'public domain (NASA)', licenseUrl: 'https://www.nasa.gov/nasa-brand-center/images-and-media/',
|
||||
page: `https://images.nasa.gov/details/${encodeURIComponent(d.nasa_id)}`, original: nasaUrl(find('~orig.mp4')) || pick.url,
|
||||
poster: nasaUrl(find('~large.jpg') || find('~medium.jpg') || find('~thumb.jpg')),
|
||||
});
|
||||
it.notes.push('NASA footage is generally free of copyright in the US. Never use NASA logos or suggest NASA endorses the client; check the page for third-party material.');
|
||||
return useRendition(it, pick, renditions[0]);
|
||||
}
|
||||
function videoNotes(it, opts) {
|
||||
const n = [];
|
||||
const smaller = it.imageWidth && it.imageHeight && Math.min(it.imageWidth, it.imageHeight) > 480;
|
||||
if (it.bytes && it.bytes > opts.maxMb * 1048576) n.push(`Over ${opts.maxMb} MB (${it.estimated ? 'about ' : ''}${size(it.bytes)}): too heavy for a page, and --get refuses it unless you pass --max-mb. ${smaller ? 'Try --res 480, or a shorter clip.' : 'Choose a shorter clip.'}`);
|
||||
if (it.duration && it.duration > 60) n.push(`Long (${clock(it.duration)}): a loop on a page wants 5 to 20 seconds. Prefer a shorter clip, or trim one with ffmpeg if it is installed.`);
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---------- search ----------
|
||||
function fits(it, opts, skipped) {
|
||||
const W = it.width || it.imageWidth; const H = it.height || it.imageHeight;
|
||||
if (W && W < opts.minWidth) { skipped.small++; return false; }
|
||||
if (opts.orientation && W && H && shape(W, H) !== opts.orientation) { skipped.shape++; return false; }
|
||||
return true;
|
||||
}
|
||||
async function search(query, opts) {
|
||||
const runners = opts.video ? { commons: searchCommonsVideo, archive: searchArchive, nasa: searchNasa } : { openverse: searchOpenverse, commons: searchCommons, aic: searchAic, met: searchMet };
|
||||
// a slow service never holds the whole search: each source gets 45 s, the check 30 s
|
||||
const deadline = (ms, message) => new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms).unref());
|
||||
const results = await Promise.all(opts.sources.map(async (key) => {
|
||||
try { return { key, ...(await Promise.race([runners[key](query, opts), deadline(45000, 'no complete answer within 45 s; try again or search another --source')])) }; }
|
||||
catch (e) { return { key, error: e.message, items: [] }; }
|
||||
}));
|
||||
// short clips first, then longer ones, then anything over the size cap; the source's own order within each
|
||||
const cap = opts.maxMb * 1048576;
|
||||
const rank = (it) => (it.media !== 'video' ? 0 : it.bytes && it.bytes > cap ? 4 : !it.duration ? 3 : it.duration <= 30 ? 0 : it.duration <= 90 ? 1 : it.duration <= 300 ? 2 : 3);
|
||||
const byRank = (a, b) => rank(a) - rank(b);
|
||||
const seen = new Set();
|
||||
for (const r of results) {
|
||||
r.skipped = { licence: r.skippedLicence || 0, small: 0, shape: 0, dead: 0, alike: 0 };
|
||||
const titles = new Map();
|
||||
r.items = r.items.filter((it) => {
|
||||
if (!opts.licences.includes(it.license)) { r.skipped.licence++; return false; }
|
||||
const k = keyOf(it.original || it.image); if (seen.has(k)) return false; seen.add(k);
|
||||
if (!fits(it, opts, r.skipped)) return false;
|
||||
// one scanned book can fill a whole page of results: keep two pictures per title
|
||||
const t = it.title.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 32); const n = (titles.get(t) || 0) + 1; titles.set(t, n);
|
||||
if (t && n > 2) { r.skipped.alike++; return false; }
|
||||
return true;
|
||||
}).sort(byRank).slice(0, opts.limit + 4); // a few spares for files that turn out not to load
|
||||
}
|
||||
if (opts.check) {
|
||||
await Promise.race([mapLimit(results.flatMap((r) => r.items), 8, async (it) => {
|
||||
const video = it.media === 'video';
|
||||
const p = await probe(it.image, video ? 15000 : 12000, video); it.check = p;
|
||||
if (p.ok && p.w && p.h) {
|
||||
it.imageWidth = p.w; it.imageHeight = p.h;
|
||||
if (!it.width || it.image === it.original) { it.width = p.w; it.height = p.h; }
|
||||
}
|
||||
if (p.ok && p.bytes) { it.bytes = p.bytes; it.estimated = false; }
|
||||
if (p.ok && p.duration) it.duration = p.duration;
|
||||
}), new Promise((r) => setTimeout(r, 30000).unref())]);
|
||||
for (const r of results) r.items = r.items.filter((it) => (it.check?.ok ? fits(it, opts, r.skipped) : (r.skipped.dead++, (r.skipped.why = it.check?.why || 'not checked within 30 s'), false))).sort(byRank);
|
||||
}
|
||||
for (const r of results) r.items = r.items.slice(0, opts.limit);
|
||||
const shown = results.reduce((n, r) => n + r.items.length, 0);
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify({ query, options: { media: opts.video ? 'video' : 'image', sources: opts.sources, licences: opts.licences, orientation: opts.orientation, minWidth: opts.minWidth, width: opts.width, res: opts.video ? opts.res : undefined, maxMb: opts.video ? opts.maxMb : undefined, limit: opts.limit, checked: opts.check },
|
||||
sources: results.map((r) => ({ source: r.key, name: SOURCES[r.key].name, total: r.total ?? null, error: r.error || null, skipped: r.skipped,
|
||||
items: r.items.map(({ check, ...it }) => ({ ...it, licenseLabel: licenceLabel(it), licenseUrl: licenceUrl(it), creditRequired: creditRequired(it), credit: credit(it), notes: [...it.notes, ...licenceNotes(it), ...(it.media === 'video' ? videoNotes(it, opts) : [])] })) })) }, null, 1));
|
||||
return shown ? 0 : 1;
|
||||
}
|
||||
|
||||
const filters = opts.video
|
||||
? [`licences ${opts.licences.join(', ')}`, `at least ${opts.minWidth}px wide`, opts.orientation, `renditions up to ${opts.res}p`, `flagged over ${opts.maxMb} MB`, opts.check ? 'every file checked' : 'files not checked']
|
||||
: [`licences ${opts.licences.join(', ')}`, `at least ${opts.minWidth}px wide`, opts.orientation, `image URLs ${opts.width}px wide where the source can size them`, opts.check ? 'every image checked' : 'images not checked'];
|
||||
console.log(`ASSETS${opts.video ? ' · VIDEO' : ''} · "${query}" · ${filters.filter(Boolean).join(' · ')}`);
|
||||
for (const r of results) {
|
||||
const src = SOURCES[r.key];
|
||||
console.log('');
|
||||
if (r.error) { console.log(`## ${src.name}: could not search it (${r.error})`); continue; }
|
||||
const dropped = [r.skipped.small && `${r.skipped.small} narrower than ${opts.minWidth}px`, r.skipped.shape && `${r.skipped.shape} not ${opts.orientation}`, r.skipped.licence && `${r.skipped.licence} under other licences`, r.skipped.alike && `${r.skipped.alike} more with the same title`, r.skipped.dead && `${r.skipped.dead} did not load (${r.skipped.why})`].filter(Boolean);
|
||||
const count = r.total != null ? `${r.total} matches, ${r.items.length} shown` : `${r.items.length} shown`;
|
||||
console.log(`## ${src.name}: ${r.items.length ? count : 'nothing usable'}${dropped.length ? ` (dropped: ${dropped.join(', ')})` : ''}`);
|
||||
if (r.note) console.log(r.note);
|
||||
if (!r.items.length) continue;
|
||||
console.log(`${(opts.video && src.videoNote) || src.note}`);
|
||||
for (const it of r.items) {
|
||||
console.log('');
|
||||
console.log(`${it.ref} ${short(it.title, 100)}${it.date ? ` (${short(it.date, 30)})` : ''}`);
|
||||
if (it.kind) console.log(` what ${short(it.kind, 110)}`);
|
||||
if (it.about) console.log(` about ${short(it.about, 150)}`);
|
||||
const pixels = it.imageWidth && it.imageHeight ? `${it.imageWidth} × ${it.imageHeight}` : 'size unknown';
|
||||
const largest = it.width && it.height && it.width !== it.imageWidth ? `; largest ${it.width} × ${it.height}` : '';
|
||||
const sh = shape(it.width || it.imageWidth, it.height || it.imageHeight);
|
||||
if (it.media === 'video') {
|
||||
const facts = [pixels, it.duration ? clock(it.duration) : 'length unknown', it.bytes ? `${it.estimated ? 'about ' : ''}${size(it.bytes)}` : null, VIDEO_TYPES[it.mime]];
|
||||
console.log(` file ${it.image}`);
|
||||
console.log(` size ${facts.filter(Boolean).join(', ')}${largest}${sh ? `; ${sh}` : ''}`);
|
||||
if (it.poster) console.log(` poster ${it.poster}`);
|
||||
} else {
|
||||
console.log(` image ${it.image}`);
|
||||
console.log(` size ${pixels}${it.bytes ? `, ${size(it.bytes)}` : ''}${largest}${sh ? `; ${sh}` : ''}`);
|
||||
if (it.smaller) console.log(` smaller ${it.smaller}`);
|
||||
}
|
||||
console.log(` page ${it.page}`);
|
||||
const lu = licenceUrl(it);
|
||||
console.log(` licence ${licenceLabel(it)}${lu ? ` (${lu})` : ''}; ${creditRequired(it) ? 'credit required' : 'no credit required, credit anyway'}`);
|
||||
console.log(` credit ${credit(it)}`);
|
||||
for (const n of [...(it.media === 'video' ? videoNotes(it, opts) : []), ...it.notes, ...licenceNotes(it), ...(it.hint ? [it.hint] : [])]) console.log(` note ${n}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
if (results.every((r) => r.error)) {
|
||||
console.log('No source could be reached. Check the network and try again; if this machine has no internet access, draw the');
|
||||
console.log('pictures instead (references/imagery.md) and say in your report that the open collections could not be searched.');
|
||||
return 1;
|
||||
}
|
||||
if (!shown && opts.video) {
|
||||
console.log('Nothing usable came back. Name the action or the process ("coffee roasting", "timber framing", "river"); use fewer');
|
||||
console.log('words; try a smaller --min-width or another --source. A page does not need video: a still picture or a drawing is fine.');
|
||||
return 1;
|
||||
}
|
||||
if (!shown) {
|
||||
console.log('Nothing usable came back. Try fewer words; the place\'s own name for things ("azulejo", not "blue tile"); the place and a');
|
||||
console.log('medium ("ghent postcard", "ghent old map", "ghent engraving"); a decade ("rotterdam harbour 1920"); a smaller --min-width;');
|
||||
console.log('or another --source. The museum sources match one or two words best ("fig", "harbour"). If nothing honest exists, draw it.');
|
||||
return 1;
|
||||
}
|
||||
if (opts.video) console.log('Next. Watch before you choose: --get downloads the clip and a poster frame into the project and records the credit.');
|
||||
else console.log('Next. Look before you choose: --get downloads into the project and records the credit; open the file to see it.');
|
||||
console.log(` ${CMD} --get <ref>[,<ref>...]${o.toArg}`);
|
||||
console.log(` ${CMD} --credits${o.toArg} the footer credit line and the list for your report`);
|
||||
console.log(`These ${opts.video ? 'clips' : 'pictures'} show the subject's world. Never present one as the client's own product, room, dish, person or team.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------- get ----------
|
||||
function creditsPath(opts) { return join(opts.to, 'credits.json'); }
|
||||
function readCredits(opts) { try { const j = JSON.parse(readFileSync(creditsPath(opts), 'utf8')); return Array.isArray(j) ? j : []; } catch { return []; } }
|
||||
// A file name in the target folder that is free, or already belongs to this same picture.
|
||||
function targetFile(opts, stem, ext, ref, credits) {
|
||||
let file = join(opts.to, `${stem}.${ext}`);
|
||||
for (let k = 2; existsSync(file) && credits.find((c) => c.file === rel(file))?.ref !== ref; k++) file = join(opts.to, `${stem}-${k}.${ext}`);
|
||||
return file;
|
||||
}
|
||||
function recordCredit(opts, credits, entry) { writeFileSync(creditsPath(opts), JSON.stringify([...credits.filter((c) => c.file !== entry.file), entry], null, 1)); }
|
||||
async function getOne(ref, opts) {
|
||||
const m = String(ref).match(/^(openverse|commons|aic|met|archive|nasa):(.+)$/);
|
||||
if (!m) throw new Error('a ref looks like openverse:<id>, commons:<id>, aic:<id>, met:<id>, archive:<id> or nasa:<id>, as a search prints it.');
|
||||
const [, src, id] = m;
|
||||
let it;
|
||||
if (src === 'openverse') { it = fromOpenverse(await getJson(`https://api.openverse.org/v1/images/${encodeURIComponent(id)}/`, 30000)); await wikimediaSizes([it], opts.width); }
|
||||
else if (src === 'commons') {
|
||||
// the same ref form serves pictures and videos; the record says which
|
||||
const j = await getJson(commonsApi({ pageids: id, ...commonsInfo(opts.width), iiprop: 'url|size|mime|mediatype|extmetadata', prop: 'imageinfo|videoinfo', viprop: 'derivatives' }));
|
||||
const pg = j.query?.pages?.[0]; const ii = pg?.imageinfo?.[0];
|
||||
it = ii && (ii.mediatype === 'VIDEO' || /^video\//.test(ii.mime || '')) ? fromCommonsVideo(pg, opts) : fromCommons(pg);
|
||||
}
|
||||
else if (src === 'aic') { const j = await getJson(`https://api.artic.edu/api/v1/artworks/${encodeURIComponent(id)}?fields=${AIC_FIELDS.join(',')}`, 20000, AIC_HEADERS); it = fromAic(j.data, j.config?.iiif_url || 'https://www.artic.edu/iiif/2', opts.width); }
|
||||
else if (src === 'archive') it = fromArchive(await getJson(`https://archive.org/metadata/${encodeURIComponent(id)}`, 30000), opts);
|
||||
else if (src === 'nasa') {
|
||||
const j = await getJson(`https://images-api.nasa.gov/search?${new URLSearchParams({ nasa_id: decodeURIComponent(id), media_type: 'video' })}`);
|
||||
const x = j.collection?.items?.[0];
|
||||
it = x ? fromNasa(x, await getJson(nasaUrl(x.href), 15000), opts) : null;
|
||||
}
|
||||
else {
|
||||
it = fromMet(await getJson(`https://collectionapi.metmuseum.org/public/collection/v1/objects/${encodeURIComponent(id)}`));
|
||||
if (it?.smaller) { const s = await probe(it.smaller); if (s.ok && s.w >= opts.width * 0.9) it.image = it.smaller; }
|
||||
}
|
||||
if (!it || !it.image) throw new Error(`no usable ${['archive', 'nasa'].includes(src) ? 'video (no rendition a browser plays, or the record is gone)' : 'image (not public domain, or the record has no picture)'}.`);
|
||||
if (!opts.licences.includes(it.license)) throw new Error(`its licence is ${licenceLabel(it)}, outside the ones allowed (${opts.licences.join(', ')}). Pass --license all to take it anyway, and say why in your report.`);
|
||||
if (it.media === 'video') return getVideo(it, src, id, opts);
|
||||
let r;
|
||||
try { r = await fetch(it.image, { headers: imageHeaders(it.image), signal: AbortSignal.timeout(90000) }); }
|
||||
catch (e) { throw new Error(`the image did not download (${e.name === 'TimeoutError' ? 'timed out' : e.cause?.code || e.message}).`); }
|
||||
if (!r.ok) throw new Error(`the image did not load (HTTP ${r.status}).`);
|
||||
const type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
||||
const ext = WEB_TYPES[type];
|
||||
if (!ext) throw new Error(`the file is ${type || 'of no stated type'}, not a web image.`);
|
||||
const buf = Buffer.from(await r.arrayBuffer());
|
||||
if (!buf.length) throw new Error('the image was empty.');
|
||||
const d = dims(buf);
|
||||
mkdirSync(opts.to, { recursive: true });
|
||||
const credits = readCredits(opts);
|
||||
const stem = slug(args.name || it.title) || `${src}-${slug(id).slice(0, 12)}`;
|
||||
const file = targetFile(opts, stem, ext, it.ref, credits);
|
||||
writeFileSync(file, buf);
|
||||
const entry = {
|
||||
file: rel(file), media: 'image', ref: it.ref, title: it.title, creator: it.creator || null, creatorUrl: it.creatorUrl, date: it.date,
|
||||
license: licenceLabel(it), licenseCode: it.license, licenseUrl: licenceUrl(it), creditRequired: creditRequired(it),
|
||||
page: it.page, via: it.via, image: it.image, width: d.w || it.imageWidth, height: d.h || it.imageHeight, bytes: buf.length,
|
||||
credit: credit(it), notes: [...it.notes, ...licenceNotes(it)], saved: new Date().toISOString(),
|
||||
};
|
||||
recordCredit(opts, credits, entry);
|
||||
const alt = it.alt || it.title;
|
||||
console.log(`Saved ${entry.file}${entry.width ? ` · ${entry.width} × ${entry.height}` : ''} · ${size(buf.length)}`);
|
||||
console.log(` page ${it.page}`);
|
||||
console.log(` credit ${entry.credit}`);
|
||||
console.log(` markup <img src="${esc(entry.file)}"${entry.width ? ` width="${entry.width}" height="${entry.height}"` : ''} alt="${esc(short(alt, 120))}" loading="lazy" decoding="async">`);
|
||||
console.log(' (rewrite the alt to say what the picture shows; drop loading="lazy" on the hero image)');
|
||||
for (const n of entry.notes) console.log(` note ${n}`);
|
||||
}
|
||||
// A clip: check its size against the cap first, stream it to disk (stopping at the cap), then save a still
|
||||
// frame from the source as its poster.
|
||||
async function getVideo(it, src, id, opts) {
|
||||
const cap = opts.maxMb * 1048576;
|
||||
const head = await probe(it.image, 20000, true);
|
||||
if (!head.ok) throw new Error(`the video did not load (${head.why}).`);
|
||||
const side = it.imageWidth && it.imageHeight ? Math.min(it.imageWidth, it.imageHeight) : 0;
|
||||
if (head.bytes && head.bytes > cap) throw new Error(`the clip is ${size(head.bytes)}, over the ${opts.maxMb} MB cap. ${side > 480 ? 'Try --res 480 for a smaller rendition, or a shorter clip' : 'Choose a shorter clip'}; pass --max-mb ${Math.ceil(head.bytes / 1048576) + 1} only if the page really needs this one.`);
|
||||
mkdirSync(opts.to, { recursive: true });
|
||||
const credits = readCredits(opts);
|
||||
const stem = slug(args.name || it.title) || `${src}-${slug(decodeURIComponent(id)).slice(0, 12)}`;
|
||||
const file = targetFile(opts, stem, VIDEO_TYPES[head.type], it.ref, credits);
|
||||
let r;
|
||||
try { r = await fetch(it.image, { headers: { 'User-Agent': UA, Accept: 'video/*' }, signal: AbortSignal.timeout(600000) }); }
|
||||
catch (e) { throw new Error(`the video did not download (${e.name === 'TimeoutError' ? 'timed out' : e.cause?.code || e.message}).`); }
|
||||
if (!r.ok) throw new Error(`the video did not load (HTTP ${r.status}).`);
|
||||
const out = createWriteStream(file); const first512 = []; let kept = 0; let n = 0;
|
||||
try {
|
||||
const reader = r.body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
n += value.length;
|
||||
if (n > cap) { reader.cancel().catch(() => {}); throw new Error(`the clip passed the ${opts.maxMb} MB cap while downloading; choose a shorter clip or pass a larger --max-mb.`); }
|
||||
if (kept < 524288) { first512.push(value); kept += value.length; }
|
||||
if (!out.write(value)) await new Promise((res) => out.once('drain', res));
|
||||
}
|
||||
await new Promise((res, rej) => out.end((e) => (e ? rej(e) : res())));
|
||||
} catch (e) { out.destroy(); try { unlinkSync(file); } catch {} throw e; }
|
||||
if (!n) { try { unlinkSync(file); } catch {} throw new Error('the video was empty.'); }
|
||||
const info = head.type === 'video/mp4' ? mp4Info(Buffer.concat(first512)) : {};
|
||||
const w = info.w || it.imageWidth; const h = info.h || it.imageHeight; const duration = info.duration || it.duration;
|
||||
let poster = null;
|
||||
if (it.poster) {
|
||||
try {
|
||||
const pr = await fetch(it.poster, { headers: imageHeaders(it.poster), signal: AbortSignal.timeout(60000) });
|
||||
const pt = (pr.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
||||
if (pr.ok && WEB_TYPES[pt]) { poster = file.replace(/\.[^.]+$/, `-poster.${WEB_TYPES[pt]}`); writeFileSync(poster, Buffer.from(await pr.arrayBuffer())); }
|
||||
} catch { poster = null; }
|
||||
}
|
||||
const entry = {
|
||||
file: rel(file), media: 'video', poster: poster ? rel(poster) : null, ref: it.ref, title: it.title, creator: it.creator || null, creatorUrl: it.creatorUrl, date: it.date,
|
||||
license: licenceLabel(it), licenseCode: it.license, licenseUrl: licenceUrl(it), creditRequired: creditRequired(it),
|
||||
page: it.page, via: it.via, video: it.image, width: w || null, height: h || null, duration: duration ? Math.round(duration * 10) / 10 : null, bytes: n,
|
||||
credit: credit(it), notes: [...it.notes, ...licenceNotes(it), ...videoNotes({ ...it, bytes: n, duration }, opts)], saved: new Date().toISOString(),
|
||||
};
|
||||
recordCredit(opts, credits, entry);
|
||||
console.log(`Saved ${entry.file}${w ? ` · ${w} × ${h}` : ''}${duration ? ` · ${clock(duration)}` : ''} · ${size(n)}`);
|
||||
console.log(` poster ${entry.poster || 'none from the source: export a still frame yourself, or use a found picture'}`);
|
||||
console.log(` page ${it.page}`);
|
||||
console.log(` credit ${entry.credit}`);
|
||||
console.log(` markup <video autoplay muted loop playsinline preload="metadata"${entry.poster ? ` poster="${esc(entry.poster)}"` : ''}${w ? ` width="${w}" height="${h}"` : ''} aria-hidden="true"><source src="${esc(entry.file)}" type="${head.type}"></video>`);
|
||||
console.log(' (a silent loop is decoration: pause it or show the poster under prefers-reduced-motion; references/imagery.md "Video")');
|
||||
for (const x of entry.notes) console.log(` note ${x}`);
|
||||
}
|
||||
async function getAll(refs, opts) {
|
||||
let failed = 0;
|
||||
for (const ref of refs) {
|
||||
try { await getOne(ref, opts); } catch (e) { failed++; console.error(`Could not get ${ref}: ${e.message}`); }
|
||||
console.log('');
|
||||
}
|
||||
if (failed < refs.length) {
|
||||
console.log(`Credits are recorded in ${rel(creditsPath(opts))}. Look at each picture and watch each clip before using it.`);
|
||||
console.log(`Print the footer credits and the report list with: ${CMD} --credits${opts.toArg}`);
|
||||
}
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
// ---------- credits ----------
|
||||
function printCredits(opts) {
|
||||
const list = readCredits(opts);
|
||||
const uniq = list.filter((c, i) => list.findIndex((x) => x.ref === c.ref) === i && existsSync(resolve(c.file)));
|
||||
if (!uniq.length) { console.log(`No pictures recorded in ${rel(creditsPath(opts))} yet. Download them with --get <ref> first.`); return 1; }
|
||||
const html = (c) => {
|
||||
const by = c.creator && !/^(unknown|anonymous)\b/i.test(c.creator) ? ` by ${esc(short(c.creator, 60))}` : '';
|
||||
const date = /^(aic|met):/.test(c.ref) && c.date ? `, ${esc(short(c.date, 30))}` : '';
|
||||
const lic = c.licenseUrl ? `<a href="${esc(c.licenseUrl)}">${esc(c.license)}</a>` : esc(c.license);
|
||||
return `<a href="${esc(c.page)}">“${esc(short(c.title, 90))}”</a>${by}${date}, ${lic}, via ${esc(c.via)}`;
|
||||
};
|
||||
const media = new Set(uniq.map((c) => c.media || 'image'));
|
||||
const label = media.size > 1 ? 'Pictures and footage' : media.has('video') ? 'Footage' : 'Pictures';
|
||||
console.log('Footer credits (HTML). Full contrast, keep the links, body size (one step smaller only on a page with plenty of');
|
||||
console.log('running text). Remove anything the page no longer uses, here and in credits.json.\n');
|
||||
console.log(`<p class="credits">${label}: ${uniq.map(html).join('; ')}.</p>`);
|
||||
console.log('\nFor your report:\n');
|
||||
for (const c of uniq) console.log(`- ${c.file}${c.media === 'video' && c.duration ? ` (video, ${clock(c.duration)})` : ''}: ${c.credit}. ${c.page}${c.creditRequired ? '' : ' (no credit required)'}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------- run ----------
|
||||
if (args.help) { console.log(USAGE); process.exit(0); }
|
||||
if (args.credits) process.exit(printCredits(o));
|
||||
if (args.get) process.exit(await getAll(String(args.get).split(',').map((s) => s.trim()).filter(Boolean), o));
|
||||
const query = words.join(' ').trim();
|
||||
if (!query) fail('Give a search query, for example: node assets.mjs "ghent postcard"');
|
||||
process.exit(await search(query, o));
|
||||
@@ -0,0 +1,584 @@
|
||||
#!/usr/bin/env node
|
||||
// tasteskill / draw — deal an art direction from the research library.
|
||||
//
|
||||
// The script chooses, not the model: a model asked to pick a direction returns the category
|
||||
// default every time. The draw is random within what fits the brief, and it remembers recent
|
||||
// draws so consecutive projects do not look alike.
|
||||
//
|
||||
// node draw.mjs --sector <sector> --brief "one line" [--sections hero,showcase,pricing,footer]
|
||||
// [--tone quiet|bold|warm|technical|playful|luxury|editorial]
|
||||
// [--have photography,product-photo,product-ui,illustration,video] assets you really have
|
||||
// [--keep palette,type] the brand already fixes these; draw the rest
|
||||
// [--avoid id,id] [--seed N] [--out DIR]
|
||||
// [--options 3] show three dealt directions to choose from, write nothing
|
||||
// [--direction <id>] deal the full sheet for the chosen direction
|
||||
// node draw.mjs --show reprint the saved direction sheet
|
||||
// node draw.mjs --redraw hero|type|palette|photos|scenes|section:<role>|direction re-deal one slot
|
||||
// node draw.mjs --list sectors|directions|roles|tones|scenes
|
||||
// node draw.mjs --site <id> print one studied reference site in full
|
||||
// node draw.mjs --app --kind <kind> ... deal a style for an app screen instead (see lib/app-draw.mjs)
|
||||
//
|
||||
// Zero dependencies. Node 18+.
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const LIB = join(HERE, '..', 'library');
|
||||
const ASSETS_PATH = join(HERE, 'assets.mjs').split('\\').join('/');
|
||||
const ASSETS_CMD = `node ${/\s/.test(ASSETS_PATH) ? `"${ASSETS_PATH}"` : ASSETS_PATH}`;
|
||||
const load = (name) => JSON.parse(readFileSync(join(LIB, name + '.json'), 'utf8'));
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const opt = (name, def) => { const i = argv.indexOf('--' + name); if (i === -1) return def; const v = argv[i + 1]; return v === undefined || v.startsWith('--') ? true : v; };
|
||||
const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : []);
|
||||
const outDir = resolve(String(opt('out', '.taste-skill')));
|
||||
const sheetPath = join(outDir, 'direction.json');
|
||||
|
||||
const SECTORS = ['saas', 'dev-tool', 'ai-product', 'consumer-app', 'finance', 'agency', 'studio-portfolio', 'personal-portfolio', 'ecommerce', 'physical-product', 'fashion', 'hospitality', 'restaurant', 'real-estate', 'architecture', 'event', 'health', 'education', 'nonprofit', 'culture', 'music', 'other'];
|
||||
const TONES = ['quiet', 'bold', 'warm', 'technical', 'playful', 'luxury', 'editorial'];
|
||||
const DEFAULT_SECTIONS = {
|
||||
saas: ['hero', 'showcase', 'features', 'how-it-works', 'testimonial', 'pricing', 'faq', 'cta', 'footer'],
|
||||
'dev-tool': ['hero', 'showcase', 'features', 'how-it-works', 'pricing', 'faq', 'cta', 'footer'],
|
||||
'ai-product': ['hero', 'showcase', 'features', 'how-it-works', 'testimonial', 'pricing', 'cta', 'footer'],
|
||||
'consumer-app': ['hero', 'showcase', 'features', 'testimonial', 'cta', 'footer'],
|
||||
finance: ['hero', 'showcase', 'features', 'stats', 'testimonial', 'faq', 'cta', 'footer'],
|
||||
agency: ['hero', 'intro', 'work', 'services', 'about', 'cta', 'footer'],
|
||||
'studio-portfolio': ['hero', 'work', 'intro', 'services', 'about', 'cta', 'footer'],
|
||||
'personal-portfolio': ['hero', 'work', 'about', 'cta', 'footer'],
|
||||
ecommerce: ['hero', 'gallery', 'features', 'intro', 'testimonial', 'cta', 'footer'],
|
||||
'physical-product': ['hero', 'showcase', 'features', 'gallery', 'intro', 'pricing', 'cta', 'footer'],
|
||||
fashion: ['hero', 'gallery', 'intro', 'work', 'cta', 'footer'],
|
||||
hospitality: ['hero', 'intro', 'gallery', 'features', 'list', 'contact', 'footer'],
|
||||
restaurant: ['hero', 'intro', 'list', 'gallery', 'contact', 'footer'],
|
||||
'real-estate': ['hero', 'intro', 'gallery', 'features', 'stats', 'contact', 'footer'],
|
||||
architecture: ['hero', 'work', 'intro', 'about', 'contact', 'footer'],
|
||||
event: ['hero', 'intro', 'list', 'features', 'pricing', 'faq', 'cta', 'footer'],
|
||||
health: ['hero', 'intro', 'features', 'how-it-works', 'testimonial', 'faq', 'cta', 'footer'],
|
||||
education: ['hero', 'intro', 'features', 'how-it-works', 'testimonial', 'pricing', 'faq', 'cta', 'footer'],
|
||||
nonprofit: ['hero', 'intro', 'stats', 'features', 'about', 'cta', 'footer'],
|
||||
culture: ['hero', 'intro', 'gallery', 'list', 'about', 'cta', 'footer'],
|
||||
music: ['hero', 'list', 'gallery', 'about', 'cta', 'footer'],
|
||||
other: ['hero', 'intro', 'features', 'showcase', 'cta', 'footer'],
|
||||
};
|
||||
// requested role names → the role vocabulary used by library/sections.json
|
||||
const ROLE_ALIAS = { features: 'features', feature: 'features', benefits: 'features', services: 'features', showcase: 'showcase', product: 'showcase', demo: 'showcase', 'how-it-works': 'how-it-works', how: 'how-it-works', process: 'how-it-works', steps: 'how-it-works', testimonial: 'testimonial', testimonials: 'testimonial', quotes: 'testimonial', proof: 'proof', logos: 'proof', press: 'proof', stats: 'stats', numbers: 'stats', pricing: 'pricing', plans: 'pricing', faq: 'faq', cta: 'cta', footer: 'footer', intro: 'intro', manifesto: 'intro', about: 'about', team: 'about', work: 'work', projects: 'work', index: 'work', 'case-studies': 'work', gallery: 'gallery', images: 'gallery', list: 'list', menu: 'list', schedule: 'list', lineup: 'list', 'line-up': 'list', speakers: 'list', agenda: 'list', programme: 'list', program: 'list', timetable: 'list', rooms: 'list', tickets: 'pricing', price: 'pricing', contact: 'contact', booking: 'contact', newsletter: 'contact', signup: 'contact' };
|
||||
|
||||
// App screens (dashboards, admin, settings) have their own library and sheet: scripts/lib/app-draw.mjs.
|
||||
const savedKind = (() => { try { return JSON.parse(readFileSync(sheetPath, 'utf8')).kind; } catch { return null; } })();
|
||||
if (opt('app') || ((opt('show') || opt('redraw')) && savedKind === 'app')) await import('./lib/app-draw.mjs');
|
||||
|
||||
if (opt('list')) {
|
||||
const what = opt('list');
|
||||
if (what === 'sectors') console.log(SECTORS.join('\n'));
|
||||
else if (what === 'tones') console.log(TONES.join('\n'));
|
||||
else if (what === 'roles') console.log([...new Set(Object.values(ROLE_ALIAS))].join('\n'));
|
||||
else if (what === 'scenes') { const all = existsSync(join(LIB, 'scenes.json')) ? load('scenes') : []; for (const sc of all) console.log(`${sc.id.padEnd(34)} ${sc.name} — ${sc.family}; ${sc.summary.split(/(?<=\.)\s/)[0]}`); if (!all.length) console.log('No scene library yet.'); }
|
||||
else for (const d of load('directions')) console.log(`${d.id.padEnd(30)} ${d.name} — ${d.sectors.join(', ')}`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (opt('photos')) {
|
||||
const lib = load('photos'); const q = String(opt('photos'));
|
||||
const line = (ph) => `- https://picsum.photos/id/${ph.id}/1600/1000 — ${ph.alt} · ${ph.tone} tone · ${(ph.colours || []).join(', ')}${ph.mono ? ' · black-and-white' : ''}${ph.calm && ph.calm !== 'none' ? ` · calm area: ${ph.calm}` : ''} · photo: ${ph.by}`;
|
||||
if (q === 'list' || q === 'true') { for (const [id, set] of Object.entries(lib.sets)) console.log(`${id.padEnd(22)} ${String(set.photos.length).padStart(3)} photos — ${set.note}`); }
|
||||
else if (lib.sets[q]) { console.log(`# ${lib.sets[q].title} — ${lib.sets[q].note}`); for (const ph of lib.sets[q].photos) console.log(line(ph)); }
|
||||
else { // free-text search across all descriptions
|
||||
const words = q.toLowerCase().split(/[^a-z]+/).filter((w) => w.length >= 3);
|
||||
const hits = Object.values(lib.sets).flatMap((s) => s.photos).filter((ph) => words.some((w) => (ph.alt + ' ' + (ph.colours || []).join(' ')).toLowerCase().includes(w)));
|
||||
if (!hits.length) console.log('No photograph matches. `--photos list` shows the sets.'); else for (const ph of hits.slice(0, 24)) console.log(line(ph));
|
||||
}
|
||||
console.log('\nPath numbers are width/height; add ?grayscale to desaturate. Photographs are atmosphere, never a stand-in for a specific product or person.');
|
||||
process.exit(0);
|
||||
}
|
||||
if (opt('site')) {
|
||||
const s = load('sites').find((x) => x.id === opt('site') || x.name.toLowerCase() === String(opt('site')).toLowerCase());
|
||||
if (!s) { console.error('No studied site with that id. Ids appear in the direction sheet under "Real references".'); process.exit(2); }
|
||||
console.log(siteCard(s, true)); process.exit(0);
|
||||
}
|
||||
|
||||
// ---------- randomness (seedable) ----------
|
||||
let seed = opt('seed') !== undefined && opt('seed') !== true ? Number(opt('seed')) : randomInt(1, 2 ** 31 - 1);
|
||||
const startSeed = seed;
|
||||
const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
||||
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
|
||||
const shuffle = (arr) => { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
|
||||
// prefer items not used recently, without ever emptying the pool
|
||||
const fresh = (pool, recent) => { const f = pool.filter((x) => !recent.includes(x.id)); return f.length ? f : pool; };
|
||||
|
||||
// ---------- history (project + user level) ----------
|
||||
const userHist = process.env.TASTE_SKILL_HISTORY || join(homedir(), '.taste-skill', 'history.json');
|
||||
const readHist = () => { for (const p of [userHist]) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch {} } return []; };
|
||||
const writeHist = (entry) => { try { mkdirSync(dirname(userHist), { recursive: true }); const h = readHist(); h.unshift(entry); writeFileSync(userHist, JSON.stringify(h.slice(0, 24), null, 1)); } catch {} };
|
||||
|
||||
// ---------- show / redraw ----------
|
||||
const saved = existsSync(sheetPath) ? JSON.parse(readFileSync(sheetPath, 'utf8')) : null;
|
||||
if (opt('show')) {
|
||||
if (!saved) { console.error('No saved direction. Run a draw first.'); process.exit(2); }
|
||||
console.log(render(saved)); process.exit(0);
|
||||
}
|
||||
|
||||
const L = { directions: load('directions'), heroes: load('heroes'), sections: load('sections'), type: load('type'), palettes: load('palettes'), photos: load('photos'), sites: load('sites'), motions: load('motions'), scenes: existsSync(join(LIB, 'scenes.json')) ? load('scenes') : [] };
|
||||
const hist = readHist();
|
||||
const recent = (key, n) => hist.slice(0, n).map((h) => h[key]).filter(Boolean);
|
||||
|
||||
let sheet;
|
||||
if (opt('alternatives')) {
|
||||
// The other blueprints of one role that suit the saved direction: for when the dealt one cannot hold
|
||||
// the real content (it assumes three plans and there is one; it needs five quotes and there are none).
|
||||
if (!saved) { console.error('Run a draw first.'); process.exit(2); }
|
||||
const role = ROLE_ALIAS[String(opt('alternatives'))] || String(opt('alternatives'));
|
||||
const dir = L.directions.find((d) => d.id === saved.direction.id);
|
||||
const current = saved.sections.find((s) => s.role === role)?.id;
|
||||
const pool = sectionPool(dir, role, saved.input);
|
||||
if (!pool.length) { console.error('No blueprints for role ' + role + '. Roles: --list roles'); process.exit(2); }
|
||||
const line = (s) => `- \`${s.id}\`${s.id === current ? ' (dealt)' : ''} — **${s.name}** (${s.shape}). ${s.blueprint.split(/(?<=\.)\s/).slice(0, 2).join(' ')}\n`;
|
||||
console.log(`# ${role}: blueprints that suit "${dir.name}"\n`);
|
||||
for (const s of pool) console.log(line(s));
|
||||
const software = imageryMode(saved.input) === 'interface';
|
||||
const others = L.sections.filter((s) => s.role === role && !pool.includes(s) && (software || !softwareOnly(s)) && (s.needs || []).every((n) => saved.input.have.includes(n) || (n === 'product-ui' && software)));
|
||||
if (others.length) {
|
||||
console.log(`# Other ${role} blueprints (written for other directions; if one fits the content better, take its geometry and set it in this direction's type, colour and manner)\n`);
|
||||
for (const s of others) console.log(line(s));
|
||||
}
|
||||
console.log(`Choose by what the content really is (how many plans, quotes, items exist), then: node draw.mjs --redraw section:${role}=<id> and say why in your report.`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (opt('redraw')) {
|
||||
if (!saved) { console.error('Nothing to redraw. Run a draw first.'); process.exit(2); }
|
||||
sheet = saved; sheet.redraws = (sheet.redraws || 0) + 1; // the sheet keeps its original seed; slot redraws are counted
|
||||
const what = String(opt('redraw'));
|
||||
const dir = L.directions.find((d) => d.id === sheet.direction.id);
|
||||
if (what === 'direction') sheet = deal({ ...sheet.input, direction: null, avoid: [...(sheet.input.avoid || []), sheet.direction.id] }, sheet.redraws);
|
||||
else if (what === 'hero') sheet.hero = dealHero(dir, sheet.input, [sheet.hero?.id]);
|
||||
else if (what === 'type') sheet.type = dealType(dir, [sheet.type?.id]);
|
||||
else if (what === 'palette') sheet.palette = dealPalette(dir, sheet.input, [sheet.palette?.id]);
|
||||
else if (what === 'photos') sheet.photos = dealPhotos(dir, sheet.input);
|
||||
else if (what === 'scenes') sheet.scenes = dealScenes(dir, sheet.input, (sheet.scenes || []).map((x) => x.id));
|
||||
else if (what.startsWith('section:')) {
|
||||
// section:<role> re-deals at random; section:<role>=<id> takes a named alternative (see --alternatives)
|
||||
const [roleRaw, wanted] = what.slice(8).split('=');
|
||||
const role = ROLE_ALIAS[roleRaw] || roleRaw;
|
||||
const i = sheet.sections.findIndex((s) => s.role === role);
|
||||
if (i === -1) { console.error('No section with role ' + role + ' in this sheet.'); process.exit(2); }
|
||||
const used = sheet.sections.map((s) => s.id);
|
||||
if (wanted) {
|
||||
// a blueprint written for another role may be borrowed when its geometry fits the content better
|
||||
const chosen = L.sections.find((s) => s.id === wanted);
|
||||
if (!chosen) { console.error(`No blueprint with id ${wanted}. List them with --alternatives <role>.`); process.exit(2); }
|
||||
sheet.sections[i] = chosen.role === role ? chosen : { ...chosen, role, name: `${chosen.name} (borrowed from ${chosen.role})` };
|
||||
} else sheet.sections[i] = dealSection(dir, role, used, sheet.sections[i - 1]?.shape, sheet.sections[i + 1]?.shape, sheet.input) || sheet.sections[i];
|
||||
} else { console.error('Unknown slot. Use hero, type, palette, photos, scenes, section:<role>, section:<role>=<id> or direction.'); process.exit(2); }
|
||||
} else {
|
||||
const sector = String(opt('sector', ''));
|
||||
if (!SECTORS.includes(sector)) {
|
||||
console.error(`--sector is required. One of:\n ${SECTORS.join(', ')}\nPick the closest; use "other" only when nothing fits.`);
|
||||
process.exit(2);
|
||||
}
|
||||
const requested = list(opt('sections'));
|
||||
const input = {
|
||||
brief: typeof opt('brief') === 'string' ? opt('brief') : '', sector,
|
||||
tone: TONES.includes(opt('tone')) ? opt('tone') : null,
|
||||
have: list(opt('have')), keep: list(opt('keep')), avoid: list(opt('avoid')),
|
||||
sections: (requested.length ? requested : DEFAULT_SECTIONS[sector]).map((r) => ROLE_ALIAS[r] || r).filter((r) => r !== 'hero'),
|
||||
direction: typeof opt('direction') === 'string' ? opt('direction') : null,
|
||||
};
|
||||
if (input.direction && !L.directions.some((d) => d.id === input.direction)) { console.error(`No direction with id ${input.direction}. List them with --list directions.`); process.exit(2); }
|
||||
if (opt('options')) {
|
||||
const n = Math.max(2, Math.min(5, Number(opt('options')) || 3));
|
||||
const options = dealOptions(input, n);
|
||||
console.log(`# ${n} directions dealt for this brief\n`);
|
||||
console.log('Choose the one whose way of composing can best carry an idea from your inventory (SKILL.md steps 1 and 4), not the');
|
||||
console.log('most familiar one. Then deal it in full with the same command plus `--direction <id>` (and without --options).\n');
|
||||
for (const d of options) {
|
||||
console.log(`## \`${d.id}\` — ${d.name}`);
|
||||
console.log(`Idea engine: ${d.idea.split(/(?<=\.)\s/).slice(0, 2).join(' ')}`);
|
||||
console.log(`Composition: ${d.composition.split(/(?<=\.)\s/).slice(0, 2).join(' ')}`);
|
||||
if (d.needsPictures) console.log('Needs: many pictures. Without photographs of its own, each is a real picture of the subject\'s world found with scripts/assets.mjs, or a drawing you make.');
|
||||
console.log('');
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
sheet = deal(input, 0);
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(sheetPath, JSON.stringify(sheet, null, 1));
|
||||
if (!opt('redraw') || String(opt('redraw')) === 'direction') writeHist({ at: sheet.at, direction: sheet.direction.id, type: sheet.type?.id, palette: sheet.palette?.id, hero: sheet.hero?.id, sector: sheet.input.sector, scenes: (sheet.scenes || []).map((x) => x.id) });
|
||||
console.log(render(sheet));
|
||||
|
||||
// ---------- dealing ----------
|
||||
// What kind of pictures this build can honestly have. It decides which blueprints are dealt and how the
|
||||
// sheet tells the agent to fill picture slots.
|
||||
function imageryMode(input) {
|
||||
if (input.have.includes('product-photo') || input.have.includes('photography')) return 'own';
|
||||
if (['saas', 'dev-tool', 'ai-product', 'consumer-app', 'finance'].includes(input.sector)) return 'interface';
|
||||
if (['physical-product', 'ecommerce'].includes(input.sector)) return 'no-product-photo';
|
||||
return 'atmosphere';
|
||||
}
|
||||
function pictureLed(x) { return (x.tags || []).includes('photo-led') || (x.tags || []).includes('object-led'); }
|
||||
function worksWithoutPictures(x) { return !pictureLed(x) || (x.tags || []).some((t) => ['ui-led', 'type-led', 'colour-field'].includes(t)); }
|
||||
|
||||
// The directions that suit this brief and the assets it really has.
|
||||
function directionPool(input) {
|
||||
const has = (need) => input.have.includes(need) || need === 'photography'; // the photo library covers photography
|
||||
let pool = L.directions.filter((d) => (d.sectors.includes(input.sector) || d.sectors.includes('*')) && !input.avoid.includes(d.id));
|
||||
const feasible = pool.filter((d) => (d.needs || []).every(has));
|
||||
if (feasible.length) pool = feasible;
|
||||
// directions built on photographs of the subject are not dealt when the subject cannot be photographed honestly
|
||||
const mode = imageryMode(input);
|
||||
if (mode === 'interface' || mode === 'no-product-photo') { const plain = pool.filter((d) => !d.needsPictures); if (plain.length >= 3) pool = plain; }
|
||||
if (input.tone) { const toned = pool.filter((d) => (d.tones || []).includes(input.tone)); if (toned.length >= 2) pool = toned; }
|
||||
if (!pool.length) pool = L.directions.filter((d) => !input.avoid.includes(d.id));
|
||||
return pool;
|
||||
}
|
||||
// Three directions dealt at random from the pool, for the builder to choose the one that carries its idea.
|
||||
function dealOptions(input, n) {
|
||||
const pool = directionPool(input); const out = [];
|
||||
let rest = fresh(pool, recent('direction', Math.min(5, pool.length - n)));
|
||||
while (out.length < n && pool.length > out.length) {
|
||||
if (!rest.length) rest = pool.filter((d) => !out.includes(d));
|
||||
const d = pick(rest); out.push(d); rest = rest.filter((x) => x !== d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function deal(input, redraws) {
|
||||
const pool = directionPool(input);
|
||||
const forced = input.direction ? L.directions.find((d) => d.id === input.direction) : null;
|
||||
const dir = forced || pick(fresh(pool, recent('direction', Math.min(5, pool.length - 1))));
|
||||
const s = { version: 1, at: new Date().toISOString(), seed: startSeed, redraws, input, imagery: imageryMode(input), allow: dir.allow || [], direction: dir };
|
||||
s.hero = dealHero(dir, input, []);
|
||||
s.type = input.keep.includes('type') ? null : dealType(dir, []);
|
||||
s.palette = input.keep.includes('palette') ? null : dealPalette(dir, input, []);
|
||||
s.photos = dealPhotos(dir, input);
|
||||
s.motion = L.motions.find((m) => m.id === (s.hero?.motion || dir.motion)) || L.motions.find((m) => m.id === dir.motion) || null;
|
||||
s.sections = [];
|
||||
for (const role of input.sections) {
|
||||
const sec = dealSection(dir, role, s.sections.map((x) => x.id), s.sections.at(-1)?.shape, null, input);
|
||||
if (sec) s.sections.push(sec); else s.sections.push({ id: null, role, shape: null, name: role, blueprint: null });
|
||||
}
|
||||
s.references = dealReferences(dir, input);
|
||||
s.scenes = dealScenes(dir, input, []);
|
||||
return s;
|
||||
}
|
||||
// The page's big scroll moments (references/scenes.md): two or three tested scenes from different
|
||||
// families, each placed on a section of the argument it can carry. Expressive sectors and tones get three.
|
||||
function dealScenes(dir, input, exclude) {
|
||||
if (!L.scenes.length) return [];
|
||||
const has = (need) => input.have.includes(need) || need === 'photography';
|
||||
const fits = (sc) => !exclude.includes(sc.id) && (sc.sectors || ['*']).some((x) => x === '*' || x === input.sector) && (sc.needs || []).every(has);
|
||||
let pool = L.scenes.filter(fits);
|
||||
if (pool.length < 2) pool = L.scenes.filter((sc) => !exclude.includes(sc.id) && (sc.needs || []).every(has));
|
||||
if (!pool.length) return [];
|
||||
const loud = ['agency', 'studio-portfolio', 'personal-portfolio', 'fashion', 'culture', 'music', 'event', 'architecture', 'physical-product', 'real-estate', 'hospitality'].includes(input.sector) || ['bold', 'playful', 'luxury', 'editorial'].includes(input.tone);
|
||||
const quiet = ['quiet', 'technical'].includes(input.tone);
|
||||
const n = Math.min(pool.length, quiet ? 2 : loud ? 3 : 2);
|
||||
const recentIds = hist.slice(0, 4).flatMap((h) => h.scenes || []);
|
||||
let rest = fresh(pool, recentIds);
|
||||
const out = []; const families = new Set(); const usedRoles = new Set();
|
||||
while (out.length < n) {
|
||||
let cands = rest.filter((sc) => !families.has(sc.family) && !(sc.position === 'opening' && out.some((o) => o.position === 'opening')) && !(sc.position === 'closing' && out.some((o) => o.position === 'closing')));
|
||||
if (!cands.length) cands = pool.filter((sc) => !out.includes(sc) && !families.has(sc.family));
|
||||
if (!cands.length) break;
|
||||
const sc = pick(cands); out.push(sc); families.add(sc.family); rest = rest.filter((x) => x !== sc);
|
||||
}
|
||||
// the section each scene carries: the first role of the argument it suits that no other scene has taken
|
||||
const roles = ['hero', ...input.sections];
|
||||
return out.map((sc) => {
|
||||
const role = sc.position === 'opening' ? 'hero' : roles.find((r) => (sc.roles || []).includes(r) && !usedRoles.has(r) && r !== 'hero') || null;
|
||||
if (role) usedRoles.add(role);
|
||||
return { ...sc, carries: role };
|
||||
}).sort((a, b) => (a.carries ? roles.indexOf(a.carries) : 99) - (b.carries ? roles.indexOf(b.carries) : 99));
|
||||
}
|
||||
function dealHero(dir, input, exclude) {
|
||||
// the direction's own list, and any hero written for this direction since (the October 2026 study added some)
|
||||
let pool = L.heroes.filter((h) => (dir.heroes || []).includes(h.id) || (h.directions || []).includes(dir.id));
|
||||
if (!pool.length) pool = L.heroes;
|
||||
if (imageryMode(input) !== 'interface' && !input.have.includes('product-ui')) { const plain = pool.filter((h) => !softwareOnly(h)); if (plain.length) pool = plain; }
|
||||
const ok = pool.filter((h) => !exclude.includes(h.id) && (h.needs || []).every((n) => n === 'photography' || input.have.includes(n)));
|
||||
return pick(fresh(ok.length ? ok : pool.filter((h) => !exclude.includes(h.id)).length ? pool.filter((h) => !exclude.includes(h.id)) : pool, recent('hero', 3)));
|
||||
}
|
||||
function dealType(dir, exclude) {
|
||||
let pool = L.type.filter((t) => (dir.type || []).includes(t.id) || (dir.typeClasses || []).includes(t.class));
|
||||
if (!pool.length) pool = L.type;
|
||||
const p = pool.filter((t) => !exclude.includes(t.id));
|
||||
return pick(fresh(p.length ? p : pool, recent('type', Math.min(6, pool.length - 1))));
|
||||
}
|
||||
function dealPalette(dir, input, exclude) {
|
||||
let pool = L.palettes.filter((p) => (dir.palettes || []).includes(p.id) || (p.tags || []).some((t) => (dir.paletteTags || []).includes(t)));
|
||||
if (!pool.length) pool = L.palettes;
|
||||
const p = pool.filter((x) => !exclude.includes(x.id));
|
||||
return pick(fresh(p.length ? p : pool, recent('palette', Math.min(6, pool.length - 1))));
|
||||
}
|
||||
function dealPhotos(dir, input) {
|
||||
if (dir.photos === false) return null; // image-free directions
|
||||
const words = briefWords(input.brief);
|
||||
const mode = imageryMode(input);
|
||||
const SOFT_SETS = ['architecture', 'fog-and-quiet', 'sky-and-space', 'texture', 'water'];
|
||||
// subject first (sector + words of the brief), then the direction's preferred tone
|
||||
const scored = Object.entries(L.photos.sets).map(([id, set]) => {
|
||||
let score = (set.sectors || []).includes(input.sector) ? 3 : 0;
|
||||
if ((set.primary || []).includes(input.sector)) score += 5;
|
||||
// rooms and tables would be taken for this very house: for places to stay, deal the surroundings instead
|
||||
if (mode === 'atmosphere' && ['hospitality', 'real-estate'].includes(input.sector) && id === 'interior-and-table') score -= 6;
|
||||
score += words.filter((w) => (set.keywords || []).some((k) => k === w || (k.length >= 5 && w.startsWith(k)) || (w.length >= 5 && k.startsWith(w)))).length * 4;
|
||||
score += set.photos.filter((ph) => words.some((w) => ph.alt.toLowerCase().includes(w))).length;
|
||||
if (dir.photoTone) score += set.photos.filter((ph) => ph.tone === dir.photoTone).length / set.photos.length;
|
||||
return { id, set, score };
|
||||
}).filter((x) => x.score > 0 && x.set.photos.length >= 5 && (mode !== 'interface' || SOFT_SETS.includes(x.id))).sort((a, b) => b.score - a.score);
|
||||
if (!scored.length) return null;
|
||||
// one set, so the photographs share a subject and a light; choose among the best-fitting few
|
||||
const top = scored.filter((x) => x.score >= scored[0].score * 0.6).slice(0, 4);
|
||||
const chosen = pick(top);
|
||||
let pool = chosen.set.photos;
|
||||
if (dir.photoTone) { const toned = pool.filter((ph) => ph.tone === dir.photoTone || ph.tone === 'mid'); if (toned.length >= 6) pool = toned; }
|
||||
// Without product photographs, a picture of the same kind of thing would pass for the product: deal
|
||||
// those last and mark them. Otherwise a picture that matches the brief is the better atmosphere.
|
||||
const things = words.filter((w) => w.length >= 5);
|
||||
const matches = (ph) => things.some((w) => ph.alt.toLowerCase().includes(w));
|
||||
const bonus = mode === 'no-product-photo' ? -2 : 2;
|
||||
const ranked = shuffle(pool).sort((a, b) => (b.quality + (matches(b) ? bonus : 0)) - (a.quality + (matches(a) ? bonus : 0)))
|
||||
.map((ph) => (mode === 'no-product-photo' && matches(ph) ? { ...ph, lookalike: true } : ph));
|
||||
return { set: chosen.id, title: chosen.set.title, note: chosen.set.note, treatment: dir.photoTreatment || null, others: scored.filter((x) => x.id !== chosen.id).slice(0, 4).map((x) => x.id), photos: ranked.slice(0, mode === 'interface' ? 4 : 8) };
|
||||
}
|
||||
function briefStop() {
|
||||
return new Set(['with', 'that', 'this', 'from', 'page', 'site', 'website', 'landing', 'their', 'they', 'have', 'will', 'your', 'about', 'into', 'make', 'build', 'create', 'design', 'small', 'based', 'called', 'named', 'which', 'where', 'what', 'need', 'want', 'like', 'also', 'some', 'very', 'more', 'most', 'just', 'than', 'then', 'them', 'and', 'the', 'for', 'one', 'hand', 'handmade', 'made', 'batch', 'ship', 'ships', 'week', 'within', 'across', 'euro', 'euros', 'cost', 'price', 'open', 'only', 'each', 'every', 'there', 'images', 'photos', 'photograph', 'static', 'html']);
|
||||
}
|
||||
function briefWords(brief) {
|
||||
const stop = briefStop();
|
||||
return [...new Set(String(brief || '').toLowerCase().split(/[^a-z]+/).filter((w) => w.length >= 4 && !stop.has(w)).map((w) => w.replace(/(ies|es|s)$/, '')))].filter((w) => w.length >= 4);
|
||||
}
|
||||
// First queries for scripts/assets.mjs, built from the brief: the place in old postcards and maps, then the
|
||||
// subject itself. In testing "<place> postcard" and "<place> old map" found archival material far more
|
||||
// reliably than a place and a year. A start only: the better queries come from the builder's inventory.
|
||||
function assetQueries(input) {
|
||||
const brief = String(input.brief || '');
|
||||
const notPlace = /^(?:january|february|march|april|may|june|july|august|september|october|november|december|monday|tuesday|wednesday|thursday|friday|saturday|sunday)s?$/i;
|
||||
const placeRe = /(?:^|[\s,(])(?:in|at|near|from|on|above|below|beside|outside|off|over)\s+(\p{Lu}[\p{L}'’-]*(?:\s+(?:\p{Lu}[\p{L}'’-]*|de|da|do|dos|das|di|del|van|von|der|den))*)/gu;
|
||||
const place = [...brief.matchAll(placeRe)].map((m) => m[1].trim()).find((p) => !notPlace.test(p.split(/\s+/)[0]));
|
||||
const stop = briefStop();
|
||||
const generic = new Set(['seat', 'seats', 'room', 'rooms', 'minute', 'minutes', 'hour', 'hours', 'night', 'nights', 'year', 'years', 'people', 'family', 'local', 'short', 'best', 'making', 'selling', 'serving', 'offering', 'business', 'company', 'brand', 'shop', 'store', 'studio', 'service', 'services', 'dinner', 'lunch', 'breakfast', 'included', 'online', 'modern']);
|
||||
// the subject: the last plain words of the first noun phrase ("a 38-seat Italian restaurant in ..." gives "restaurant")
|
||||
const words = brief.split(/\s+/).filter(Boolean); const phrase = [];
|
||||
for (let k = words.findIndex((w) => /^an?$/i.test(w)) + 1; k < words.length; k++) {
|
||||
if (/^(?:in|at|on|for|from|with|near|above|below|by|that|which|who|of|and|to)$/i.test(words[k])) break;
|
||||
phrase.push(words[k].replace(/[,.;:()!?]+$/, ''));
|
||||
if (/[,.;:()!?]$/.test(words[k])) break;
|
||||
}
|
||||
const plain = phrase.filter((w) => /^\p{Ll}{3,}$/u.test(w) && !stop.has(w) && !generic.has(w));
|
||||
const subject = plain.slice(-2).join(' ') || (input.sector !== 'other' ? input.sector.replace(/-/g, ' ') : null);
|
||||
const out = [];
|
||||
if (place) out.push(`${place.toLowerCase()} postcard`, `${place.toLowerCase()} old map`);
|
||||
if (subject) out.push(subject.toLowerCase());
|
||||
return [...new Set(out)];
|
||||
}
|
||||
// Blueprints written for showing software (an interface fragment is their picture).
|
||||
function softwareOnly(x) { return (x.directions || [])[0] === 'product-performs' || (x.tags || []).includes('ui-led') || /data-ui|interface fragment/i.test(x.blueprint || ''); }
|
||||
// Blueprints were measured on real sites and carry that site's sizes, greys, faces, colours and corners.
|
||||
// This lists, for one blueprint, exactly where it disagrees with the house rules and the dealt system,
|
||||
// so the builder does not have to work it out section by section.
|
||||
function adjustments(bp, s) {
|
||||
const text = [bp.blueprint, bp.mobile, bp.headline].filter(Boolean).join(' ');
|
||||
const notes = [];
|
||||
const sizes = (re) => [...new Set([...text.matchAll(re)].map((m) => m[1] + 'px'))];
|
||||
const notSpacing = String.raw`(?!\s*(?:gaps?|gutters?|margins?|padding|radius|corners?|apart|from|inset|offset|high|tall|wide|thick|square|borders?|stroke|rules?|line\b|dots?|circles?|bars?|of|between|beneath|below|above|under|down|up|across|deep|left|right|in\b|to\b|and\b|each))`;
|
||||
const tiny = sizes(new RegExp(String.raw`(?<!(?:radius|radii|gap|margin|padding|inset|offset)[:\s]{0,3}(?:of\s)?)\b(9|10|11|12)(?:\.\d)?px\b` + notSpacing, 'g'));
|
||||
if (tiny.length) notes.push(`It sets text at ${tiny.join(', ')}: use your small step (13px or more) instead.`);
|
||||
const running = sizes(/\b(14|15)px\s*(?:paragraphs?|body|text|descriptions?|copy|sentences?|answers?)/gi);
|
||||
if (running.length) notes.push(`It sets running text at ${running.join(', ')}: paragraphs use your body step (16px or more).`);
|
||||
if (/\d{1,2}% (?:opacity|black|ink|grey|gray|white)|opacity:? ?0?\.\d|\b(?:rest|others?|remaining)\b[^.;]{0,25}\b(?:at|to) \d{1,2}%(?! (?:of|width|wide))|full (?:ink|white|strength)[^.;]{0,60}\bat \d{1,2}%(?! (?:of|width|wide))/i.test(text)) notes.push('It fades text: anything a visitor reads, inactive items of a list included, is `--ink` or `--muted` at full strength (contrast 4.5:1 or more). The active item is told apart by `--ink` against `--muted`, weight or the accent, not by fading the rest.');
|
||||
if (/\d+px (?:corner )?radius|radius (?:of )?\d+px|\brounded\b|\bpill/i.test(text)) notes.push('It names its own corners: use the one radius of this page (square unless the direction says otherwise) and one button shape.');
|
||||
if (/\b(?:serif|sans-serif|condensed|grotesk|italic|monospace|mono|all caps|uppercase|capitals|script)\b/i.test(text)) notes.push(s.type ? `It names a kind of typeface: use the dealt faces (display ${s.type.display.family}, text ${s.type.text.family}) and their settings; capitals only where the type system sets them.` : 'It names a kind of typeface: use the brand faces.');
|
||||
const colours = [...new Set([...text.matchAll(/\b(green|red|blue|yellow|orange|pink|purple|teal|navy|cream|beige|brown|grey|gray|white|black)\b(?)/gi)].map((m) => m[1].toLowerCase()))];
|
||||
if (colours.length) notes.push(`It names colours (${colours.join(', ')}): map them onto this palette's roles (ground \`--bg\`, panels \`--surface\` and the fields, text \`--ink\`/\`--muted\`, the accent \`--accent\` on the primary action only).`);
|
||||
if (/(?<!\b(?:no|without|never|not|nor) (?:a |any )?)\b(?:rule|rules|hairline|border|borders|divider|underline|thin line|1px line)\b/i.test(text)) notes.push('It draws lines or borders: keep them only inside a table, a form or a `data-ui` fragment; elsewhere separate with space or a change of ground.');
|
||||
if (/shadow|glow/i.test(text)) notes.push('It mentions a shadow: only on something open above the page (a menu, a dialog), never on a resting element.');
|
||||
const bigSentence = [...text.matchAll(/\b(?:sentence|statement|paragraph|quotation|quote)\b[^.;]{0,40}?\b(?:at|to)\s+(?:clamp\(\s*\d+(?:\.\d+)?px,\s*)?(\d+(?:\.\d+)?)(px|vw)/gi)].some((m) => (m[2] === 'px' && +m[1] > 36) || (m[2] === 'vw' && +m[1] > 2.6));
|
||||
if (bigSentence) notes.push('It sets a sentence large over several lines: jurors read that as a long headline. Either cut it to a two-line headline at display size, or keep it a statement at 36px or less on three lines (24px, four lines on a phone).');
|
||||
if (/(?:sticky|pinned|pins?\b|stays? (?:fixed|put|in place))/i.test(text) && /\b(?:three|four|five|six) (?:to (?:four|five|six) )?(?:viewports|screens)\b|\b[3-6]00svh\b/i.test(text)) notes.push('It pins something for three screens or more: hold a pin for two or three screens at most, give every screen under it its own content, and let nothing pass over the pinned words (the inspector checks all three).');
|
||||
if (/mix-blend-mode:\s*(?:difference|exclusion)/i.test(text)) notes.push('It blends text with `difference`: over anything but black, white or grey that gives random hues. Use a solid ink chosen for each ground instead.');
|
||||
if (/tops? of the (?:letters|word|name|wordmark)|(?:letters|wordmark|name|word)\b[^.;]{0,30}\b(?:sliced|cut|cropped|clipped)[^.;]{0,20}\btop/i.test(text)) notes.push('It crops letters along their top: the inspector fails that (jurors read it as broken). Crop at the bottom edge or a side instead.');
|
||||
if (bp.archetype && /single action[^.;]{0,40}?\b(?:text link|underlined (?:text )?link)/i.test(text)) notes.push('Its action is a text link: the primary action on the first screen is a real button (filled or solid-bordered, 44px or taller), placed where the blueprint puts the link. A second action may stay a link.');
|
||||
if (s.type?.class === 'condensed-heavy' && /\d+(?:\.\d+)?vw/.test(text)) notes.push('Its vw sizes assume a face of normal width: with this condensed display face, size the words to fill the measure the blueprint gives them instead of copying the vw value.');
|
||||
if (/\b0[1-9]\b(?![:.,]\d)|numbered|index number/i.test(text)) notes.push('It numbers things 01, 02: drop the numbers unless they are real data (a price, a date, a count).');
|
||||
return notes;
|
||||
}
|
||||
// Every blueprint of a role that suits this direction and this build's real assets.
|
||||
function sectionPool(dir, role, input) {
|
||||
let pool = L.sections.filter((s) => s.role === role);
|
||||
if (!pool.length) return [];
|
||||
const mode = imageryMode(input);
|
||||
const feasible = pool.filter((s) => (s.needs || []).every((n) => n === 'photography' || input.have.includes(n) || (n === 'product-ui' && mode === 'interface')));
|
||||
if (feasible.length) pool = feasible;
|
||||
if (mode !== 'interface' && !input.have.includes('product-ui')) { const plain = pool.filter((s) => !softwareOnly(s)); if (plain.length) pool = plain; }
|
||||
if (mode === 'interface' || mode === 'no-product-photo') {
|
||||
// prefer compositions that do not hang on a photograph of the thing
|
||||
const honest = pool.filter((s) => (mode === 'interface' ? worksWithoutPictures(s) : !(s.tags || []).includes('object-led') || worksWithoutPictures(s)));
|
||||
if (honest.length >= 2) pool = honest;
|
||||
}
|
||||
const byDirection = pool.filter((s) => (s.directions || []).includes(dir.id));
|
||||
if (byDirection.length) return byDirection;
|
||||
const byTag = pool.filter((s) => (s.tags || []).some((t) => (dir.sectionTags || []).includes(t)));
|
||||
return byTag.length ? byTag : pool;
|
||||
}
|
||||
function dealSection(dir, role, usedIds, prevShape, nextShape, input) {
|
||||
let pool = sectionPool(dir, role, input);
|
||||
if (!pool.length) return null;
|
||||
if (imageryMode(input) === 'interface') { const ui = pool.filter((s) => (s.tags || []).includes('ui-led')); if (ui.length >= 2 && rnd() < 0.6) pool = ui; }
|
||||
const unused = pool.filter((s) => !usedIds.includes(s.id)); if (unused.length) pool = unused;
|
||||
const varied = pool.filter((s) => s.shape !== prevShape && s.shape !== nextShape); if (varied.length) pool = varied;
|
||||
return pick(pool);
|
||||
}
|
||||
function dealReferences(dir, input) {
|
||||
const named = (dir.seenIn || []).map((n) => L.sites.find((s) => s.id === n || s.name === n)).filter(Boolean);
|
||||
// the October 2026 panel scored on a lower scale (its top band starts at 7.5, the first panel's at about 7.6)
|
||||
const good = (s, a, b) => s.score >= (s.panel === '2026-10' ? b : a);
|
||||
const sameKind = L.sites.filter((s) => s.kind === input.sector && good(s, 7.2, 7.3) && !named.includes(s));
|
||||
const out = [...shuffle(named).slice(0, 2), ...shuffle(sameKind).slice(0, 1)];
|
||||
return out.length ? out : shuffle(L.sites.filter((s) => good(s, 7.6, 7.5))).slice(0, 2);
|
||||
}
|
||||
|
||||
// ---------- rendering ----------
|
||||
function siteCard(s, full) {
|
||||
const lines = [`- **${s.name}** (${s.source}, studied score ${s.score}) \`${s.id}\``, ` Idea: ${s.idea}`, ` Hero: ${s.hero}`];
|
||||
if (full) {
|
||||
lines.push(` Type: ${s.type}`, ` Colour: ${s.colour}`, ` Imagery: ${s.imagery}`, ` Layout: ${s.layout}`);
|
||||
if (s.media) lines.push(` Pictures and footage: ${s.media}`);
|
||||
if (s.motion) lines.push(` Motion: ${s.motion}`);
|
||||
for (const sec of s.sections || []) lines.push(` Section (${sec.role}): ${sec.description}`);
|
||||
for (const t of s.steal || []) lines.push(` Worth taking: ${t}`);
|
||||
lines.push(` URL: ${s.url}`);
|
||||
} else if (s.steal?.[0]) lines.push(` Worth taking: ${s.steal[0]}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
function render(s) {
|
||||
const d = s.direction; const o = [];
|
||||
const p = (x = '') => o.push(x);
|
||||
p(`# DIRECTION SHEET — ${d.name}`);
|
||||
p(`seed ${s.seed} · drawn ${s.at.slice(0, 10)}${s.redraws ? ` · redraws: ${s.redraws}` : ''} · sector ${s.input.sector}${s.input.tone ? ' · tone ' + s.input.tone : ''}`);
|
||||
if (s.input.brief) p(`Brief: ${s.input.brief}`);
|
||||
p();
|
||||
p('| | |'); p('| --- | --- |');
|
||||
p(`| Direction | ${d.name} |`);
|
||||
p(`| Hero | ${s.hero.name} |`);
|
||||
p(`| Type | ${s.type ? s.type.name : 'kept from the brand'} |`);
|
||||
p(`| Colour | ${s.palette ? s.palette.name : 'kept from the brand'} |`);
|
||||
p(`| Sections | ${s.sections.map((x) => x.role + (x.name && x.name !== x.role ? ' (' + x.name + ')' : '')).join(' → ')} |`);
|
||||
p(`| Motion | ${s.motion ? s.motion.name : '—'} |`);
|
||||
p(`| Photographs | ${s.photos ? 'the "' + (s.photos.title || s.photos.set) + '" set, as atmosphere' : 'none'} |`);
|
||||
p();
|
||||
p('This is the direction for this build. Adopt it. Do not blend it with the layout you would have made anyway,');
|
||||
p('and do not redraw because it is unfamiliar: unfamiliar is the point. Redraw a slot only when it conflicts');
|
||||
p('with a stated brand constraint or needs an asset that does not exist, and say why in your report.');
|
||||
p();
|
||||
p('The parts of this sheet were written separately and will disagree in small ways. Settle every disagreement in this order:');
|
||||
p('**house rules** (including type no smaller than 13px and body text of 16px or more) → **the direction** (the idea, the composition habits, where the');
|
||||
p('one accent goes) → **the type system and the palette** (which face does which job, the five size steps, which colour is the ground and where the');
|
||||
p('accent goes) → **the hero blueprint** → **the section blueprints**. Under each blueprint below, "Adjusted for this page" lists where it disagrees.');
|
||||
p('In practice: take geometry from blueprints (columns, proportions, what sits where) and take everything else from above them. Map every size a');
|
||||
p('blueprint names onto one of your five type steps instead of adding a sixth. Use one button shape, one corner radius and one pair of page margins on');
|
||||
p('the whole page, whatever individual blueprints say. If a blueprint names a kind of typeface (a light serif, a heavy grotesk), use the dealt face.');
|
||||
p();
|
||||
p('## 1. The idea'); p(d.idea); p();
|
||||
p('Before anything else, write one sentence in your notes: "On this page, <the subject\'s own material> becomes <the device>."');
|
||||
p('Fill the first blank from your inventory of what only this client has (its name and what it means, its street, its numbers, its');
|
||||
p('constraint), then put the nearest competitor in the sentence: if it still works, the idea is the category\'s, not the client\'s.');
|
||||
p('Then name the one live thing the visitor can do on the page (SKILL.md step 4).'); p();
|
||||
p('## 2. Composition'); p(d.composition); p();
|
||||
if (s.type) {
|
||||
const t = s.type;
|
||||
p(`## 3. Type — ${t.name}`); p(t.note);
|
||||
p('```html'); for (const l of [...new Set([t.display.css, t.text.css].filter(Boolean))]) p(l); p('```');
|
||||
p(`- Display: **${t.display.family}** — ${t.display.settings}`);
|
||||
p(`- Text: **${t.text.family}** — ${t.text.settings}`);
|
||||
if (t.scale) { p('```css'); p(':root {'); for (const [k, v] of Object.entries(t.scale)) p(` --${k}: ${v};`); p('}'); p('```'); }
|
||||
if (d.typography) p(d.typography);
|
||||
p('The direction decides sizes and how many of them; the type system decides how this face is set. Where the two give different tracking or leading, use the values of the type system: they are tuned to this face.');
|
||||
p('The display size itself comes from the hero blueprint below.');
|
||||
p();
|
||||
} else { p('## 3. Type'); p('Kept from the brand. ' + (d.typography || '')); p(); }
|
||||
if (s.palette) {
|
||||
const c = s.palette;
|
||||
p(`## 4. Colour — ${c.name}`); p('```css'); p(':root {');
|
||||
for (const k of ['bg', 'surface', 'ink', 'muted', 'accent', 'accentInk']) if (c[k]) p(` --${k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase())}: ${c[k]};`);
|
||||
(c.fields || []).forEach((f, i) => p(` --field-${i + 1}: ${f.bg};${f.ink ? ` --field-${i + 1}-ink: ${f.ink};` : ''}`));
|
||||
p('}'); p('```'); p(c.note); if (d.colour) p(d.colour); p();
|
||||
} else { p('## 4. Colour'); p('Kept from the brand. ' + (d.colour || '')); p(); }
|
||||
p('## 5. Imagery');
|
||||
// where to look for real pictures of the subject's world, with first queries built from the brief
|
||||
const qs = assetQueries(s.input);
|
||||
const find = qs.length
|
||||
? `\`${ASSETS_CMD} "${qs[0]}"\`${qs.length > 1 ? `, then ${qs.slice(1).map((q) => `"${q}"`).join(' and ')}` : ''}, then queries from your inventory (its street, its materials, its trade's tools and history)`
|
||||
: `\`${ASSETS_CMD} "<a place, material or object from your inventory>"\``;
|
||||
const MODE = {
|
||||
interface: '**This subject is software and there are no screenshots, so its pictures are its own interface.** Wherever this sheet says photograph, image or object, build a believable fragment of the product in HTML at real size (real labels, numbers that add up, one item hovered or selected) by the app-screen rules (references/app-screens.md: deal its style once with `node <skill>/scripts/draw.mjs --app --kind <kind> --keep palette,type --out .taste-skill/app`, and build it with assets/app.css and assets/charts.js), put the attribute `data-ui` on its root, and crop or scale it the way the blueprint treats a photograph: a full-bleed close crop, a tile, the fixed half of a split. Where a fragment makes no sense, use a flat colour field with type on it. Never a fake dashboard of grey bars, never a device frame. If the product works on something physical (ships, plants, buildings, a city), one real picture of that world can carry a section: find it with `' + ASSETS_CMD + ' "<that world>"`, download it with `--get`, credit it on the page, and never let it pass for the product or its customers.',
|
||||
'no-product-photo': '**There are no photographs of the product and none can be faked.** Wherever this sheet calls for a picture of the object, draw the object: its pieces as flat shapes in inline SVG, large, in one graphic language and in the colours that make it worth buying (a glaze, a finish, a material), never grey placeholders (references/imagery.md §8). **Around the object, use real pictures of its world** from the open collections: its material up close, its town in old postcards, its craft in engravings, a museum piece of the same kind, never one a visitor could take for the product itself (references/imagery.md §3). Start with ' + find + '. Download what you use with `--get` and credit every one on the page (`--credits` prints the footer line). Type at scale and full colour fields carry the rest; the dealt photographs only where they plainly do not pretend to be the product. In your report, say that product photography is needed and which drawings it will replace, and list every found picture with its source and licence.',
|
||||
atmosphere: '**The project has no photographs of its own yet.** First choice: **real pictures of the subject\'s world from the open collections**: its city and street in old postcards and photographs, an old map of the neighbourhood, its materials up close, its trade in engravings, museum objects of its kind (references/imagery.md §3). Start with ' + find + '. Download what you use into the project with `--get` and credit every one on the page (`--credits` prints the footer line). They show the world around the client, never the client\'s own room, dish, product, person or team. Second, for what no honest picture exists of: draw the subject\'s own things in code, large, in one flat graphic language and the palette\'s real colours (its tiles, its windows, its objects; references/imagery.md §8). Third: the dealt photographs, only where an honestly generic picture fits (the sea, the weather, a material), never one a visitor could take for the thing itself and never an unrelated scene. One treatment for all of them. If this direction is built on photographs, plan the set before building (four to six pictures of one family) and reuse them at different crops and scales instead of finding or drawing a new picture for every slot; keep drawings as `<symbol>` + `<use>` or generate them with a small script so the page stays light. List every found picture (with its source and licence), every dealt photograph and every drawing that stands in for photography in your report.',
|
||||
own: '**Use the images that belong to the project in every picture slot.** For slots they cannot fill, real pictures of the subject\'s world from the open collections come next (start with ' + find + '; credit them on the page), then the dealt photographs.',
|
||||
};
|
||||
p(MODE[s.imagery || 'atmosphere']); p(); p(d.imagery);
|
||||
p(); p('**Floor (the inspector checks it):** at least four real pictures on the page, usually six to twelve; SVG drawings do not count. One moving picture where an honest clip exists: `node <skill>/scripts/assets.mjs --video "<query>"`, used as a muted loop with a poster frame. Whatever this direction says about pictures, it is carried out with real ones.');
|
||||
if (s.photos) {
|
||||
p();
|
||||
p(s.imagery === 'interface' ? `Optional atmosphere, two at most, from the "${s.photos.title || s.photos.set}" set (${s.photos.note}). The page does not need them.` : `Photographs dealt for this build, from the "${s.photos.title || s.photos.set}" set (${s.photos.note})`);
|
||||
p('Order of preference for imagery: (1) the project\'s own photographs and product shots; (2) images you generate, if an image tool is');
|
||||
p('available; (3) real pictures of the subject\'s world found in the open collections (assets.mjs, downloaded and credited on the page);');
|
||||
p('(4) drawings of the subject in code; (5) these. They are real photographs (Unsplash licence, served by picsum.photos) of places, light and');
|
||||
p('texture. They are atmosphere, never a stand-in for a specific product, dish, person or interface. List the ones you use in your report so');
|
||||
p('they can be replaced.');
|
||||
if (s.photos.treatment) p(`Treatment: ${s.photos.treatment}`);
|
||||
for (const ph of s.photos.photos) p(`- \`https://picsum.photos/id/${ph.id}/1600/1000\` — ${ph.alt} · ${ph.tone} tone · ${(ph.colours || []).join(', ')}${ph.mono ? ' · black-and-white' : ''}${ph.lookalike ? ' · **could be taken for the product: never use it as the product**' : ''}${ph.calm && ph.calm !== 'none' ? ` · calm area: ${ph.calm}` : ''} · native ${ph.ratio >= 1.15 ? 'landscape' : ph.ratio <= 0.87 ? 'portrait' : 'square'} · photo: ${ph.by}`);
|
||||
p('The last two path numbers are width and height: request the exact crop you render (`/1200/1500` for a 4:5 portrait, `/2400/1350` for a hero) and');
|
||||
p('add `?grayscale` to desaturate. ' + (s.imagery === 'interface' ? 'Use at most two.' : 'Use three to six of them, not all eight.') + ' Always set `width`, `height`, `alt` and `loading="lazy"` (not on the hero image).');
|
||||
if (s.photos.others?.length) p(`Other sets that fit this brief: ${s.photos.others.join(', ')} — \`node draw.mjs --photos <set>\` prints one in full; \`--photos list\` shows all sets. Taking photographs from another set is your call and is not a redraw; keep them to one light and one treatment.`);
|
||||
} else if (d.photos === false) {
|
||||
p(); p('This direction does not lead with photographs; it still carries the picture floor below, with real pictures placed where the direction allows them.');
|
||||
}
|
||||
p();
|
||||
const adjust = (bp) => { const n = adjustments(bp, s); if (n.length) { p(); p('Adjusted for this page:'); for (const x of n) p(`- ${x}`); } };
|
||||
p(`## 6. Hero — ${s.hero.name}`); p(s.hero.blueprint); p(); p(`On a phone: ${s.hero.mobile}`); if (s.hero.headline) p(`Headline: ${s.hero.headline}`); adjust(s.hero); p();
|
||||
p('## 7. Sections, in order');
|
||||
p('Each is a composition to rebuild with this project\'s content. Keep the geometry; change the subject. Re-order or drop a');
|
||||
p('section when the argument of the page needs it (see references/copy.md), never to fall back to a card grid.');
|
||||
s.sections.forEach((sec, i) => {
|
||||
p();
|
||||
if (!sec.blueprint) { p(`### ${i + 1}. ${sec.role}`); p('No blueprint in the library for this role. Compose it from the direction above, using a layout shape not used by its neighbours.'); return; }
|
||||
p(`### ${i + 1}. ${sec.role} — ${sec.name} (${sec.shape})`); p(sec.blueprint); if (sec.mobile) p(`On a phone: ${sec.mobile}`); adjust(sec);
|
||||
});
|
||||
p();
|
||||
if (s.motion) { p(`## 8. Motion — ${s.motion.name}`); p(s.motion.description); p('This is the signature move, the one loud moment. Around it the page still needs the rest of the motion layer (SKILL.md step 9): an entrance for the parts of every section, feedback on every control, and one moving picture. The inspector fails a page that barely moves. Recipes: references/motion.md.'); p(); }
|
||||
if (s.scenes?.length) {
|
||||
p('## 9. Scroll scenes');
|
||||
p(`The page's big moments: ${s.scenes.length} scroll scenes, tested and ready to fill. They are part of the composition, so build them in step 6 with the section they carry, not as a motion afterthought. Each runs on the shared stage (assets/scenes/stage.js and stage.css, with GSAP and Lenis from the pinned CDN lines in stage.js) and ships its own finished composition for reduced motion and for phones that rest. How to put one on a page, fill it and check it: references/scenes.md. Check them with \`node <skill>/scripts/scenes.mjs <page>\` and look at every sheet.`);
|
||||
for (const sc of s.scenes) {
|
||||
p();
|
||||
p(`### ${sc.name} (\`${sc.id}\`)${sc.carries ? ` — carries the ${sc.carries === 'hero' ? 'first screen' : sc.carries + ' section'}` : ' — a transition between two sections'}`);
|
||||
p(sc.summary);
|
||||
if (sc.place) p(`Where: ${sc.place}`);
|
||||
if (sc.content) p(`Content: ${sc.content}`);
|
||||
if (sc.options) p(`Options (data-options): ${sc.options}`);
|
||||
p(`Files: \`<skill>/assets/scenes/${sc.id}.js\` and \`${sc.id}.css\`; a working example: \`<skill>/assets/scenes/demo/${sc.id}.html\`.${sc.phone ? ' On a phone: ' + sc.phone : ''}`);
|
||||
}
|
||||
p();
|
||||
p('Leave a quiet section between two scenes so each one lands. A scene whose content you cannot fill honestly is redrawn (`--redraw scenes`), never faked.');
|
||||
p();
|
||||
}
|
||||
if (d.richness) { p('## 10. Where the richness comes from'); p(d.richness); p(); }
|
||||
if (d.pitfalls) { p('## 11. How this direction goes wrong'); p(d.pitfalls); p(); }
|
||||
if (s.references?.length) {
|
||||
p('## 12. Real references');
|
||||
p('Studied sites that work this way. Take the principle, never the specifics: no names, copy, images or signature assets of theirs.');
|
||||
for (const r of s.references) p(siteCard(r, false));
|
||||
p('Full notes on any of them: `node draw.mjs --site <id>`.'); p();
|
||||
}
|
||||
p('## Always');
|
||||
p('Headlines two lines or fewer at every width. No eyebrow labels, no monospace, no 01/02/03, no thin rules, no gradients, no shadows at rest,');
|
||||
p('no cream-and-terracotta, no violet. The frame: one h1 on the first screen that says what this is, equal outer margins, no');
|
||||
p('section huddled on one side with the other half empty, no blank stretch over half a screen, a heading on every section.');
|
||||
p('`inspect.mjs` measures all of it.');
|
||||
if (s.allow?.length) p(`This direction is allowed: ${s.allow.join(', ')} (the inspector reads this from the saved sheet).`);
|
||||
p();
|
||||
p(`Saved to ${sheetPath.split('\\').join('/')} · reprint with \`node draw.mjs --show\``);
|
||||
return o.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env node
|
||||
// tasteskill / inspect — render the page in a real browser, measure it, and photograph it.
|
||||
//
|
||||
// node inspect.mjs <url | path/to/file.html> [--out DIR] [--widths 1440,768,390] [--allow rule,rule] [--app] [--film] [--json]
|
||||
//
|
||||
// --film also photographs the scroll as a visitor sees it (a frame every third of a screen, real wheel
|
||||
// input) into desktop-film-NN.jpg, so a scroll scene is judged by its in-between states. A direction
|
||||
// that dealt scroll scenes switches it on by itself.
|
||||
//
|
||||
// --app judges an app screen (dashboard, admin, settings) by the rules in references/app-screens.md; a
|
||||
// direction dealt with draw.mjs --app switches it on by itself.
|
||||
//
|
||||
// Exit code 0 = no FAIL findings. Exit code 1 = at least one FAIL (the page is not done).
|
||||
// Zero dependencies: drives an installed Chrome/Edge/Chromium. Node 22+.
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, createReadStream, readdirSync, unlinkSync } from 'node:fs';
|
||||
import { resolve, join, relative, dirname, extname, sep } from 'node:path';
|
||||
import { createServer } from 'node:http';
|
||||
import { Browser, sleep } from './lib/cdp.mjs';
|
||||
import { probe, frame, layout, app, colourShare } from './lib/probe.mjs';
|
||||
import { judge, judgeLayout, judgeApp, APP_SKIP } from './lib/rules.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name, def) => { const i = args.indexOf('--' + name); if (i === -1) return def; const v = args[i + 1]; args.splice(i, 2); return v; };
|
||||
const has = (name) => { const i = args.indexOf('--' + name); if (i === -1) return false; args.splice(i, 1); return true; };
|
||||
const outDir = resolve(flag('out', '.taste-skill/inspect'));
|
||||
const widths = flag('widths', '1440,768,390').split(',').map(Number).filter(Boolean);
|
||||
let allow = flag('allow', '').split(',').filter(Boolean);
|
||||
const asJson = has('json');
|
||||
let appMode = has('app');
|
||||
let film = has('film');
|
||||
let dealtFonts = []; // the faces the saved sheet dealt, to catch a build that quietly fell back to a default
|
||||
const target = args[0];
|
||||
if (!target || target === '-h' || target.startsWith('--')) {
|
||||
console.error('usage: node inspect.mjs <url | file.html> [--out DIR] [--widths 1440,768,390] [--allow rule,rule] [--app] [--film] [--json]');
|
||||
process.exit(2);
|
||||
}
|
||||
// Allowances granted by the drawn direction (written by draw.mjs) apply automatically.
|
||||
for (const p of ['.taste-skill/direction.json', join(outDir, '..', 'direction.json')]) {
|
||||
if (existsSync(p)) { try { const d = JSON.parse(readFileSync(p, 'utf8')); allow = [...new Set([...allow, ...(d.allow || [])])]; if (d.kind === 'app') appMode = true; if (Array.isArray(d.scenes) && d.scenes.length) film = true; dealtFonts = (d.input?.keep || []).includes('type') ? [] : d.kind === 'app' ? [d.style?.tokens?.font?.family].filter(Boolean) : [d.type?.display?.family, d.type?.text?.family].filter(Boolean); } catch {} break; }
|
||||
}
|
||||
// A local file is served over http from a throwaway server on 127.0.0.1, so the browser never has to
|
||||
// open a file path (some sandboxes show Node and the browser different views of the disk) and relative
|
||||
// assets, fetch() and module scripts behave as they will when the page is hosted.
|
||||
let url = target; let server = null;
|
||||
if (!/^https?:\/\//.test(target)) {
|
||||
const file = resolve(target);
|
||||
if (!existsSync(file)) { console.error('No such file: ' + file); process.exit(2); }
|
||||
const cwd = resolve('.');
|
||||
const root = file.startsWith(cwd + sep) ? cwd : dirname(file);
|
||||
const MIME = { '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf', '.mp4': 'video/mp4', '.webm': 'video/webm', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml', '.webmanifest': 'application/manifest+json' };
|
||||
server = createServer((req, res) => {
|
||||
try {
|
||||
let f = resolve(join(root, decodeURIComponent(new URL(req.url, 'http://x').pathname)));
|
||||
if (f !== root && !f.startsWith(root + sep)) { res.writeHead(403); return res.end(); }
|
||||
if (statSync(f).isDirectory()) f = join(f, 'index.html');
|
||||
res.writeHead(200, { 'content-type': MIME[extname(f).toLowerCase()] || 'application/octet-stream', 'content-length': statSync(f).size, 'cache-control': 'no-store' });
|
||||
createReadStream(f).pipe(res);
|
||||
} catch { res.writeHead(404); res.end('not found'); }
|
||||
});
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
url = `http://127.0.0.1:${server.address().port}/${relative(root, file).split(sep).map(encodeURIComponent).join('/')}`;
|
||||
}
|
||||
const HEIGHT = { 1440: 900, 1280: 800, 1024: 768, 768: 1024, 390: 844, 375: 812 };
|
||||
const name = (w) => (w >= 1100 ? 'desktop' : w >= 600 ? 'tablet' : 'mobile');
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
// Screenshots from an earlier run would sit beside the new ones when the page got shorter.
|
||||
for (const f of readdirSync(outDir)) if (/^(desktop|tablet|mobile)-(fold|page-\d+|film-\d+)\.jpg$/.test(f)) unlinkSync(join(outDir, f));
|
||||
const browser = await Browser.launch();
|
||||
const helper = await browser.newPage({ width: 1600, height: 1000 });
|
||||
const report = { url: server ? target : url, at: new Date().toISOString(), allow, viewports: [], findings: [], images: [] };
|
||||
|
||||
const MOTION_MIN = 8, MOTION_GOOD = 16; // distinct moving elements over load + one scroll through the page
|
||||
// Runs in the page before any of its own scripts: counts the distinct elements that actually move while
|
||||
// the page loads and is scrolled (CSS animations, CSS transitions, Web Animations, and script-driven
|
||||
// transform/opacity changes such as GSAP), so a page that never moves can be told from one that does.
|
||||
const MOTION_RECORDER = `(() => {
|
||||
const seen = new WeakSet(); const c = { elements: 0, css: 0, transitions: 0, scripted: 0 };
|
||||
const note = (t, k) => { if (!(t instanceof Element) || seen.has(t)) return; seen.add(t); c.elements++; c[k]++; };
|
||||
addEventListener('animationstart', (e) => note(e.target, 'css'), true);
|
||||
addEventListener('transitionrun', (e) => note(e.target, 'transitions'), true);
|
||||
const watch = () => new MutationObserver((list) => { for (const m of list) { const s = m.target.getAttribute && m.target.getAttribute('style') || ''; if (/transform|translate|opacity|scale|rotate|clip-path/.test(s)) note(m.target, 'scripted'); } })
|
||||
.observe(document.documentElement, { attributes: true, attributeFilter: ['style'], subtree: true });
|
||||
if (document.documentElement) watch(); else addEventListener('DOMContentLoaded', watch);
|
||||
window.__motionSample = () => { for (const a of document.getAnimations ? document.getAnimations() : []) { const t = a.effect && a.effect.target; if (t) note(t, a.constructor && a.constructor.name === 'CSSTransition' ? 'transitions' : 'css'); } };
|
||||
window.__motion = () => { window.__motionSample(); const v = [...document.querySelectorAll('video')]; return { ...c, videos: v.length, playingVideos: v.filter((x) => x.autoplay || !x.paused).length }; };
|
||||
})();`;
|
||||
|
||||
// Is the page's title on screen and readable: in the viewport, not faded, not masked away?
|
||||
const TITLE_SEEN = `(() => {
|
||||
const h = document.querySelector('h1'); if (!h) return { found: false };
|
||||
const r = h.getBoundingClientRect();
|
||||
if (r.width < 2 || r.height < 2) return { found: true, visible: false, why: 'it has no size yet' };
|
||||
if (r.bottom < 0 || r.top > innerHeight) return { found: false }; // a title below the first screen is the no-title check's business
|
||||
let o = 1; for (let p = h; p && p !== document.documentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.visibility === 'hidden' || cs.display === 'none') return { found: true, visible: false, why: 'it is hidden' }; o *= +cs.opacity; }
|
||||
if (o < 0.5) return { found: true, visible: false, why: 'it is at ' + Math.round(o * 100) + '% opacity' };
|
||||
// text moved out of its own masked line, or a cover lying over it, shows up as nothing at its centre
|
||||
const range = document.createRange(); range.selectNodeContents(h); const rr = range.getBoundingClientRect();
|
||||
const x = Math.min(innerWidth - 1, Math.max(0, rr.left + rr.width / 2)), y = Math.min(innerHeight - 1, Math.max(0, rr.top + Math.min(rr.height, 80) / 2));
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
if (hit && !h.contains(hit) && !hit.contains(h)) { const cs = getComputedStyle(hit); if (cs.backgroundColor !== 'rgba(0, 0, 0, 0)' || /^(IMG|VIDEO|CANVAS)$/.test(hit.tagName)) return { found: true, visible: false, why: 'something lies over it' }; }
|
||||
return { found: true, visible: true };
|
||||
})()`;
|
||||
|
||||
async function sheet(frames, cols, cellW, cellH, path) {
|
||||
const rows = Math.ceil(frames.length / cols);
|
||||
await helper.setViewport({ width: cols * cellW, height: rows * cellH, dsf: 1 });
|
||||
await helper.eval((a) => {
|
||||
document.body.style.cssText = `margin:0;background:#888;display:grid;grid-template-columns:repeat(${a.cols},${a.cellW}px)`;
|
||||
document.body.innerHTML = a.frames.map((s) => `<img src="data:image/jpeg;base64,${s}" style="width:${a.cellW}px;height:${a.cellH}px;display:block;outline:1px solid #888">`).join('');
|
||||
return Promise.all([...document.images].map((i) => i.decode().catch(() => {})));
|
||||
}, { frames, cols, cellW, cellH });
|
||||
await helper.screenshot({ path, quality: 76 });
|
||||
}
|
||||
|
||||
try {
|
||||
for (const w of widths) {
|
||||
const h = HEIGHT[w] || Math.round(w * 0.625);
|
||||
const label = name(w);
|
||||
const page = await browser.newPage({ width: w, height: h, dsf: 1, mobile: w < 600 });
|
||||
await page.send('Page.addScriptToEvaluateOnNewDocument', { source: MOTION_RECORDER }).catch(() => {});
|
||||
const how = await page.goto(url, { timeoutMs: 30000, settleMs: 600 });
|
||||
await sleep(600); // about 1.2s after the load event: is the title there to read yet?
|
||||
const early = await page.eval(TITLE_SEEN).catch(() => null);
|
||||
await page.eval('document.fonts.ready.then(() => true)').catch(() => {});
|
||||
await sleep(800); // let the load choreography finish before judging the first screen
|
||||
const fold = join(outDir, `${label}-fold.jpg`);
|
||||
await page.screenshot({ path: fold, quality: 82 });
|
||||
const metrics = await page.eval(probe);
|
||||
|
||||
// walk the page one screen at a time so scroll reveals fire, photographing each screen
|
||||
const frames = [readFileSync(fold).toString('base64')];
|
||||
const frameChecks = [await page.eval(frame).catch(() => null)];
|
||||
const screens = Math.min(11, Math.max(0, Math.ceil(metrics.page.height / h) - 1));
|
||||
for (let i = 1; i <= screens; i++) {
|
||||
await page.eval(`window.scrollTo({ top: ${i * h}, behavior: 'instant' })`).catch(() => {});
|
||||
await page.wheel(2);
|
||||
await sleep(650);
|
||||
frames.push(await page.screenshot({ quality: 78 }));
|
||||
frameChecks.push(await page.eval(frame).catch(() => null));
|
||||
await page.eval('window.__motionSample && window.__motionSample()').catch(() => {});
|
||||
}
|
||||
const motion = await page.eval('window.__motion ? window.__motion() : null').catch(() => null);
|
||||
// re-measure after everything has revealed (catches content that only exists post-reveal)
|
||||
await page.eval(`window.scrollTo({ top: 0, behavior: 'instant' })`).catch(() => {});
|
||||
await sleep(350);
|
||||
const after = await page.eval(probe).catch(() => metrics);
|
||||
const m = after.headings.length >= metrics.headings.length ? after : metrics;
|
||||
const lay = await page.eval(layout).catch(() => null);
|
||||
const appM = appMode ? await page.eval(app).catch(() => null) : null;
|
||||
if (appM) appM.colourShare = await helper.eval(colourShare, 'data:image/jpeg;base64,' + frames[0]).catch(() => null);
|
||||
|
||||
// scroll scenes (assets/scenes): each must have started, run live on desktop, and leave the page room
|
||||
const sceneState = w >= 1100 && !appMode ? await page.eval(`[...document.querySelectorAll('section.scene, .scene[data-scene]')].map((s) => ({ id: s.dataset.scene || s.id || 'scene', live: s.classList.contains('is-live'), rest: s.classList.contains('is-rest'), h: Math.round(s.getBoundingClientRect().height) }))`).catch(() => []) : [];
|
||||
const sceneFindings = [];
|
||||
if (sceneState.length) {
|
||||
const idle = sceneState.filter((s) => !s.live && !s.rest);
|
||||
if (idle.length && !allow.includes('scene-not-started')) sceneFindings.push({ level: 'FAIL', id: 'scene-not-started', width: w, message: `${idle.length} scene(s) never started: ${idle.map((s) => s.id).join(', ')}.`, fix: 'Load assets/scenes/stage.js and the scene file for each data-scene, then call Stage.smooth(); Stage.init(); at the end of <body> (references/scenes.md, "Putting a scene on a page").' });
|
||||
const atRest = sceneState.filter((s) => s.rest);
|
||||
if (atRest.length && !allow.includes('scene-not-live')) sceneFindings.push({ level: 'FAIL', id: 'scene-not-live', width: w, message: `${atRest.length} scene(s) show their finished composition on desktop instead of running: ${atRest.map((s) => s.id).join(', ')}.`, fix: 'GSAP or ScrollTrigger did not load (check the pinned CDN lines and the console), or the scene threw while building. The scene must run where motion is allowed.' });
|
||||
if (sceneState.length > 4 && !allow.includes('many-scenes')) sceneFindings.push({ level: 'WARN', id: 'many-scenes', width: w, message: `${sceneState.length} scroll scenes on one page.`, fix: 'Two or three scenes, with quiet sections between them, so each one lands. Cut the weakest.' });
|
||||
const held = sceneState.reduce((a, s) => a + Math.max(0, s.h - h), 0);
|
||||
if (held > (metrics.page.height || 1) * 0.6 && !allow.includes('scene-heavy')) sceneFindings.push({ level: 'WARN', id: 'scene-heavy', width: w, message: `Scenes hold ${Math.round(held / h)} of the page's ${Math.round((metrics.page.height || 0) / h)} screens.`, fix: 'Shorten the holds (one screen per beat) or cut a scene; the reading sections need room between them.' });
|
||||
}
|
||||
|
||||
// --film: the scroll as a visitor sees it. Real wheel input and a frame every third of a screen, so a
|
||||
// scroll scene shows its in-between states, not only where each screen lands.
|
||||
const filmSheets = [];
|
||||
if (film && w >= 1100) {
|
||||
await page.eval(`window.scrollTo({ top: 0, behavior: 'instant' })`).catch(() => {});
|
||||
await sleep(700);
|
||||
const shots = []; let lastY = -1, still = 0;
|
||||
for (let i = 0; i < 96; i++) {
|
||||
shots.push(await page.screenshot({ quality: 70 }));
|
||||
await page.wheel(Math.round(h / 3), { step: 100, pauseMs: 16 });
|
||||
await sleep(420);
|
||||
const y = await page.eval('Math.round(scrollY || document.scrollingElement.scrollTop || 0)').catch(() => -1);
|
||||
if (y === lastY) { if (++still >= 2) break; } else still = 0;
|
||||
lastY = y;
|
||||
}
|
||||
for (let i = 0; i < shots.length; i += 24) {
|
||||
const p = join(outDir, `${label}-film-${String(i / 24 + 1).padStart(2, '0')}.jpg`);
|
||||
await sheet(shots.slice(i, i + 24), 6, 320, 200, p);
|
||||
filmSheets.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
const cols = w >= 1100 ? 2 : w >= 600 ? 3 : 4;
|
||||
const cellW = w >= 1100 ? 800 : w >= 600 ? 512 : 390;
|
||||
const cellH = Math.round((cellW * h) / w);
|
||||
const perSheet = w >= 1100 ? 4 : w >= 600 ? 6 : 8;
|
||||
const sheets = [];
|
||||
for (let i = 0; i < frames.length; i += perSheet) {
|
||||
const p = join(outDir, `${label}-page-${String(i / perSheet + 1).padStart(2, '0')}.jpg`);
|
||||
await sheet(frames.slice(i, i + perSheet), cols, cellW, cellH, p);
|
||||
sheets.push(p);
|
||||
}
|
||||
let findings = [...judge(m, { width: w, allow }), ...sceneFindings];
|
||||
if (dealtFonts.length && w >= 1100 && !allow.includes('font-not-dealt')) {
|
||||
const norm = (f) => String(f).toLowerCase().replace(/["']/g, '').replace(/\s+variable$/, '').trim();
|
||||
const used = m.type.families.filter((f) => f.share >= 0.1).map((f) => norm(f.family));
|
||||
const missing = dealtFonts.filter((f) => !used.some((u) => u === norm(f) || u.startsWith(norm(f))));
|
||||
if (missing.length === dealtFonts.length) findings.push({ level: appMode ? 'FAIL' : 'WARN', id: 'font-not-dealt', width: w, message: `The sheet dealt ${dealtFonts.join(' and ')}, but the page renders ${m.type.families.slice(0, 2).map((f) => f.family).join(', ')}.`, fix: 'Load the dealt face (the link on the sheet) and put it first in font-family. A build that falls back to a default face lands on the default look.' });
|
||||
}
|
||||
// a third of the studied award pages were still unreadable 4.5s after load; the house sequence is over by 1.2s
|
||||
if (!appMode && early && early.found && !early.visible && !allow.includes('late-title')) findings.push({ level: 'FAIL', id: 'late-title', width: w, message: `The title is not readable 1.2s after the page loaded (${early.why}).`, fix: 'Put the headline in the HTML, painted on the first frame; the load sequence only adds arrival and is settled by 1.2s (references/motion.md, "Load sequence in 1.2 seconds"). No preloader, no gate, no waiting for the load event.' });
|
||||
if (lay) findings.push(...judgeLayout(lay, { width: w, allow }));
|
||||
if (appMode) {
|
||||
findings = findings.filter((f) => !APP_SKIP.has(f.id) && f.id !== 'no-title' && f.id !== 'small-title');
|
||||
if (appM) findings.push(...judgeApp(appM, m, { width: w, allow }));
|
||||
}
|
||||
// what only shows up while scrolling: text hidden under something, and screens with almost nothing on them
|
||||
const coveredText = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => f.covered.map((c) => [c.text, { ...c, screen: i + 1 }]))).values()];
|
||||
if (coveredText.length && !allow.includes('covered-text')) findings.push({ level: 'FAIL', id: 'covered-text', width: w, message: `Large text lies under another element at some scroll position: ` + coveredText.slice(0, 4).map((c) => `"${c.text}" (screen ${c.screen}, ${Math.round(c.share * 100)}% hidden)`).join('; '), fix: 'Nothing may sit on top of a headline or statement. Move the picture, shorten the pinned run, or raise the text above it (z-index) and give it a ground.' });
|
||||
const clippedText = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => (f.clipped || []).map((c) => [c.text, { ...c, screen: i + 1 }]))).values()];
|
||||
if (clippedText.length && !allow.includes('clipped-text')) findings.push({ level: 'FAIL', id: 'clipped-text', width: w, message: 'Large text is cut off along its top by the box that holds it: ' + clippedText.slice(0, 3).map((c) => `"${c.text}" (screen ${c.screen})`).join('; '), fix: 'Jurors read a wordmark sliced at the top as broken. Give the box room for the ascenders (line-height and padding), or crop at the bottom or a side edge on purpose.' });
|
||||
const overPictures = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => (f.pinnedOver || []).map((c) => [c.text, { ...c, screen: i + 1 }]))).values()];
|
||||
if (overPictures.length && !allow.includes('pinned-over-picture')) findings.push({ level: 'WARN', id: 'pinned-over-picture', width: w, message: 'Pinned large text slides across pictures while the page scrolls: ' + overPictures.slice(0, 3).map((c) => `"${c.text}" (screen ${c.screen}, ${Math.round(c.share * 100)}% over a picture)`).join('; '), fix: 'Jurors read a pinned title crossing drawings or photographs as a collision. Keep the pictures in the column the title does not occupy, or unpin it.' });
|
||||
const empty = frameChecks.map((f, i) => ({ f, i })).filter(({ f }) => f && f.filled < 0.2);
|
||||
// the same large statement on screen for three screens or more in a row
|
||||
const runs = new Map();
|
||||
frameChecks.forEach((f, i) => { for (const t of f?.big || []) { const r = runs.get(t) || { start: i, last: i - 1, best: 0 }; if (r.last === i - 1) r.last = i; else { r.start = i; r.last = i; } r.best = Math.max(r.best, r.last - r.start + 1); runs.set(t, r); } });
|
||||
const longPins = [...runs.entries()].filter(([, r]) => r.best >= 3);
|
||||
if (w >= 1100 && longPins.length && !allow.includes('long-pin')) findings.push({ level: 'WARN', id: 'long-pin', width: w, message: `The same large text stays on screen for ${Math.max(...longPins.map(([, r]) => r.best))} screens in a row: ` + longPins.slice(0, 2).map(([t]) => `"${t}"`).join('; '), fix: 'A pinned element earns two screens, and something must change at each. Shorten the run or give every screen its own content.' });
|
||||
// a page that barely moves: counted from what really animated while it loaded and was scrolled
|
||||
if (w >= 1100 && motion && !allow.includes('still-page')) {
|
||||
const moving = motion.elements + motion.playingVideos * 4;
|
||||
if (moving < MOTION_MIN) findings.push({ level: 'FAIL', id: 'still-page', width: w, message: `Only ${motion.elements} element(s) moved while the page loaded and scrolled${motion.playingVideos ? ` (plus ${motion.playingVideos} playing video)` : ''}.`, fix: 'Build the motion layer (SKILL.md step 9, references/motion.md): the signature move, an entrance for the parts of every section, feedback on every control, and one moving picture. Transform and opacity only, with a reduced-motion fallback.' });
|
||||
else if (moving < MOTION_GOOD) findings.push({ level: 'WARN', id: 'still-page', width: w, message: `Little motion: ${motion.elements} element(s) moved while the page loaded and scrolled.`, fix: 'Give every section an entrance for its parts (not one fade of the whole block) and add one moving picture: a looping clip or a slow move on a photograph.' });
|
||||
}
|
||||
if (w >= 1100 && empty.length >= 2 && !allow.includes('empty-screens')) findings.push({ level: 'FAIL', id: 'empty-screens', width: w, message: `${empty.length} of ${frameChecks.length} desktop screens are almost empty ground (under 20% of the screen holds text, a picture or a colour field): screens ${empty.map(({ i }) => i + 1).join(', ')}.`, fix: 'An empty screen is a rest only once per page. Elsewhere it means the section is underbuilt: set its content larger, give it the full information, put it on a colour field, or cut it.' });
|
||||
report.viewports.push({ width: w, height: h, load: how, screens: frames.length, filled: frameChecks.map((f) => f?.filled ?? null), motion, metrics: m, layout: lay, app: appM, consoleErrors: page.consoleErrors.slice(0, 5) });
|
||||
report.findings.push(...(appMode ? findings.filter((f) => !APP_SKIP.has(f.id)) : findings));
|
||||
report.images.push({ viewport: label, width: w, fold, pages: sheets, film: filmSheets });
|
||||
await page.close();
|
||||
}
|
||||
// Under prefers-reduced-motion the page is the finished page: nothing may stay hidden waiting for an
|
||||
// animation that will not run. Two in three studied award pages had no reduced-motion rules at all.
|
||||
if (!appMode && widths.some((x) => x >= 1100) && !allow.includes('reduced-hidden')) {
|
||||
const rp = await browser.newPage({ width: 1440, height: 900, dsf: 1, reducedMotion: true });
|
||||
try {
|
||||
await rp.goto(url, { timeoutMs: 30000, settleMs: 1500 });
|
||||
const hidden = await rp.eval(`(() => {
|
||||
const out = []; const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); const rg = document.createRange();
|
||||
for (let n = w.nextNode(); n; n = w.nextNode()) {
|
||||
const t = n.nodeValue.trim(); const el = n.parentElement; if (t.length < 3 || !el || el.closest('script, style, noscript, [aria-hidden="true"]')) continue;
|
||||
rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.width < 2 || r.top > innerHeight || r.bottom < 0) continue;
|
||||
let o = 1, gone = false; for (let p = el; p && p !== document.documentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.visibility === 'hidden') { gone = true; break; } o *= +cs.opacity; }
|
||||
if (gone || o < 0.1) out.push(t.slice(0, 40));
|
||||
}
|
||||
return out;
|
||||
})()`).catch(() => []);
|
||||
if (hidden.length >= 2) report.findings.push({ level: 'FAIL', id: 'reduced-hidden', width: 1440, message: `With reduced motion requested, ${hidden.length} text block(s) on the first screen stay invisible: ` + hidden.slice(0, 4).map((t) => `"${t}"`).join(', '), fix: 'Put every hidden starting state inside @media (prefers-reduced-motion: no-preference); the reduced page is the finished page (references/motion.md §8).' });
|
||||
} finally { await rp.close(); }
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
if (server) server.close();
|
||||
}
|
||||
|
||||
// de-duplicate findings that repeat across viewports (keep the widest occurrence, note the others)
|
||||
const merged = [];
|
||||
for (const f of report.findings) {
|
||||
const same = merged.find((x) => x.id === f.id && x.level === f.level);
|
||||
if (same && f.id !== 'headline-lines' && f.id !== 'overflow') { same.alsoAt = [...(same.alsoAt || []), f.width]; continue; }
|
||||
merged.push(f);
|
||||
}
|
||||
report.findings = merged;
|
||||
writeFileSync(join(outDir, 'report.json'), JSON.stringify(report, null, 1));
|
||||
|
||||
if (asJson) { console.log(JSON.stringify(report, null, 1)); process.exit(merged.some((f) => f.level === 'FAIL') ? 1 : 0); }
|
||||
|
||||
const d = report.viewports[0].metrics;
|
||||
const line = (s = '') => console.log(s);
|
||||
line(`TASTESKILL · INSPECT ${report.url}`);
|
||||
line('');
|
||||
line('MEASURED (first viewport listed)');
|
||||
if (d.hero) line(` hero headline "${d.hero.text.slice(0, 70)}" ${d.hero.words} words · ${d.hero.lines} line(s) · ${d.hero.size}px (${d.hero.vw}vw) · ${d.hero.font} ${d.hero.weight} · tracking ${d.hero.tracking}em · leading ${d.hero.leading}`);
|
||||
for (const v of report.viewports.slice(1)) if (v.metrics.hero) line(` @${v.width}px ${v.metrics.hero.lines} line(s) · ${v.metrics.hero.size}px`);
|
||||
line(` typefaces ${d.type.families.filter((f) => f.share >= 0.02).map((f) => `${f.family} ${Math.round(f.share * 100)}%`).join(', ')}`);
|
||||
line(` sizes in use ${d.type.sizes.join(' / ')} px${d.type.body ? ` body ${d.type.body.size}px / ${d.type.body.leading}` : ''}`);
|
||||
line(` colour page ${d.color.bodyBg}${d.color.sectionBgs.length ? ' · sections ' + d.color.sectionBgs.map((s) => s.color).slice(0, 6).join(' ') : ''}${d.color.button ? ' · button ' + d.color.button.bg : ''}`);
|
||||
line(` page ${d.page.screens} screens · ${d.page.words} words · ${d.page.img} images · hero media ${d.heroMedia[0] ? Math.round(d.heroMedia[0].ratio * 100) + '% ' + d.heroMedia[0].type : 'none'}`);
|
||||
line(` headings ${d.headingSummary.count} · longest ${d.headingSummary.maxLines} line(s) · median ${d.headingSummary.medianWords ?? '-'} words`);
|
||||
line('');
|
||||
const fails = merged.filter((f) => f.level === 'FAIL'), warns = merged.filter((f) => f.level === 'WARN');
|
||||
if (!fails.length && !warns.length) line('FINDINGS none. Measurements are clean — now judge the pictures.');
|
||||
for (const [title, list] of [['FAIL — fix every one of these', fails], ['WARN — fix, or be able to defend', warns]]) {
|
||||
if (!list.length) continue;
|
||||
line(title);
|
||||
for (const f of list) {
|
||||
line(` [${f.id}] @${f.width}px${f.alsoAt ? ' (+' + f.alsoAt.join(', ') + ')' : ''} ${f.message}`);
|
||||
line(` → ${f.fix}`);
|
||||
}
|
||||
line('');
|
||||
}
|
||||
line('LOOK AT THE RENDER — open these with your image viewer / Read tool before saying anything about quality:');
|
||||
const rel = (p) => relative(process.cwd(), p).split('\\').join('/');
|
||||
for (const im of report.images) { line(` ${(im.viewport + ' ' + im.width + 'px').padEnd(15)} ${rel(im.fold)}`); for (const p of im.pages) line(` ${''.padEnd(15)} ${rel(p)}`); for (const p of im.film || []) line(` ${'scroll film'.padEnd(15)} ${rel(p)} (6 frames a row, a third of a screen apart)`); }
|
||||
line('');
|
||||
line(`report: ${rel(join(outDir, 'report.json'))}`);
|
||||
line(fails.length ? `RESULT: ${fails.length} FAIL, ${warns.length} WARN — not done.` : `RESULT: 0 FAIL, ${warns.length} WARN — measurements pass. Passing the meter is the floor, not the grade: score the screenshots with references/critique.md.`);
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
@@ -0,0 +1,203 @@
|
||||
// tasteskill / draw --app — deal a style for an app screen (dashboard, admin, settings, inbox).
|
||||
//
|
||||
// node draw.mjs --app --kind <kind> --brief "one line" [--screens overview,list,detail,settings]
|
||||
// [--theme light|dark] [--keep palette,type] [--options 3] [--style <id>] [--seed N]
|
||||
// node draw.mjs --show reprint the saved sheet
|
||||
// node draw.mjs --redraw style|shell|screen:<name>
|
||||
// node draw.mjs --app --list kinds|styles|screens
|
||||
//
|
||||
// Same idea as the page draw: the script deals, you choose among what it dealt, so two products do not
|
||||
// end up in the same default look. The library (library/app.json) was distilled from a study of real
|
||||
// product screens; it holds styles (tokens and the one move that makes each recognisable), shells and
|
||||
// screen patterns.
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const LIB = JSON.parse(readFileSync(join(HERE, '..', '..', 'library', 'app.json'), 'utf8'));
|
||||
const argv = process.argv.slice(2);
|
||||
const opt = (name, def) => { const i = argv.indexOf('--' + name); if (i === -1) return def; const v = argv[i + 1]; return v === undefined || v.startsWith('--') ? true : v; };
|
||||
const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : []);
|
||||
const outDir = resolve(String(opt('out', '.taste-skill')));
|
||||
const sheetPath = join(outDir, 'direction.json');
|
||||
|
||||
const KINDS = Object.keys(LIB.kinds);
|
||||
// signature patterns are elements of one kind of product (a power flow, a sell-out strip), not whole screens
|
||||
const SCREENS = [...new Set(LIB.patterns.filter((p) => p.screen).map((p) => p.screen))];
|
||||
const SIGNATURES = LIB.patterns.filter((p) => p.signature);
|
||||
const signaturesFor = (brief = '') => { const b = brief.toLowerCase(); return SIGNATURES.filter((p) => (p.cues || []).some((c) => new RegExp('(^|[^a-z])' + c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(b))); };
|
||||
|
||||
if (opt('list')) {
|
||||
const what = opt('list');
|
||||
if (what === 'kinds') for (const [k, v] of Object.entries(LIB.kinds)) console.log(`${k.padEnd(14)} ${v.note}`);
|
||||
else if (what === 'screens') for (const s of SCREENS) console.log(`${s.padEnd(12)} ${LIB.patterns.filter((p) => p.screen === s).map((p) => p.id).join(', ')}`);
|
||||
else if (what === 'signatures') for (const p of SIGNATURES) console.log(`${p.id.padEnd(16)} ${p.name} — ${(p.cues || []).slice(0, 5).join(', ')}`);
|
||||
else for (const s of LIB.styles) console.log(`${s.id.padEnd(22)} ${s.name} — ${s.theme}, ${s.suits.join(', ')}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let seed = opt('seed') !== undefined && opt('seed') !== true ? Number(opt('seed')) : randomInt(1, 2 ** 31 - 1);
|
||||
const startSeed = seed;
|
||||
const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
||||
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
|
||||
const userHist = process.env.TASTE_SKILL_HISTORY || join(homedir(), '.taste-skill', 'history.json');
|
||||
const readHist = () => { try { return JSON.parse(readFileSync(userHist, 'utf8')); } catch { return []; } };
|
||||
const writeHist = (e) => { try { mkdirSync(dirname(userHist), { recursive: true }); const h = readHist(); h.unshift(e); writeFileSync(userHist, JSON.stringify(h.slice(0, 24), null, 1)); } catch {} };
|
||||
const recentStyles = readHist().filter((h) => h.kind === 'app').slice(0, 3).map((h) => h.style);
|
||||
|
||||
const saved = existsSync(sheetPath) ? JSON.parse(readFileSync(sheetPath, 'utf8')) : null;
|
||||
if (opt('show')) { if (!saved || saved.kind !== 'app') { console.error('No saved app sheet. Run draw.mjs --app first.'); process.exit(2); } console.log(render(saved)); process.exit(0); }
|
||||
|
||||
function stylePool(input) {
|
||||
const pref = LIB.kinds[input.kind]?.styles || [];
|
||||
let pool = LIB.styles.filter((s) => !(input.avoid || []).includes(s.id));
|
||||
if (input.theme) { const t = pool.filter((s) => s.theme === input.theme); if (t.length) pool = t; }
|
||||
const fit = pool.filter((s) => pref.includes(s.id) || s.suits.includes(input.kind));
|
||||
if (fit.length >= 2) pool = fit;
|
||||
return pool;
|
||||
}
|
||||
function dealStyle(input, exclude = []) {
|
||||
let pool = stylePool(input).filter((s) => !exclude.includes(s.id));
|
||||
if (!pool.length) pool = LIB.styles.filter((s) => !exclude.includes(s.id));
|
||||
const fresh = pool.filter((s) => !recentStyles.includes(s.id));
|
||||
return pick(fresh.length ? fresh : pool.length ? pool : LIB.styles);
|
||||
}
|
||||
// The style's own shell usually carries part of its move, so it is dealt about two times in three.
|
||||
function dealShell(style, input, exclude = []) {
|
||||
const own = LIB.shells.find((s) => s.id === style.shell && !exclude.includes(s.id));
|
||||
const alts = LIB.shells.filter((s) => (style.shellAlternatives || []).includes(s.id) && !exclude.includes(s.id));
|
||||
if (own && (!alts.length || rnd() < 0.66)) return own;
|
||||
if (alts.length) return pick(alts);
|
||||
const rest = LIB.shells.filter((s) => !exclude.includes(s.id));
|
||||
return pick(rest.length ? rest : LIB.shells);
|
||||
}
|
||||
function dealScreen(name, style, input, exclude = []) {
|
||||
let pool = LIB.patterns.filter((p) => p.screen === name && !exclude.includes(p.id));
|
||||
// a pattern written for another kind of product brings that product's furniture with it (a getting-started strip on a
|
||||
// leave planner): when none fits this kind, the sheet says to compose the screen from the chapter instead
|
||||
pool = pool.filter((p) => !p.kinds || p.kinds.includes(input.kind));
|
||||
return pool.length ? pick(pool) : null;
|
||||
}
|
||||
function deal(input) {
|
||||
const style = input.style ? LIB.styles.find((s) => s.id === input.style) : dealStyle(input);
|
||||
if (!style) { console.error(`No app style ${input.style}. List them with --app --list styles.`); process.exit(2); }
|
||||
const s = { version: 1, kind: 'app', at: new Date().toISOString(), seed: startSeed, input, style, shell: dealShell(style, input), screens: [] };
|
||||
for (const name of input.screens) s.screens.push({ name, pattern: dealScreen(name, style, input) });
|
||||
return s;
|
||||
}
|
||||
|
||||
let sheet;
|
||||
if (opt('redraw') && saved?.kind === 'app') {
|
||||
sheet = saved; sheet.redraws = (sheet.redraws || 0) + 1;
|
||||
const what = String(opt('redraw'));
|
||||
if (what === 'style') { sheet.style = dealStyle(sheet.input, [sheet.style.id]); }
|
||||
else if (what === 'shell') sheet.shell = dealShell(sheet.style, sheet.input, [sheet.shell.id]);
|
||||
else if (what.startsWith('screen:')) { const n = what.slice(7); const i = sheet.screens.findIndex((x) => x.name === n); if (i === -1) { console.error('No screen ' + n + ' on this sheet.'); process.exit(2); } sheet.screens[i].pattern = dealScreen(n, sheet.style, sheet.input, [sheet.screens[i].pattern?.id]) || sheet.screens[i].pattern; }
|
||||
else { console.error('Redraw style, shell or screen:<name>.'); process.exit(2); }
|
||||
} else {
|
||||
const kind = String(opt('kind', ''));
|
||||
if (!KINDS.includes(kind)) { console.error(`--kind is required with --app. One of:\n ${KINDS.join(', ')}`); process.exit(2); }
|
||||
const screens = list(opt('screens')).length ? list(opt('screens')) : (LIB.kinds[kind].screens || ['overview']);
|
||||
const bad = screens.filter((x) => !SCREENS.includes(x));
|
||||
if (bad.length) { console.error(`Unknown screen(s): ${bad.join(', ')}. One of: ${SCREENS.join(', ')}`); process.exit(2); }
|
||||
const input = { kind, brief: typeof opt('brief') === 'string' ? opt('brief') : '', theme: ['light', 'dark'].includes(opt('theme')) ? opt('theme') : null, screens, keep: list(opt('keep')), style: typeof opt('style') === 'string' ? opt('style') : null };
|
||||
if (opt('options')) {
|
||||
const n = Math.max(2, Math.min(5, Number(opt('options')) || 3)); const pool = stylePool(input); const out = [];
|
||||
let rest = pool.filter((s) => !recentStyles.includes(s.id)); if (rest.length < n) rest = pool;
|
||||
while (out.length < n && rest.length) { const s = pick(rest); out.push(s); rest = rest.filter((x) => x !== s); }
|
||||
console.log(`# ${out.length} app styles dealt for this brief\n`);
|
||||
console.log('Choose the one that suits how this product is used (long sessions or a quick look, money or media, a team or one person), not the most familiar. Then deal it with the same command plus `--style <id>` (and without --options).\n');
|
||||
for (const s of out) console.log(`## \`${s.id}\` — ${s.name} (${s.theme})\n${s.idea}\nThe move: ${s.signature}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
sheet = deal(input);
|
||||
}
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(sheetPath, JSON.stringify(sheet, null, 1));
|
||||
if (!opt('redraw')) writeHist({ at: sheet.at, kind: 'app', style: sheet.style.id, shell: sheet.shell.id });
|
||||
console.log(render(sheet));
|
||||
process.exit(0);
|
||||
|
||||
function kebab(k) { return k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); }
|
||||
// A Google Fonts link for the style's faces (all of them are free there).
|
||||
function fontLink(f) {
|
||||
const fam = (name, w) => 'family=' + name.trim().replace(/ /g, '+') + ':wght@' + w.join(';');
|
||||
const fams = [fam(f.family, f.weights || [400, 500, 600])];
|
||||
const second = f.second && f.second.split(',')[0].trim();
|
||||
if (second && !/^(system|inter\b)/i.test(second)) fams.push(fam(second, [400, 500]));
|
||||
return `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n<link href="https://fonts.googleapis.com/css2?${fams.join('&')}&display=swap" rel="stylesheet">`;
|
||||
}
|
||||
function render(s) {
|
||||
const st = s.style, t = st.tokens, o = []; const p = (x = '') => o.push(x);
|
||||
p(`# APP SHEET — ${st.name}`);
|
||||
p(`seed ${s.seed} · drawn ${s.at.slice(0, 10)}${s.redraws ? ` · redraws: ${s.redraws}` : ''} · kind ${s.input.kind} · ${st.theme}`);
|
||||
if (s.input.brief) p(`Brief: ${s.input.brief}`);
|
||||
p();
|
||||
p('This is the style for this product. Adopt it whole and keep its move intact; everything the sheet does not fix comes from the');
|
||||
p('shared defaults in references/app-screens.md. Redraw only for a reason you can state (it contradicts the brand, or the product is');
|
||||
p('used in a way the style does not suit) and say why in your report. The values were measured on real product screens: use them');
|
||||
p('as given rather than rounding them towards your habits.');
|
||||
p();
|
||||
p('## 1. The style'); p(st.idea); p(); p(`**The move:** ${st.signature}`); p();
|
||||
if (st.colourBudget) p(`**Colour budget:** ${st.colourBudget}.`);
|
||||
if (st.avoid?.length) p('**Off-style here:** ' + st.avoid.join('; ') + '.');
|
||||
if (st.basis) p(`Distilled from ${st.basis}.${st.notes ? ' ' + st.notes : ''}`);
|
||||
p();
|
||||
// the style makes a screen correct; its face makes it this product's (references/app-screens.md, §2.3)
|
||||
if (st.identity?.length) {
|
||||
p('**Its face.** Choose one of these (or one of your own of the same weight) before you build, write it in your notes next to');
|
||||
p('the screen\'s question, and check that it shows on the first screen. Without one the screen reads as a well-applied kit.');
|
||||
for (const x of st.identity) p('- ' + x);
|
||||
p();
|
||||
}
|
||||
const sigs = signaturesFor(s.input.brief);
|
||||
if (sigs.length) {
|
||||
p('**Signature elements this brief names** (an element only this kind of product has; build one of them as part of a screen):');
|
||||
for (const g of sigs) p(`- **${g.name}** (\`${g.id}\`). ${g.blueprint}${g.phone ? ' On a phone: ' + g.phone : ''}`);
|
||||
p();
|
||||
}
|
||||
const f = t.font || {};
|
||||
if (!s.input.keep.includes('palette')) {
|
||||
p('## 2. Tokens'); p('```css'); p(':root {');
|
||||
for (const [k, v] of Object.entries(t)) {
|
||||
if (k === 'font' || k === 'density') continue;
|
||||
if (typeof v === 'string') p(` --${kebab(k)}: ${v};`);
|
||||
else if (typeof v === 'number') p(` --${kebab(k)}: ${v}px;`);
|
||||
else if (k === 'chart') v.forEach((c, i) => p(` --chart-${i + 1}: ${c};`));
|
||||
else if (k === 'pills') for (const [n, [bg, ink]] of Object.entries(v)) p(` --pill-${n}-bg: ${bg}; --pill-${n}-ink: ${ink};`);
|
||||
else if (k === 'radius') for (const [n, r] of Object.entries(v)) p(` --radius-${n}: ${r}px;`);
|
||||
}
|
||||
if (f.sizes) for (const [n, v] of Object.entries(f.sizes)) p(` --fs-${n}: ${v}px;`);
|
||||
p('}'); p('```');
|
||||
p(`Density: ${t.density}. One gap value, horizontally and vertically. ${t.shadow ? 'The shadow belongs to this dark style\'s cards only; nothing else casts one.' : 'No shadow on anything at rest.'}`);
|
||||
p();
|
||||
} else { p('## 2. Tokens'); p('Kept from the brand: map its colours onto this style\'s roles (ground, surface, raised, border, rule, control, text, muted, accent, status pills, chart) and keep its proportions: ' + (st.colourBudget || 'colour on data and status only') + '. Take the sizes, radii, row and control heights from the style as they are.'); p(); }
|
||||
if (!s.input.keep.includes('type') && f.family) {
|
||||
p(`## 3. Type — ${f.family}`);
|
||||
p('```html'); p(fontLink(f)); p('```');
|
||||
p(`\`font-family: "${f.family}", ${f.fallback || 'system-ui, sans-serif'}\`. Weights ${(f.weights || []).join(', ')}. Sizes: ${Object.entries(f.sizes || {}).map(([k, v]) => `${k} ${v}px`).join(' · ')}.${f.note ? ' ' + f.note : ''}${f.second ? ' Second face: ' + f.second + '.' : ''}`);
|
||||
p('Numbers: `font-variant-numeric: tabular-nums` on every table, KPI and anything that updates. Sentence case; no tracked capitals.'); p();
|
||||
}
|
||||
p(`## 4. Shell — ${s.shell.name}`); p(s.shell.blueprint);
|
||||
if (s.shell.numbers) p('Numbers: ' + Object.entries(s.shell.numbers).map(([k, v]) => `${k} ${v}`).join('; ') + '.');
|
||||
if (s.shell.phone) p(`On a phone: ${s.shell.phone}`); p();
|
||||
p('## 5. Screens');
|
||||
for (const sc of s.screens) {
|
||||
p();
|
||||
if (!sc.pattern) { p(`### ${sc.name}`); p('No pattern in the library for this screen. Compose it from the rules in references/app-screens.md.'); continue; }
|
||||
p(`### ${sc.name} — ${sc.pattern.name}`); p(sc.pattern.blueprint); if (sc.pattern.phone) p(`On a phone: ${sc.pattern.phone}`);
|
||||
}
|
||||
p();
|
||||
p('## 6. Always');
|
||||
p('Real data for this product, written before the markup: names, amounts that add up, dates in order, units on every figure,');
|
||||
p('honest zeros and dashes. One chart answers one question. No gradients, glows, glass, icons in tinted squares, sparklines on KPIs,');
|
||||
p('rainbow charts, emoji, lorem or Acme. `<skill>/assets/app.css` and `<skill>/assets/charts.js` are a neutral start set to these');
|
||||
p('rules. Check with `node <skill>/scripts/lint.mjs <files>` and `node <skill>/scripts/inspect.mjs <page>`: this sheet switches both');
|
||||
p('to app rules.');
|
||||
p();
|
||||
p(`Saved to ${sheetPath.split('\\').join('/')} · reprint with \`node draw.mjs --show\``);
|
||||
return o.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Zero-dependency Chrome DevTools Protocol client.
|
||||
// Drives an installed Chrome / Edge / Chromium in headless mode over Node's built-in WebSocket.
|
||||
// Requires Node 22+. No npm install.
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { tmpdir, hostname } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export function findChrome() {
|
||||
const env = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
if (env && existsSync(env)) return env;
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const local = process.env.LOCALAPPDATA || '';
|
||||
const pf = process.env.PROGRAMFILES || 'C:\\Program Files';
|
||||
const pf86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';
|
||||
const candidates = [
|
||||
// Windows
|
||||
join(pf, 'Google/Chrome/Application/chrome.exe'),
|
||||
join(pf86, 'Google/Chrome/Application/chrome.exe'),
|
||||
join(local, 'Google/Chrome/Application/chrome.exe'),
|
||||
join(pf86, 'Microsoft/Edge/Application/msedge.exe'),
|
||||
join(pf, 'Microsoft/Edge/Application/msedge.exe'),
|
||||
join(pf, 'BraveSoftware/Brave-Browser/Application/brave.exe'),
|
||||
// macOS
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
||||
join(home, 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
|
||||
// Linux
|
||||
'/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser', '/snap/bin/chromium', '/usr/bin/microsoft-edge',
|
||||
];
|
||||
for (const c of candidates) if (c && existsSync(c)) return c;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Each profile carries an owner file naming the machine and the two processes that use it (the run and
|
||||
// its Chrome). A later run removes a profile left by a killed run only when that file names this machine
|
||||
// and neither process is alive. A profile without one (made before 2.1.4, or by another tool) is never touched.
|
||||
const OWNER = 'taste-skill-owner.json';
|
||||
|
||||
function alive(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
||||
}
|
||||
|
||||
export function sweepProfiles(base) {
|
||||
let names;
|
||||
try { names = readdirSync(base); } catch { return; }
|
||||
for (const name of names) {
|
||||
if (!name.startsWith('ad-chrome-')) continue;
|
||||
const p = join(base, name);
|
||||
try {
|
||||
const o = JSON.parse(readFileSync(join(p, OWNER), 'utf8'));
|
||||
if (o.host !== hostname() || !Number.isInteger(o.pid) || alive(o.pid) || alive(o.chrome)) continue;
|
||||
rmSync(p, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export class Browser {
|
||||
static async launch({ width = 1440, height = 900, extraArgs = [] } = {}) {
|
||||
if (typeof WebSocket === 'undefined') {
|
||||
throw new Error('Node 22+ is required (built-in WebSocket missing). Current: ' + process.version);
|
||||
}
|
||||
const exe = findChrome();
|
||||
if (!exe) {
|
||||
throw new Error('No Chrome/Edge/Chromium found. Install one or set CHROME_PATH to its executable.');
|
||||
}
|
||||
// A throwaway profile per run, its cache capped (each run starts cold anyway), removed on exit. It is
|
||||
// claimed with an owner file before Chrome starts, so another run's sweep can tell when it was abandoned.
|
||||
const base = process.env.TASTE_SKILL_TMP || tmpdir();
|
||||
sweepProfiles(base);
|
||||
const dir = mkdtempSync(join(base, 'ad-chrome-'));
|
||||
const claim = (chrome) => {
|
||||
try { writeFileSync(join(dir, OWNER), JSON.stringify({ host: hostname(), pid: process.pid, chrome })); } catch {}
|
||||
};
|
||||
claim();
|
||||
// On exit Chrome still holds the profile's files: stop it first, then remove the folder.
|
||||
let pid = 0;
|
||||
const drop = () => {
|
||||
try { if (pid) { if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }); else process.kill(pid, 'SIGKILL'); } } catch {}
|
||||
try { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch {}
|
||||
};
|
||||
process.once('exit', drop);
|
||||
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(sig, () => { drop(); process.exit(130); });
|
||||
const args = [
|
||||
'--headless=new', '--remote-debugging-port=0', `--user-data-dir=${dir}`,
|
||||
'--disk-cache-size=16777216', '--media-cache-size=16777216',
|
||||
'--no-first-run', '--no-default-browser-check', '--mute-audio', '--hide-scrollbars',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--disable-backgrounding-occluded-windows', '--disable-features=Translate,MediaRouter',
|
||||
'--force-color-profile=srgb', '--autoplay-policy=no-user-gesture-required',
|
||||
'--enable-unsafe-swiftshader', `--window-size=${width},${height}`, ...extraArgs, 'about:blank',
|
||||
];
|
||||
const proc = spawn(exe, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
pid = proc.pid;
|
||||
claim(pid);
|
||||
const wsUrl = await new Promise((resolve, reject) => {
|
||||
let buf = '';
|
||||
const t = setTimeout(() => reject(new Error('Chrome did not start within 20s')), 20000);
|
||||
proc.stderr.on('data', (d) => {
|
||||
buf += d.toString();
|
||||
const m = buf.match(/DevTools listening on (ws:\/\/[^\s]+)/);
|
||||
if (m) { clearTimeout(t); resolve(m[1]); }
|
||||
});
|
||||
proc.on('exit', (code) => { clearTimeout(t); reject(new Error('Chrome exited early, code ' + code + '\n' + buf.slice(-600))); });
|
||||
});
|
||||
const b = new Browser();
|
||||
b.proc = proc; b.dir = dir; b.nextId = 1; b.pending = new Map(); b.listeners = new Map();
|
||||
b.ws = new WebSocket(wsUrl);
|
||||
await new Promise((res, rej) => { b.ws.onopen = res; b.ws.onerror = () => rej(new Error('CDP socket failed')); });
|
||||
b.ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : Buffer.from(ev.data).toString());
|
||||
if (msg.id && b.pending.has(msg.id)) {
|
||||
const { resolve, reject, timer } = b.pending.get(msg.id);
|
||||
clearTimeout(timer); b.pending.delete(msg.id);
|
||||
if (msg.error) reject(new Error(msg.error.message)); else resolve(msg.result);
|
||||
} else if (msg.method) {
|
||||
const fns = b.listeners.get((msg.sessionId || '') + '|' + msg.method);
|
||||
if (fns) for (const fn of [...fns]) fn(msg.params);
|
||||
}
|
||||
};
|
||||
return b;
|
||||
}
|
||||
|
||||
send(method, params = {}, sessionId, timeoutMs = 30000) {
|
||||
const id = this.nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('CDP timeout: ' + method)); }, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.ws.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
on(sessionId, method, fn) {
|
||||
const k = (sessionId || '') + '|' + method;
|
||||
if (!this.listeners.has(k)) this.listeners.set(k, new Set());
|
||||
this.listeners.get(k).add(fn);
|
||||
return () => this.listeners.get(k)?.delete(fn);
|
||||
}
|
||||
|
||||
async newPage({ width = 1440, height = 900, dsf = 1, mobile = false, reducedMotion = false } = {}) {
|
||||
const { targetId } = await this.send('Target.createTarget', { url: 'about:blank', newWindow: true });
|
||||
const { sessionId } = await this.send('Target.attachToTarget', { targetId, flatten: true });
|
||||
const p = new Page(this, targetId, sessionId);
|
||||
await p.send('Page.enable');
|
||||
await p.send('Runtime.enable');
|
||||
await p.setViewport({ width, height, dsf, mobile });
|
||||
if (reducedMotion) {
|
||||
await p.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
async close() {
|
||||
try { await this.send('Browser.close', {}, undefined, 3000); } catch {}
|
||||
try { this.ws.close(); } catch {}
|
||||
try { this.proc.kill(); } catch {}
|
||||
await sleep(300);
|
||||
try { rmSync(this.dir, { recursive: true, force: true, maxRetries: 3 }); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export class Page {
|
||||
constructor(browser, targetId, sessionId) {
|
||||
this.browser = browser; this.targetId = targetId; this.sessionId = sessionId;
|
||||
this.consoleErrors = [];
|
||||
browser.on(sessionId, 'Runtime.exceptionThrown', (p) => {
|
||||
this.consoleErrors.push((p.exceptionDetails?.exception?.description || p.exceptionDetails?.text || '').slice(0, 300));
|
||||
});
|
||||
// Never let a JS dialog block the page.
|
||||
browser.on(sessionId, 'Page.javascriptDialogOpening', () => {
|
||||
this.send('Page.handleJavaScriptDialog', { accept: false }).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
send(method, params = {}, timeoutMs = 30000) {
|
||||
return this.browser.send(method, params, this.sessionId, timeoutMs);
|
||||
}
|
||||
|
||||
async setViewport({ width, height, dsf = 1, mobile = false }) {
|
||||
this.viewport = { width, height, dsf, mobile };
|
||||
await this.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: dsf, mobile });
|
||||
await this.send('Emulation.setTouchEmulationEnabled', { enabled: !!mobile });
|
||||
}
|
||||
|
||||
// Navigates and resolves after the load event (or timeout), then waits `settleMs` for intros to finish.
|
||||
async goto(url, { timeoutMs = 25000, settleMs = 1500 } = {}) {
|
||||
let off;
|
||||
const loaded = new Promise((resolve) => { off = this.browser.on(this.sessionId, 'Page.loadEventFired', () => resolve('load')); });
|
||||
const nav = await this.send('Page.navigate', { url }, timeoutMs).catch((e) => ({ errorText: e.message }));
|
||||
if (nav.errorText && !/ERR_ABORTED/.test(nav.errorText)) { off(); throw new Error('navigate: ' + nav.errorText); }
|
||||
const how = await Promise.race([loaded, sleep(timeoutMs).then(() => 'timeout')]);
|
||||
off();
|
||||
await sleep(settleMs);
|
||||
return how;
|
||||
}
|
||||
|
||||
// Evaluates a function (serialised) or an expression string in the page and returns its JSON value.
|
||||
async eval(fnOrExpr, arg) {
|
||||
const expression = typeof fnOrExpr === 'function'
|
||||
? `(${fnOrExpr.toString()})(${arg === undefined ? '' : JSON.stringify(arg)})`
|
||||
: fnOrExpr;
|
||||
const r = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, 30000);
|
||||
if (r.exceptionDetails) {
|
||||
throw new Error('page eval: ' + (r.exceptionDetails.exception?.description || r.exceptionDetails.text));
|
||||
}
|
||||
return r.result?.value;
|
||||
}
|
||||
|
||||
async screenshot({ path, format = 'jpeg', quality = 72, fullPage = false, clip } = {}) {
|
||||
const params = { format, captureBeyondViewport: !!fullPage };
|
||||
if (format !== 'png') params.quality = quality;
|
||||
if (fullPage) {
|
||||
const m = await this.send('Page.getLayoutMetrics');
|
||||
const s = m.cssContentSize || m.contentSize;
|
||||
params.clip = { x: 0, y: 0, width: Math.ceil(s.width), height: Math.min(Math.ceil(s.height), 16000), scale: 1 };
|
||||
} else if (clip) {
|
||||
params.clip = { scale: 1, ...clip };
|
||||
}
|
||||
const { data } = await this.send('Page.captureScreenshot', params, 45000);
|
||||
if (path) writeFileSync(path, Buffer.from(data, 'base64'));
|
||||
return data;
|
||||
}
|
||||
|
||||
// Real wheel input, so smooth-scroll libraries and scroll-driven reveals behave as they do for a visitor.
|
||||
async wheel(totalDeltaY, { step = 240, pauseMs = 45 } = {}) {
|
||||
const { width, height } = this.viewport;
|
||||
const dir = Math.sign(totalDeltaY) || 1;
|
||||
let left = Math.abs(totalDeltaY);
|
||||
while (left > 0) {
|
||||
const d = Math.min(step, left);
|
||||
await this.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: Math.round(width / 2), y: Math.round(height / 2), deltaX: 0, deltaY: d * dir }).catch(() => {});
|
||||
left -= d;
|
||||
await sleep(pauseMs);
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
try { await this.browser.send('Target.closeTarget', { targetId: this.targetId }, undefined, 5000); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export { sleep };
|
||||
@@ -0,0 +1,909 @@
|
||||
// probe() runs INSIDE the page (it is serialised and evaluated through cdp.mjs), so it must stay
|
||||
// self-contained: no imports, no closures over module scope.
|
||||
// It measures what a design reviewer looks at — headline line counts, the type system, colour,
|
||||
// surfaces, decoration — and the specific tells this skill bans.
|
||||
export function probe() {
|
||||
const W = innerWidth, H = innerHeight, SY = scrollY;
|
||||
const round = (n, d = 2) => Math.round(n * 10 ** d) / 10 ** d;
|
||||
|
||||
// --- colour normalisation (handles oklch(), color(), named colours, etc.)
|
||||
const cvs = document.createElement('canvas'); cvs.width = cvs.height = 1;
|
||||
const ctx = cvs.getContext('2d', { willReadFrequently: true });
|
||||
const colorCache = new Map();
|
||||
const rgba = (c) => {
|
||||
if (colorCache.has(c)) return colorCache.get(c);
|
||||
ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.fillStyle = c; ctx.fillRect(0, 0, 1, 1);
|
||||
const d = ctx.getImageData(0, 0, 1, 1).data;
|
||||
const v = { r: d[0], g: d[1], b: d[2], a: round(d[3] / 255) };
|
||||
colorCache.set(c, v); return v;
|
||||
};
|
||||
const hex = (v) => '#' + [v.r, v.g, v.b].map((n) => n.toString(16).padStart(2, '0')).join('');
|
||||
const lum = (v) => {
|
||||
const f = (x) => { x /= 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
|
||||
return 0.2126 * f(v.r) + 0.7152 * f(v.g) + 0.0722 * f(v.b);
|
||||
};
|
||||
const contrast = (a, b) => { const l1 = lum(a), l2 = lum(b); return round((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05), 1); };
|
||||
const hsl = (v) => {
|
||||
const r = v.r / 255, g = v.g / 255, b = v.b / 255, mx = Math.max(r, g, b), mn = Math.min(r, g, b);
|
||||
const l = (mx + mn) / 2; let h = 0, s = 0;
|
||||
if (mx !== mn) {
|
||||
const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
|
||||
h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60;
|
||||
}
|
||||
return { h: Math.round(h), s: round(s), l: round(l) };
|
||||
};
|
||||
|
||||
// --- monospace detection by measurement, not by font name
|
||||
const mctx = document.createElement('canvas').getContext('2d');
|
||||
const monoCache = new Map();
|
||||
const isMono = (family) => {
|
||||
if (monoCache.has(family)) return monoCache.get(family);
|
||||
mctx.font = `40px ${family}`;
|
||||
const a = mctx.measureText('iiiiiiii').width, b = mctx.measureText('WWWWWWWW').width;
|
||||
const v = Math.abs(a - b) < 1;
|
||||
monoCache.set(family, v); return v;
|
||||
};
|
||||
const firstFam = (ff) => ff.split(',')[0].replace(/["']/g, '').trim();
|
||||
// Text kept for screen readers only (the usual .sr-only / .visually-hidden patterns) is not on the page.
|
||||
const srHidden = (el) => {
|
||||
for (let p = el, i = 0; p && p !== document.body && i < 4; p = p.parentElement, i++) {
|
||||
const cs = getComputedStyle(p); const r = p.getBoundingClientRect();
|
||||
if (cs.clip === 'rect(0px, 0px, 0px, 0px)' || /inset\(50%\)/.test(cs.clipPath) || (r.width <= 2 && r.height <= 2 && cs.overflow !== 'visible')) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const GENERIC = /^(serif|sans-serif|monospace|system-ui|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|cursive|fantasy|inherit|-apple-system|BlinkMacSystemFont|Segoe UI|Arial|Helvetica|Helvetica Neue|Times New Roman|Times|Georgia|Verdana|Tahoma|Courier New|Courier|Roboto|Apple Color Emoji)$/i;
|
||||
|
||||
// --- collect elements
|
||||
const all = [...document.body.querySelectorAll('*')].slice(0, 9000);
|
||||
const texts = [];
|
||||
const big = [];
|
||||
for (const el of all) {
|
||||
const tag = el.tagName;
|
||||
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE') continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 1 || r.height < 1) continue;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === 'none' || cs.visibility === 'hidden') continue;
|
||||
const area = r.width * r.height;
|
||||
if (area >= 60 * 30) big.push({ el, r, cs, area, top: r.top + SY });
|
||||
let direct = '';
|
||||
for (const n of el.childNodes) if (n.nodeType === 3) direct += n.nodeValue;
|
||||
direct = direct.replace(/\s+/g, ' ').trim();
|
||||
if (!direct) continue;
|
||||
if (tag === 'OPTION' || el.closest('svg') || srHidden(el)) continue;
|
||||
const size = parseFloat(cs.fontSize);
|
||||
if (!size || size < 6) continue;
|
||||
const ls = cs.letterSpacing === 'normal' ? 0 : parseFloat(cs.letterSpacing) / size;
|
||||
const lh = cs.lineHeight === 'normal' ? 1.2 : parseFloat(cs.lineHeight) / size;
|
||||
texts.push({
|
||||
el, tag, text: direct, chars: direct.length, fam: firstFam(cs.fontFamily), ff: cs.fontFamily,
|
||||
size: round(size, 1), weight: +cs.fontWeight || 400, ls: round(ls, 3), lh: round(lh, 2),
|
||||
upper: cs.textTransform === 'uppercase' || (direct.length > 2 && direct === direct.toUpperCase() && /[A-Z]{2}/.test(direct)),
|
||||
italic: cs.fontStyle === 'italic', align: cs.textAlign, color: cs.color,
|
||||
top: r.top + SY, bottom: r.bottom + SY, left: r.left, right: r.right, w: r.width, h: r.height,
|
||||
clipText: (cs.webkitBackgroundClip || cs.backgroundClip || '').includes('text'),
|
||||
inCode: !!el.closest('pre, code, kbd, samp, textarea'), inUI: !!el.closest('[data-ui]'), textShadow: cs.textShadow !== 'none',
|
||||
});
|
||||
}
|
||||
|
||||
// --- heading blocks: climb from a large text node to the block that holds the whole headline
|
||||
// An element that is a complete text unit by itself: separate links, list items or paragraphs set large
|
||||
// are separate lines of content, never the wrapped lines of one headline.
|
||||
const UNIT = 'h1, h2, h3, h4, h5, h6, [role="heading"], p, li, a, button, blockquote, dt, dd, figcaption, td, th, label, summary';
|
||||
const blockOf = (t) => {
|
||||
let cur = t.el;
|
||||
// usable as the block when it holds only this size of type (a title with its own small description is not one block)
|
||||
const clean = (el) => (el.innerText || '').trim().length <= 320 && !texts.some((o) => o.el !== t.el && el.contains(o.el) && o.size < t.size * 0.72);
|
||||
let unit = t.el.closest(UNIT);
|
||||
if (unit && unit.matches('a, button')) { const outer = unit.parentElement?.closest('h1, h2, h3, h4, h5, h6, p, blockquote, li'); if (outer && clean(outer)) unit = outer; }
|
||||
if (unit && clean(unit)) return unit;
|
||||
while (cur.parentElement && cur.parentElement !== document.body) {
|
||||
if (cur.matches(UNIT)) break;
|
||||
const p = cur.parentElement;
|
||||
if (p.matches('ul, ol, dl, nav, menu, table, tbody')) break;
|
||||
const inner = (p.innerText || '').trim();
|
||||
if (inner.length > 220) break;
|
||||
let ok = true;
|
||||
for (const o of texts) if (o.el !== t.el && p.contains(o.el) && o.size < t.size * 0.72) { ok = false; break; }
|
||||
if (!ok) break;
|
||||
cur = p;
|
||||
}
|
||||
return cur;
|
||||
};
|
||||
// Count lines from the text itself: inline images or media set inside a sentence are not lines.
|
||||
const linesOf = (el, size) => {
|
||||
const tops = [];
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
const range = document.createRange();
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (!node.nodeValue.trim()) continue;
|
||||
range.selectNodeContents(node);
|
||||
for (const rc of range.getClientRects()) {
|
||||
if (rc.width < 1 || rc.height < size * 0.35) continue;
|
||||
const mid = rc.top + rc.height / 2;
|
||||
if (!tops.some((t) => Math.abs(t - mid) < size * 0.55)) tops.push(mid);
|
||||
}
|
||||
}
|
||||
return Math.max(1, tops.length);
|
||||
};
|
||||
const describeBlock = (el, t) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const text = (el.innerText || t.text).replace(/\s+/g, ' ').trim();
|
||||
const cx = (r.left + r.right) / 2 / W;
|
||||
const fams = new Set(texts.filter((o) => el.contains(o.el)).map((o) => o.fam + (o.italic ? ' italic' : '')));
|
||||
return {
|
||||
text: text.slice(0, 200), words: text.split(' ').filter(Boolean).length, chars: text.length,
|
||||
lines: linesOf(el, t.size), size: t.size, vw: round((t.size / W) * 100, 1), font: t.fam, weight: t.weight,
|
||||
tracking: t.ls, leading: t.lh, upper: t.upper, italic: t.italic, mono: isMono(t.ff),
|
||||
align: /center/.test(t.align) ? 'center' : /right|end/.test(t.align) ? 'right' : 'left',
|
||||
position: cx < 0.42 ? 'left' : cx > 0.58 ? 'right' : 'center',
|
||||
widthRatio: round(r.width / W), top: Math.round(r.top + SY), mixedFonts: fams.size > 1 ? [...fams] : undefined,
|
||||
gradientText: texts.some((o) => el.contains(o.el) && o.clipText) || undefined, tag: el.tagName.toLowerCase(),
|
||||
// a title (h1–h6, or unmarked large text) or a statement (a paragraph, quotation or list item set large)
|
||||
kind: el.closest('h1,h2,h3,h4,h5,h6,[role="heading"]') || el.querySelector('h1,h2,h3,h4,h5,h6,[role="heading"]') ? 'heading'
|
||||
: t.el.closest('p, blockquote, li, dd, q, figcaption') || text.length > 150 ? 'statement' : 'heading',
|
||||
};
|
||||
};
|
||||
const headingMin = W < 600 ? 22 : Math.max(26, W * 0.019);
|
||||
const seenBlocks = new Set();
|
||||
const headings = [];
|
||||
for (const t of texts.filter((x) => x.size >= headingMin && !x.inCode).sort((a, b) => b.size - a.size)) {
|
||||
const blk = blockOf(t);
|
||||
if (seenBlocks.has(blk) || [...seenBlocks].some((b) => b.contains(blk) || blk.contains(b))) continue;
|
||||
seenBlocks.add(blk);
|
||||
const d = describeBlock(blk, t);
|
||||
if (d.kind === 'statement' && d.chars > 150 && d.size <= 30) continue; // a lead paragraph, not display type
|
||||
headings.push({ ...d, _el: blk });
|
||||
}
|
||||
headings.sort((a, b) => a.top - b.top);
|
||||
|
||||
// hero headline = largest heading block that starts inside the first viewport
|
||||
const inHero = headings.filter((h) => h.top < H * 0.95);
|
||||
let hero = inHero.slice().sort((a, b) => b.size - a.size || b.chars - a.chars)[0] || null;
|
||||
if (hero) hero.kind = 'heading'; // the main line of the first screen is a headline whatever its tag
|
||||
const h1el = document.querySelector('h1');
|
||||
if (!hero) {
|
||||
// quiet pages: fall back to the h1, then to the largest text in the first viewport
|
||||
const h1r = h1el && h1el.getBoundingClientRect();
|
||||
const pick = (h1r && h1r.top < H * 0.95 && h1r.width > 0 && texts.find((t) => h1el.contains(t.el))) ||
|
||||
texts.filter((t) => t.top - SY < H * 0.95 && t.size >= 18 && t.chars >= 8).sort((a, b) => b.size - a.size)[0];
|
||||
if (pick) { const blk = h1el && h1el.contains(pick.el) ? h1el : blockOf(pick); hero = { ...describeBlock(blk, pick), _el: blk }; }
|
||||
}
|
||||
let h1 = null;
|
||||
if (h1el && h1el.getBoundingClientRect().width > 0) {
|
||||
const cs = getComputedStyle(h1el); const size = parseFloat(cs.fontSize);
|
||||
const text = (h1el.innerText || '').replace(/\s+/g, ' ').trim();
|
||||
h1 = { text: text.slice(0, 200), words: text.split(' ').filter(Boolean).length, lines: linesOf(h1el, size), size: round(size, 1), font: firstFam(cs.fontFamily) };
|
||||
}
|
||||
|
||||
// --- eyebrows: a small short label sitting directly above a heading block
|
||||
// Sticky and fixed layers overlap other content by design; their geometry says nothing about what labels what.
|
||||
const pinned = (el) => { for (let p = el; p && p !== document.body; p = p.parentElement) { const pos = getComputedStyle(p).position; if (pos === 'fixed' || pos === 'sticky') return true; } return false; };
|
||||
const eyebrows = [];
|
||||
for (const h of headings) {
|
||||
const hr = h._el.getBoundingClientRect(); const htop = hr.top + SY;
|
||||
if (h._el.closest('[data-ui]')) continue;
|
||||
const cand = texts.filter((t) => !t.inUI && !h._el.contains(t.el) && t.size <= Math.min(18, h.size * 0.5) && t.chars <= 44 && t.chars >= 2 &&
|
||||
t.bottom <= htop + 4 && htop - t.bottom <= Math.min(140, Math.max(72, h.size * 1.1)) && t.right > hr.left - 8 && t.left < hr.right + 8 &&
|
||||
!t.el.closest('button') &&
|
||||
!t.el.closest('nav, [role="navigation"], footer') && !(t.top < 120 && t.el.closest('header')) && !pinned(t.el) && pinned(h._el) === false)
|
||||
.sort((a, b) => b.bottom - a.bottom)[0];
|
||||
if (!cand) continue;
|
||||
// must be alone on its row (a real paragraph above a heading is not an eyebrow)
|
||||
if (texts.some((t) => t !== cand && Math.abs(t.top - cand.top) < 6 && t.chars > 44)) continue;
|
||||
// a note that sits closer to the heading above it than to this one belongs to that heading
|
||||
const gapDown = htop - cand.bottom;
|
||||
if (headings.some((o) => o !== h && !o._el.contains(cand.el) && (() => { const or = o._el.getBoundingClientRect(); const gapUp = cand.top - (or.bottom + SY); return gapUp >= -2 && gapUp < gapDown && or.left < cand.right && or.right > cand.left; })())) continue;
|
||||
let pill = false; let p = cand.el;
|
||||
for (let i = 0; i < 3 && p; i++, p = p.parentElement) {
|
||||
const cs = getComputedStyle(p); const pr = p.getBoundingClientRect();
|
||||
if (pr.width < 420 && pr.height < 56 && parseFloat(cs.borderTopLeftRadius) >= 10 && (rgba(cs.backgroundColor).a > 0.04 || parseFloat(cs.borderTopWidth) > 0)) pill = true;
|
||||
}
|
||||
eyebrows.push({ text: cand.text.slice(0, 50), above: h.text.slice(0, 40), upper: cand.upper, tracked: cand.ls >= 0.04, mono: isMono(cand.ff), pill, size: cand.size, top: Math.round(cand.top) });
|
||||
}
|
||||
|
||||
// --- decorative numbering: 01 / 02 / (03) / 04. / 001 / "Step 02"
|
||||
const numbered = texts.filter((t) => !t.inCode && !t.inUI && (/^[(\[\/#]?\s?0\d{1,2}\s?[)\]\/.:—–-]?$/.test(t.text) || /^0\d\s?[\/.—–-]\s/.test(t.text) || /^(step|phase|no\.?|n°|nº|chapter|part|fig\.?)\s?0\d{1,2}\b/i.test(t.text)));
|
||||
|
||||
// --- type system
|
||||
const famChars = new Map(); const sizeChars = new Map(); let totalChars = 0; let monoChars = 0; let monoProse = 0; const monoFams = new Set(); const monoSamples = [];
|
||||
let microLabels = 0; let microLabelChars = 0; let upperChars = 0; let centerChars = 0; const microSamples = [];
|
||||
for (const t of texts) {
|
||||
totalChars += t.chars;
|
||||
famChars.set(t.fam, (famChars.get(t.fam) || 0) + t.chars);
|
||||
if (!t.inUI) { const s = Math.round(t.size); sizeChars.set(s, (sizeChars.get(s) || 0) + t.chars); } // interface fragments keep their own sizes
|
||||
if (isMono(t.ff)) {
|
||||
monoChars += t.chars; monoFams.add(t.fam);
|
||||
if (!t.inCode) { monoProse += t.chars; if (monoSamples.length < 6) monoSamples.push(t.text.slice(0, 40)); }
|
||||
}
|
||||
if (t.size <= 14 && t.upper && t.ls >= 0.05 && !t.inUI && !t.el.closest('nav, button, a[href]')) { microLabels++; microLabelChars += t.chars; if (microSamples.length < 6) microSamples.push(t.text.slice(0, 40)); }
|
||||
if (t.upper) upperChars += t.chars;
|
||||
if (/center/.test(t.align)) centerChars += t.chars;
|
||||
}
|
||||
const families = [...famChars.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([family, chars]) => ({ family, share: round(chars / totalChars), mono: isMono(`"${family}", serif`) && isMono(`"${family}", sans-serif`) }));
|
||||
const bodyTexts = texts.filter((t) => t.size >= 10 && t.size <= 24 && t.chars >= 60 && !t.inCode && !t.inUI);
|
||||
const bodyBy = new Map();
|
||||
for (const t of bodyTexts) { const k = t.fam + '|' + Math.round(t.size); bodyBy.set(k, (bodyBy.get(k) || 0) + t.chars); }
|
||||
const bodyTop = [...bodyBy.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||
const bodySample = bodyTop ? bodyTexts.find((t) => t.fam + '|' + Math.round(t.size) === bodyTop[0]) : null;
|
||||
const longest = bodyTexts.slice().sort((a, b) => b.chars - a.chars)[0];
|
||||
const sizes = [...sizeChars.entries()].sort((a, b) => a[0] - b[0]).map(([s]) => s);
|
||||
const maxText = texts.slice().sort((a, b) => b.size - a.size)[0];
|
||||
const smallText = texts.filter((t) => t.size < 13 && t.chars >= 12 && !t.inUI && !t.inCode).length; // interface fragments keep their true sizes
|
||||
const loaded = new Set([...document.fonts].filter((f) => f.status === 'loaded').map((f) => f.family.replace(/["']/g, '').toLowerCase()));
|
||||
const declared = new Set([...document.fonts].map((f) => f.family.replace(/["']/g, '').toLowerCase()));
|
||||
const notLoaded = families.map((f) => f.family).filter((f) => !GENERIC.test(f) && !loaded.has(f.toLowerCase()) && (declared.has(f.toLowerCase()) || declared.size > 0));
|
||||
|
||||
// --- colours
|
||||
const bodyBg = (() => {
|
||||
for (const el of [document.body, document.documentElement]) { const v = rgba(getComputedStyle(el).backgroundColor); if (v.a > 0.5) return v; }
|
||||
return { r: 255, g: 255, b: 255, a: 1 };
|
||||
})();
|
||||
const sectionBgs = [];
|
||||
for (const b of big) {
|
||||
if (b.area < W * H * 0.3) continue;
|
||||
const v = rgba(b.cs.backgroundColor); if (v.a < 0.9) continue;
|
||||
const hx = hex(v); if (!sectionBgs.some((s) => s.color === hx)) sectionBgs.push({ color: hx, top: Math.round(b.top), hsl: hsl(v) });
|
||||
}
|
||||
const textColorChars = new Map();
|
||||
for (const t of texts) { const v = rgba(t.color); if (v.a < 0.3) continue; const hx = hex(v); textColorChars.set(hx, (textColorChars.get(hx) || 0) + t.chars); }
|
||||
const textColors = [...textColorChars.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([color, c]) => ({ color, share: round(c / totalChars) }));
|
||||
|
||||
// effective background behind an element (null when an image or gradient sits behind it)
|
||||
const bgBehind = (el) => {
|
||||
for (let p = el; p && p !== document.documentElement; p = p.parentElement) {
|
||||
const cs = getComputedStyle(p);
|
||||
if (cs.backgroundImage !== 'none' || p.tagName === 'VIDEO' || p.tagName === 'CANVAS') return null;
|
||||
const v = rgba(cs.backgroundColor); if (v.a >= 0.9) return v;
|
||||
if (cs.position === 'absolute' || cs.position === 'fixed') return null; // may overlap media
|
||||
}
|
||||
return bodyBg;
|
||||
};
|
||||
// media that may sit behind text without being its ancestor (an absolutely placed hero image, a video layer)
|
||||
const mediaRects = big.filter((b) => b.area >= 120 * 80 && (b.el.tagName === 'IMG' || b.el.tagName === 'VIDEO' || b.el.tagName === 'CANVAS' || b.el.tagName === 'PICTURE' || b.el.tagName === 'IFRAME' || b.el.tagName === 'svg' || b.cs.backgroundImage !== 'none')).map((b) => ({ el: b.el, l: b.r.left, r: b.r.right, t: b.r.top + SY, b: b.r.bottom + SY }));
|
||||
const overMedia = (t) => {
|
||||
const area = Math.max(1, t.w * t.h);
|
||||
for (const m of mediaRects) {
|
||||
const ox = Math.min(t.right, m.r) - Math.max(t.left, m.l), oy = Math.min(t.bottom, m.b) - Math.max(t.top, m.t);
|
||||
if (ox > 0 && oy > 0 && (ox * oy) / area >= 0.25) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const lowContrast = [];
|
||||
for (const t of texts) {
|
||||
if (t.chars < 4 || t.top - SY > H * 6) continue;
|
||||
const fg = rgba(t.color); if (fg.a < 0.99) continue;
|
||||
if (overMedia(t)) continue; // cannot know the pixels behind it; judge that from the screenshot
|
||||
// text blended with difference or exclusion inverts whatever lies under it; its colour alone says nothing
|
||||
let blended = false;
|
||||
for (let p = t.el, i = 0; p && p !== document.body && i < 6; p = p.parentElement, i++) if (/difference|exclusion/.test(getComputedStyle(p).mixBlendMode)) { blended = true; break; }
|
||||
if (blended) continue;
|
||||
const bg = bgBehind(t.el); if (!bg) continue;
|
||||
const c = contrast(fg, bg); const need = t.size >= 24 || (t.size >= 18.5 && t.weight >= 700) ? 3 : 4.5;
|
||||
if (c < need) lowContrast.push({ text: t.text.slice(0, 40), ratio: c, need, size: t.size, fg: hex(fg), bg: hex(bg) });
|
||||
}
|
||||
lowContrast.sort((a, b) => a.ratio - b.ratio);
|
||||
|
||||
// --- buttons
|
||||
const buttons = [];
|
||||
for (const el of document.querySelectorAll('a, button, [role="button"], input[type="submit"]')) {
|
||||
const r = el.getBoundingClientRect(); if (r.height < 28 || r.height > 84 || r.width < 64 || r.width > Math.min(W * 0.96, 900)) continue;
|
||||
const cs = getComputedStyle(el); let bg = rgba(cs.backgroundColor); const bw = parseFloat(cs.borderTopWidth) || 0;
|
||||
const grad = cs.backgroundImage.includes('gradient(');
|
||||
if (grad && bg.a < 0.5) { const first = cs.backgroundImage.match(/(rgba?\([^)]+\)|oklch\([^)]+\)|color\([^)]+\)|#[0-9a-f]{3,8})/i); if (first) bg = rgba(first[1]); }
|
||||
if (bg.a < 0.5 && bw < 1) continue;
|
||||
const rad = parseFloat(cs.borderTopLeftRadius) || 0;
|
||||
buttons.push({
|
||||
text: (el.innerText || el.value || '').replace(/\s+/g, ' ').trim().slice(0, 40), filled: bg.a >= 0.5, bg: bg.a >= 0.5 ? hex(bg) : null, bgHsl: bg.a >= 0.5 ? hsl(bg) : null, fg: hex(rgba(cs.color)),
|
||||
radius: rad >= r.height / 2 - 1 ? 'pill' : Math.round(rad), h: Math.round(r.height), top: Math.round(r.top + SY),
|
||||
shadow: cs.boxShadow !== 'none' && !/^rgba?\(0, 0, 0, 0\)/.test(cs.boxShadow), gradient: grad, upper: cs.textTransform === 'uppercase', mono: isMono(cs.fontFamily),
|
||||
});
|
||||
}
|
||||
const mode = (arr) => { const m = new Map(); for (const a of arr) m.set(JSON.stringify(a), (m.get(JSON.stringify(a)) || 0) + 1); const top = [...m.entries()].sort((a, b) => b[1] - a[1])[0]; return top ? JSON.parse(top[0]) : undefined; };
|
||||
const filledBtns = buttons.filter((b) => b.filled);
|
||||
|
||||
// --- surfaces and decoration
|
||||
const isOverlay = (el) => { for (let p = el, i = 0; p && i < 4; p = p.parentElement, i++) { const pos = getComputedStyle(p).position; if (pos === 'fixed' || pos === 'sticky') return true; if (p.matches('dialog, [role="dialog"], [role="menu"], [role="tooltip"], [popover]')) return true; } return false; };
|
||||
// real tables, form fields and interface fragments (marked data-ui) keep their own lines
|
||||
const formish = (el) => el.matches('input, textarea, select, table, thead, tbody, tr, td, th, hr') || !!el.closest('table, [data-ui]');
|
||||
let shadowStatic = 0, shadowOverlay = 0; const shadowSamples = []; let gradients = 0; let scrims = 0; const gradientSamples = []; let gradientText = 0;
|
||||
let hairlines = 0; const hairlineSamples = []; let outlinedBoxes = 0; let blurred = 0; let roundedBoxes = 0; let squareMedia = 0; let roundedMedia = 0; const mediaRadii = []; const boxRadii = [];
|
||||
let dashed = 0;
|
||||
const label = (el) => (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '')).slice(0, 60);
|
||||
for (const b of big) {
|
||||
const { cs, r, el } = b;
|
||||
const sh = cs.boxShadow;
|
||||
if (sh !== 'none' && !/^rgba?\(0, 0, 0, 0\) 0px 0px 0px 0px$/.test(sh)) {
|
||||
const outer = sh.split(/,(?![^(]*\))/).some((part) => !/inset/.test(part) && !/ 0px 0px 0px 0px/.test(part) && !/rgba\(\d+, \d+, \d+, 0\)/.test(part));
|
||||
if (outer) { if (isOverlay(el) || el.closest('[data-ui]')) shadowOverlay++; else { shadowStatic++; if (shadowSamples.length < 4) shadowSamples.push(label(el) + ' → ' + sh.slice(0, 80)); } }
|
||||
}
|
||||
if (cs.backgroundImage.includes('gradient(')) {
|
||||
if ((cs.webkitBackgroundClip || cs.backgroundClip || '').includes('text')) gradientText++;
|
||||
else if (b.area >= W * H * 0.02) {
|
||||
// neutral stops with changing alpha = a legibility scrim over media, not decoration
|
||||
const stops = [...cs.backgroundImage.matchAll(/rgba?\(([^)]+)\)/g)].map((mm) => mm[1].split(/[,\s/]+/).filter(Boolean).map(Number));
|
||||
const exotic = /oklch|oklab|color\(|lab\(|lch\(|hsl/.test(cs.backgroundImage);
|
||||
const scrim = !exotic && stops.length > 0 && stops.every((c) => Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2]) <= 14) && stops.some((c) => c.length > 3 && c[3] < 1);
|
||||
if (scrim) scrims++; else { gradients++; if (gradientSamples.length < 4) gradientSamples.push(label(el) + ' → ' + cs.backgroundImage.slice(0, 110)); }
|
||||
}
|
||||
}
|
||||
if ((cs.backdropFilter && cs.backdropFilter !== 'none') || (cs.webkitBackdropFilter && cs.webkitBackdropFilter !== 'none')) blurred++;
|
||||
const bt = parseFloat(cs.borderTopWidth) || 0, bb = parseFloat(cs.borderBottomWidth) || 0, bl = parseFloat(cs.borderLeftWidth) || 0, br = parseFloat(cs.borderRightWidth) || 0;
|
||||
const thin = (w, c, style) => w > 0 && w <= 1.5 && style !== 'none' && rgba(c).a > 0.02;
|
||||
const topLine = thin(bt, cs.borderTopColor, cs.borderTopStyle), botLine = thin(bb, cs.borderBottomColor, cs.borderBottomStyle);
|
||||
const leftLine = thin(bl, cs.borderLeftColor, cs.borderLeftStyle), rightLine = thin(br, cs.borderRightColor, cs.borderRightStyle);
|
||||
if (!formish(el)) {
|
||||
if (topLine && botLine && leftLine && rightLine) { if (r.width >= 150 && r.height >= 80) outlinedBoxes++; }
|
||||
else if ((r.width >= 240 && (topLine || botLine)) || (r.height >= 240 && (leftLine || rightLine))) { hairlines++; if (hairlineSamples.length < 5) hairlineSamples.push(label(el) + ' ' + Math.round(r.width) + 'px'); }
|
||||
// thin dashed or dotted lines; a heavy dotted edge (3px or more) is a drawn perforation, not a hairline
|
||||
if ([[cs.borderTopStyle, bt], [cs.borderBottomStyle, bb], [cs.borderLeftStyle, bl], [cs.borderRightStyle, br]].some(([st, w]) => /dashed|dotted/.test(st) && w > 0 && w < 3)) dashed++;
|
||||
}
|
||||
const rad = parseFloat(cs.borderTopLeftRadius) || 0;
|
||||
const isMedia = el.tagName === 'IMG' || el.tagName === 'VIDEO' || el.tagName === 'CANVAS' || /url\(/.test(cs.backgroundImage);
|
||||
if (isMedia && r.width >= 200 && r.height >= 140) {
|
||||
let effective = rad; let p = el.parentElement;
|
||||
for (let i = 0; i < 3 && p && effective === 0; i++, p = p.parentElement) { const pcs = getComputedStyle(p); if (pcs.overflow !== 'visible') effective = parseFloat(pcs.borderTopLeftRadius) || 0; }
|
||||
if (effective > 1) { roundedMedia++; mediaRadii.push(Math.round(effective)); } else squareMedia++;
|
||||
} else if (rad >= 6 && r.width >= 150 && r.height >= 80 && (rgba(cs.backgroundColor).a > 0.04 || bt > 0)) { roundedBoxes++; boxRadii.push(Math.round(Math.min(rad, 99))); }
|
||||
}
|
||||
// elements that are themselves a thin rule (1px tall or 1px wide)
|
||||
for (const el of all) {
|
||||
const r = el.getBoundingClientRect();
|
||||
const horiz = r.height > 0 && r.height <= 1.5 && r.width >= 160, vert = r.width > 0 && r.width <= 1.5 && r.height >= 160;
|
||||
if (!horiz && !vert) continue; if (formish(el) && (el.tagName !== 'HR' || el.closest('[data-ui]'))) continue;
|
||||
const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.03) continue;
|
||||
if (rgba(cs.backgroundColor).a > 0.02 || parseFloat(cs.borderTopWidth) > 0 || parseFloat(cs.borderLeftWidth) > 0 || cs.backgroundImage !== 'none') { hairlines++; if (hairlineSamples.length < 5) hairlineSamples.push(label(el) + ' ' + Math.round(Math.max(r.width, r.height)) + 'px rule'); }
|
||||
}
|
||||
|
||||
// --- repeated card rows (three or more same-size boxed siblings)
|
||||
const cardRows = [];
|
||||
const seenParents = new Set();
|
||||
for (const b of big) {
|
||||
const p = b.el.parentElement; if (!p || seenParents.has(p)) continue; seenParents.add(p);
|
||||
// cards carry text; a row of photographs or media tiles is not a card row
|
||||
const kids = [...p.children].filter((k) => { const r = k.getBoundingClientRect(); return r.width >= 150 && r.width < W * 0.9 && r.height >= 90 && !k.matches('img, picture, video, canvas, svg, figure:not(:has(figcaption))') && (k.innerText || '').trim().length >= 12; }); // full-width bands are sections, not cards
|
||||
if (kids.length < 3) continue;
|
||||
const boxed = kids.filter((k) => { const cs = getComputedStyle(k); return (rgba(cs.backgroundColor).a > 0.04 || parseFloat(cs.borderTopWidth) > 0 || cs.boxShadow !== 'none') && true; });
|
||||
if (boxed.length < 3) continue;
|
||||
const w0 = boxed[0].getBoundingClientRect().width;
|
||||
const same = boxed.filter((k) => Math.abs(k.getBoundingClientRect().width - w0) < 3);
|
||||
if (same.length < 3) continue;
|
||||
const iconTop = same.filter((k) => { const first = k.querySelector('svg, img, i, [class*="icon" i]'); if (!first) return false; const r = first.getBoundingClientRect(); return r.width > 0 && r.width <= 64 && r.height <= 64; }).length;
|
||||
cardRows.push({ count: same.length, iconTopped: iconTop >= same.length - 1 && iconTop >= 3, rounded: (parseFloat(getComputedStyle(same[0]).borderTopLeftRadius) || 0) >= 6, top: Math.round(same[0].getBoundingClientRect().top + SY) });
|
||||
}
|
||||
|
||||
// --- hero media
|
||||
const heroMedia = [];
|
||||
for (const b of big) {
|
||||
const { el, r, cs } = b; if (r.top > H * 0.9 || r.bottom < 0) continue;
|
||||
const type = el.tagName === 'IMG' ? 'img' : el.tagName === 'VIDEO' ? 'video' : el.tagName === 'CANVAS' ? 'canvas' : el.tagName === 'svg' && r.width > 240 ? 'svg' : /url\(/.test(cs.backgroundImage) ? 'bg-image' : el.tagName === 'IFRAME' ? 'iframe' : null;
|
||||
if (!type) continue;
|
||||
const vw = Math.max(0, Math.min(r.right, W) - Math.max(r.left, 0)), vh = Math.max(0, Math.min(r.bottom, H) - Math.max(r.top, 0));
|
||||
const ratio = (vw * vh) / (W * H); if (ratio >= 0.08) heroMedia.push({ type, ratio: round(ratio) });
|
||||
}
|
||||
heroMedia.sort((a, b) => b.ratio - a.ratio);
|
||||
const brokenImages = [...document.images].filter((i) => i.complete && i.naturalWidth === 0 && i.getBoundingClientRect().width > 0 && (i.currentSrc || i.src)).map((i) => (i.currentSrc || i.src).slice(0, 120)).slice(0, 8);
|
||||
|
||||
// --- navigation
|
||||
const navEl = document.querySelector('header nav, nav, header, [role="navigation"]');
|
||||
let nav = null;
|
||||
if (navEl) {
|
||||
let host = navEl; let fixed = false; let p = navEl;
|
||||
for (let i = 0; i < 4 && p && p !== document.body; i++, p = p.parentElement) { const pos = getComputedStyle(p).position; if (pos === 'fixed' || pos === 'sticky') { fixed = true; host = p; break; } }
|
||||
const hr = host.getBoundingClientRect(); const hcs = getComputedStyle(host);
|
||||
const links = [...navEl.querySelectorAll('a')].filter((a) => { const r = a.getBoundingClientRect(); return r.width > 0 && r.top < 140; });
|
||||
nav = { links: links.length, fixed, height: Math.round(hr.height), floatingPill: hr.width < W * 0.92 && parseFloat(hcs.borderTopLeftRadius) >= 16 && hr.top > 4, blur: (hcs.backdropFilter || 'none') !== 'none' };
|
||||
}
|
||||
|
||||
// data-ui is for cropped interface fragments, not for wrapping the page
|
||||
const uiAbuse = [...document.querySelectorAll('[data-ui]')].some((el) => el.querySelector('h1') || el.getBoundingClientRect().height > H * 1.6 || el === document.body);
|
||||
const uiFragments = document.querySelectorAll('[data-ui]').length;
|
||||
// pictures that were not drawn by the page's own code: photographs, footage, raster artwork.
|
||||
// Inline SVG and .svg files do not count; cropped interface fragments (data-ui) are counted apart.
|
||||
const svgSrc = (u) => /\.svg(\?|#|$)|^data:image\/svg/i.test(u || '');
|
||||
let realPictures = 0, videoCount = 0; const pictureSamples = [];
|
||||
for (const el of all) {
|
||||
if (el.closest('[data-ui]') || el.closest('svg')) continue;
|
||||
const r = el.getBoundingClientRect(); if (r.width < 120 || r.height < 90 || r.width * r.height < 160 * 120) continue;
|
||||
if (el.tagName === 'IMG') {
|
||||
const src = el.currentSrc || el.src;
|
||||
if (el.naturalWidth > 0 && !svgSrc(src)) { realPictures++; if (pictureSamples.length < 8) pictureSamples.push(src.split('/').pop().slice(0, 48)); }
|
||||
continue;
|
||||
}
|
||||
if (el.tagName === 'VIDEO') { realPictures++; videoCount++; continue; }
|
||||
const bg = getComputedStyle(el).backgroundImage;
|
||||
if (bg && bg.includes('url(')) { const u = (bg.match(/url\(["']?([^"')]+)/) || [])[1]; if (!svgSrc(u)) realPictures++; }
|
||||
}
|
||||
const uiPictures = [...document.querySelectorAll('[data-ui]')].filter((el) => { const r = el.getBoundingClientRect(); return r.width >= 300 && r.height >= 200; }).length;
|
||||
// which elements push the page wider than the viewport (the deepest ones that are not clipped by an ancestor)
|
||||
let overflowCulprits = [];
|
||||
if (document.documentElement.scrollWidth > W + 2) {
|
||||
const clippedByAncestor = (el) => { for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { if (getComputedStyle(p).overflowX !== 'visible' && p.getBoundingClientRect().right <= W + 2) return true; } return false; };
|
||||
const wide = all.filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.right > W + 2 && !clippedByAncestor(el); });
|
||||
overflowCulprits = wide.filter((el) => !wide.some((o) => o !== el && el.contains(o))).slice(0, 3).map((el) => `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${el.classList.length ? '.' + [...el.classList].slice(0, 2).join('.') : ''} (+${Math.round(el.getBoundingClientRect().right - W)}px)`);
|
||||
}
|
||||
// rows that scroll sideways with content past their edge (read as clipped in screenshots and by most visitors)
|
||||
const sideScrollers = all.filter((el) => { if (el.closest('[data-ui], table, pre, code')) return false; const cs = getComputedStyle(el); return /auto|scroll/.test(cs.overflowX) && el.scrollWidth > el.clientWidth + 24 && el.clientHeight > 40; }).length;
|
||||
const blendText = texts.filter((t) => { for (let p = t.el, i = 0; p && p !== document.body && i < 6; p = p.parentElement, i++) if (/difference|exclusion/.test(getComputedStyle(p).mixBlendMode)) return true; return false; }).length;
|
||||
const bodyText = document.body.innerText || '';
|
||||
const emoji = (bodyText.match(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu) || []);
|
||||
const doc = document.documentElement; const pageH = Math.max(doc.scrollHeight, document.body.scrollHeight);
|
||||
const headingStats = headings.map(({ _el, ...h }) => h);
|
||||
const heroOut = hero ? (({ _el, ...h }) => h)(hero) : null;
|
||||
const below = headingStats.filter((h) => !heroOut || h.top !== heroOut.top);
|
||||
|
||||
return {
|
||||
viewport: { w: W, h: H },
|
||||
page: { height: pageH, screens: round(pageH / H, 1), words: bodyText.split(/\s+/).filter(Boolean).length,
|
||||
img: document.images.length, video: document.querySelectorAll('video').length, canvas: document.querySelectorAll('canvas').length, svg: document.querySelectorAll('svg').length, sections: document.querySelectorAll('section').length,
|
||||
horizontalOverflow: doc.scrollWidth > W + 2 ? doc.scrollWidth - W : 0, overflowCulprits, uiFragments, uiAbuse, realPictures, videoCount, uiPictures, pictureSamples, blendText, sideScrollers, brokenImages, emoji: [...new Set(emoji)].slice(0, 10),
|
||||
videosNoPoster: [...document.querySelectorAll('video')].filter((v) => !v.getAttribute('poster') && v.getBoundingClientRect().width >= 160).length },
|
||||
hero: heroOut, h1,
|
||||
headings: headingStats.slice(0, 40),
|
||||
headingSummary: { count: headingStats.length, maxLines: Math.max(0, ...headingStats.map((h) => h.lines)), over2Lines: headingStats.filter((h) => h.lines > 2 && h.kind !== 'statement').length, statements: headingStats.filter((h) => h.kind === 'statement').length, centered: headingStats.filter((h) => h.align === 'center').length, medianWords: below.length ? below.map((h) => h.words).sort((a, b) => a - b)[Math.floor(below.length / 2)] : null, mixedFontHeadings: headingStats.filter((h) => h.mixedFonts).length },
|
||||
type: {
|
||||
families, familyCount: famChars.size, sizes, sizeCount: sizes.length, notLoaded,
|
||||
body: bodySample ? { font: bodySample.fam, size: bodySample.size, leading: bodySample.lh, weight: bodySample.weight, tracking: bodySample.ls, contrast: (() => { const bg = bgBehind(bodySample.el); return bg ? contrast(rgba(bodySample.color), bg) : null; })() } : null,
|
||||
measureChars: longest ? Math.round(longest.w / (longest.size * 0.5)) : null,
|
||||
max: maxText ? { size: maxText.size, vw: round((maxText.size / W) * 100, 1), font: maxText.fam, text: maxText.text.slice(0, 40) } : null,
|
||||
monoShare: round(monoChars / Math.max(1, totalChars)), monoProseShare: round(monoProse / Math.max(1, totalChars)), monoFamilies: [...monoFams], monoSamples,
|
||||
microLabels, microLabelShare: round(microLabelChars / Math.max(1, totalChars), 3), microSamples,
|
||||
upperShare: round(upperChars / Math.max(1, totalChars)), centerShare: round(centerChars / Math.max(1, totalChars)), smallText,
|
||||
},
|
||||
eyebrows, numbered: numbered.slice(0, 12).map((t) => ({ text: t.text.slice(0, 30), size: t.size, top: Math.round(t.top) })),
|
||||
color: {
|
||||
bodyBg: hex(bodyBg), bodyBgHsl: hsl(bodyBg), dark: lum(bodyBg) < 0.18, sectionBgs: sectionBgs.slice(0, 10), textColors,
|
||||
button: filledBtns.length ? { bg: mode(filledBtns.map((b) => b.bg)), hsl: mode(filledBtns.map((b) => b.bgHsl)), fg: mode(filledBtns.map((b) => b.fg)) } : null,
|
||||
lowContrast: lowContrast.slice(0, 6),
|
||||
},
|
||||
buttons: { count: buttons.length, inHero: buttons.filter((b) => b.top < H).length, radius: mode(buttons.map((b) => b.radius)), height: mode(buttons.map((b) => b.h)), withShadow: buttons.filter((b) => b.shadow).length, withGradient: buttons.filter((b) => b.gradient).length, mono: buttons.filter((b) => b.mono).length, upper: buttons.filter((b) => b.upper).length, heroLabels: buttons.filter((b) => b.top < H).map((b) => b.text).filter(Boolean).slice(0, 5) },
|
||||
surfaces: { shadowStatic, shadowOverlay, shadowSamples, gradients, scrims, gradientSamples, gradientText, hairlines, hairlineSamples, outlinedBoxes, dashed, blurred, roundedBoxes, boxRadius: mode(boxRadii) ?? 0, squareMedia, roundedMedia, mediaRadius: mode(mediaRadii) ?? 0, textShadow: texts.filter((t) => t.textShadow).length, cardRows: cardRows.slice(0, 8) },
|
||||
heroMedia: heroMedia.slice(0, 4), nav,
|
||||
fontsLoaded: [...loaded].slice(0, 12),
|
||||
};
|
||||
}
|
||||
|
||||
// frame() runs at each scroll position the inspector photographs. It reports large text that something
|
||||
// else is lying on top of (a picture sliding over a headline), and how much of the screen holds anything.
|
||||
export function frame() {
|
||||
const W = innerWidth, H = innerHeight;
|
||||
const pinnedTop = (el) => { for (let p = el; p && p !== document.body; p = p.parentElement) { const cs = getComputedStyle(p); if ((cs.position === 'fixed' || cs.position === 'sticky') && p.getBoundingClientRect().top < 140 && p.getBoundingClientRect().height < 180) return true; } return false; };
|
||||
// 1. covered text. Hit-testing skips elements with pointer-events: none (a pinned title usually has it),
|
||||
// which would make whatever lies beneath look as if it were on top; switch it on while sampling.
|
||||
const hitStyle = document.createElement('style');
|
||||
hitStyle.textContent = '*, *::before, *::after { pointer-events: auto !important; }';
|
||||
document.head.appendChild(hitStyle);
|
||||
const covered = [], clipped = [], pinnedOver = [];
|
||||
const label = (el, direct) => (el.innerText || direct).replace(/\s+/g, ' ').trim().slice(0, 48);
|
||||
const pinnedLow = (el) => { for (let p = el; p && p !== document.body; p = p.parentElement) { const pcs = getComputedStyle(p); if (pcs.position === 'fixed' || pcs.position === 'sticky') return !pinnedTop(el); } return false; };
|
||||
// Does the element found on top actually paint anything there? A transparent layout box does not hide text.
|
||||
const paints = (hit, el) => {
|
||||
for (let p = hit; p && p !== document.body && !p.contains(el); p = p.parentElement) {
|
||||
if (/^(IMG|VIDEO|CANVAS|PICTURE|IFRAME|svg)$/.test(p.tagName) || p.closest('svg')) return true;
|
||||
const pcs = getComputedStyle(p);
|
||||
if (pcs.backgroundImage !== 'none') return true;
|
||||
const m = pcs.backgroundColor.match(/rgba?\(([^)]+)\)/); const parts = m ? m[1].split(/[,\s/]+/).filter(Boolean) : [];
|
||||
if (parts.length && (parts.length < 4 || parseFloat(parts[3]) > 0.05)) return true;
|
||||
if (p === hit) for (const n of p.childNodes) if (n.nodeType === 3 && n.nodeValue.trim()) return true; // its own text lies on ours
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const pictures = [...document.querySelectorAll('img, picture, svg, canvas, video')].filter((m) => !m.parentElement?.closest('svg')).map((m) => ({ m, r: m.getBoundingClientRect() })).filter(({ r }) => r.width >= 80 && r.height >= 80 && r.bottom > 0 && r.top < H);
|
||||
for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, blockquote, li, a, button, span, div')) {
|
||||
let direct = ''; for (const n of el.childNodes) if (n.nodeType === 3) direct += n.nodeValue;
|
||||
if (direct.trim().length < 3) continue;
|
||||
const cs = getComputedStyle(el); const size = parseFloat(cs.fontSize); if (size < 24 || cs.visibility === 'hidden' || +cs.opacity < 0.3) continue;
|
||||
let srOnly = false;
|
||||
for (let p = el, i = 0; p && p !== document.body && i < 4; p = p.parentElement, i++) { const pcs = getComputedStyle(p); const pr = p.getBoundingClientRect(); if (pcs.clip === 'rect(0px, 0px, 0px, 0px)' || /inset\(50%\)/.test(pcs.clipPath) || (pr.width <= 2 && pr.height <= 2 && pcs.overflow !== 'visible')) { srOnly = true; break; } }
|
||||
if (srOnly) continue;
|
||||
// the boxes that clip this text (overflow other than visible, or a clip-path)
|
||||
const clips = []; for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { const pcs = getComputedStyle(p); if (pcs.overflowY !== 'visible' || pcs.overflowX !== 'visible' || pcs.clipPath !== 'none') clips.push(p.getBoundingClientRect()); }
|
||||
// a scroll scene moves pictures across its own held stage by design; its text is judged by the scene rules
|
||||
const pinned = size >= 32 && pinnedLow(el) && !el.closest('.scene.is-live');
|
||||
// lines of one heading set as separate spans overlap each other's glyph boxes; that is not a collision
|
||||
const unit = el.closest('h1, h2, h3, h4, h5, h6, p, blockquote');
|
||||
const range = document.createRange(); let total = 0, hidden = 0, cutTop = false, area = 0, over = 0;
|
||||
for (const node of el.childNodes) {
|
||||
if (node.nodeType !== 3 || !node.nodeValue.trim()) continue;
|
||||
range.selectNodeContents(node);
|
||||
for (const r of range.getClientRects()) {
|
||||
if (r.width < 6 || r.bottom < 0 || r.top > H) continue;
|
||||
// sampled at two heights inside the line box (a display face's glyph box often spills past it and
|
||||
// under the next line), so a band lying over the top or the bottom of the letters is found too
|
||||
const lh = parseFloat(cs.lineHeight); const band = Math.min(r.height, Number.isFinite(lh) ? lh : r.height);
|
||||
for (const dy of [-0.2, 0.2]) for (const fx of [0.12, 0.37, 0.63, 0.88]) {
|
||||
const x = r.left + r.width * fx, y = r.top + r.height * 0.55 + band * dy; if (x < 0 || x >= W || y < 0 || y >= H) continue;
|
||||
total++; const hit = document.elementFromPoint(x, y);
|
||||
if (hit && hit !== el && !el.contains(hit) && !hit.contains(el) && !pinnedTop(hit) && !(unit && unit.contains(hit) && !/^(IMG|VIDEO|CANVAS|PICTURE|svg)$/.test(hit.tagName) && !hit.closest('svg')) && paints(hit, el)) hidden++;
|
||||
}
|
||||
// the glyph box starts above the capitals (ascender room), so only a cut well into it is a real crop
|
||||
if (size >= 40 && r.top >= -4 && clips.some((c) => c.top > r.top + r.height * 0.3 && c.top < r.bottom - r.height * 0.2)) cutTop = true;
|
||||
if (pinned) { area += r.width * r.height; for (const { m, r: pr } of pictures) { if (m.contains(el) || el.contains(m)) continue; const ox = Math.min(r.right, pr.right) - Math.max(r.left, pr.left), oy = Math.min(r.bottom, pr.bottom) - Math.max(r.top, pr.top); if (ox > 0 && oy > 0) over += ox * oy; } }
|
||||
}
|
||||
}
|
||||
if (total >= 4 && hidden / total >= 0.2) covered.push({ text: label(el, direct), share: Math.round((hidden / total) * 100) / 100 });
|
||||
if (cutTop) clipped.push({ text: label(el, direct) });
|
||||
if (pinned && area && over / area >= 0.15) pinnedOver.push({ text: label(el, direct), share: Math.min(1, Math.round((over / area) * 100) / 100) });
|
||||
}
|
||||
hitStyle.remove();
|
||||
// 2. how much of the screen holds anything: text glyph boxes, media, or a ground that differs from the page's
|
||||
const opaque = (c) => c && c !== 'transparent' && !/rgba\([^)]*,\s*0\)$/.test(c);
|
||||
const pageBg = [document.body, document.documentElement].map((e) => getComputedStyle(e).backgroundColor).find(opaque) || 'rgb(255, 255, 255)';
|
||||
const textRects = [];
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); const rg = document.createRange();
|
||||
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
||||
if (!n.nodeValue.trim() || !n.parentElement) continue; const pcs = getComputedStyle(n.parentElement); if (pcs.visibility === 'hidden' || +pcs.opacity < 0.2) continue;
|
||||
rg.selectNodeContents(n); for (const r of rg.getClientRects()) if (r.bottom > 0 && r.top < H && r.width > 1) textRects.push(r);
|
||||
}
|
||||
let filled = 0, samples = 0;
|
||||
for (let gx = 0; gx < 24; gx++) for (let gy = 0; gy < 15; gy++) {
|
||||
const x = (gx + 0.5) * (W / 24), y = (gy + 0.5) * (H / 15); samples++;
|
||||
if (textRects.some((r) => x >= r.left - 6 && x <= r.right + 6 && y >= r.top - 4 && y <= r.bottom + 4)) { filled++; continue; }
|
||||
let el = document.elementFromPoint(x, y);
|
||||
for (let i = 0; el && i < 6 && el !== document.body && el !== document.documentElement; i++, el = el.parentElement) {
|
||||
if (/^(IMG|VIDEO|CANVAS|PICTURE|svg|IFRAME)$/.test(el.tagName) || el.closest('svg')) { filled++; break; }
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.backgroundImage !== 'none') { filled++; break; }
|
||||
const bg = cs.backgroundColor; if (bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') { if (bg !== pageBg) filled++; break; }
|
||||
}
|
||||
}
|
||||
// 3. the large text in view, to spot one statement held on screen for screen after screen
|
||||
const big = [...new Set([...document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, blockquote')].filter((el) => { const r = el.getBoundingClientRect(); return !el.closest('.scene.is-live') && parseFloat(getComputedStyle(el).fontSize) >= 32 && r.top >= -4 && r.bottom <= H + 4 && r.height > 0; }).map((el) => (el.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 60)).filter((t) => t.length >= 4))];
|
||||
return { covered, clipped, pinnedOver, filled: Math.round((filled / samples) * 100) / 100, big, y: Math.round(scrollY) };
|
||||
}
|
||||
|
||||
// layout() runs once, back at the top after the page has been scrolled through (so reveals have fired).
|
||||
// It measures the frame of the page rather than its details: the two outer margins, sections whose content
|
||||
// huddles on one side, blank stretches with nothing in them, the title, untitled sections, text pressed
|
||||
// against the edge of the screen and how many left edges the text uses.
|
||||
export function layout() {
|
||||
const W = innerWidth, H = innerHeight, SY = scrollY;
|
||||
const D = document.documentElement, B = document.body;
|
||||
const pageH = Math.max(D.scrollHeight, B.scrollHeight);
|
||||
const pct = (arr, p) => { if (!arr.length) return null; const s = [...arr].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.max(0, Math.round((s.length - 1) * p)))]; };
|
||||
const shown = (el) => { for (let p = el, i = 0; p && p !== B && i < 30; p = p.parentElement, i++) { const cs = getComputedStyle(p); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.05) return false; if (i < 4 && (cs.clip === 'rect(0px, 0px, 0px, 0px)' || /inset\(50%\)/.test(cs.clipPath))) return false; } return true; };
|
||||
const layerOf = (el) => { for (let p = el; p && p !== B; p = p.parentElement) { const pos = getComputedStyle(p).position; if (pos === 'fixed') return 'fixed'; if (pos === 'sticky') return 'sticky'; } return null; };
|
||||
const sel = (el) => (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '')).slice(0, 60);
|
||||
const boxes = [];
|
||||
// text, one box per text node (its line boxes merged)
|
||||
const walker = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); const rg = document.createRange();
|
||||
for (let n = walker.nextNode(); n && boxes.length < 6000; n = walker.nextNode()) {
|
||||
const t = n.nodeValue.replace(/\s+/g, ' ').trim(); if (t.length < 2) continue;
|
||||
const el = n.parentElement; if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|OPTION)$/.test(el.tagName) || el.closest('svg')) continue;
|
||||
rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.width < 2 || r.height < 2) continue;
|
||||
if (!shown(el)) continue;
|
||||
const cs = getComputedStyle(el);
|
||||
boxes.push({ kind: 'text', el, l: r.left, r: r.right, t: r.top + SY, b: r.bottom + SY, chars: t.length, text: t.slice(0, 50), size: parseFloat(cs.fontSize), align: cs.textAlign, layer: layerOf(el), tag: el.tagName });
|
||||
}
|
||||
// pictures, drawings and controls
|
||||
for (const el of B.querySelectorAll('img, video, canvas, picture, iframe, svg, input, select, textarea, button, [role="button"], a, div, section, figure, span, li')) {
|
||||
const tag = el.tagName;
|
||||
let kind = null;
|
||||
if (/^(IMG|VIDEO|CANVAS|PICTURE|IFRAME)$/.test(tag)) kind = 'media';
|
||||
else if (tag === 'svg') { if (el.parentElement && el.parentElement.closest('svg')) continue; kind = 'media'; }
|
||||
else if (/^(INPUT|SELECT|TEXTAREA|BUTTON)$/.test(tag) || el.getAttribute('role') === 'button') kind = 'control';
|
||||
else { const bi = getComputedStyle(el).backgroundImage; if (bi && bi.includes('url(')) kind = 'media'; else continue; }
|
||||
const r = el.getBoundingClientRect(); if (r.width < 24 || r.height < 24) continue;
|
||||
if (kind === 'media' && r.width * r.height < 48 * 48) continue;
|
||||
if (!shown(el)) continue;
|
||||
boxes.push({ kind, el, l: r.left, r: r.right, t: r.top + SY, b: r.bottom + SY, chars: 0, size: 0, layer: layerOf(el), tag });
|
||||
}
|
||||
const flow = boxes.filter((b) => b.layer !== 'fixed');
|
||||
// 1. the frame: how far the page's content comes to each edge (full-bleed things say nothing about margins)
|
||||
// a row that runs off one edge (a carousel, a bled display line) says nothing about that side's margin
|
||||
const framed = flow.filter((b) => b.r - b.l < W * 0.97 && b.l >= -1 && b.r <= W + 1);
|
||||
const L = framed.length ? Math.max(0, pct(framed.map((b) => b.l), 0.02)) : null;
|
||||
const R = framed.length ? Math.max(0, W - pct(framed.map((b) => b.r), 0.98)) : null;
|
||||
// 2. sections: descend through single wrappers, then take the blocks
|
||||
const tall = (c) => { const r = c.getBoundingClientRect(); return r.height >= 120 && r.width >= W * 0.6 && getComputedStyle(c).position !== 'fixed' && shown(c); };
|
||||
let root = B;
|
||||
for (let i = 0; i < 12; i++) { const kids = [...root.children].filter(tall); if (kids.length === 1) root = kids[0]; else break; }
|
||||
// a tall block is split into its parts; the parts of a block that has its own heading continue it
|
||||
let blocks = [...root.children].filter(tall).map((el) => ({ el, cont: false }));
|
||||
for (let pass = 0; pass < 2; pass++) blocks = blocks.flatMap((b) => { const k = [...b.el.children].filter(tall); if (b.el.getBoundingClientRect().height > H * 1.6 && k.length >= 2) { const titled = b.cont || !!b.el.querySelector('h1, h2, h3, h4, [role="heading"]'); return k.map((el) => ({ el, cont: titled })); } return [b]; });
|
||||
const continues = new Set(blocks.filter((b) => b.cont).map((b) => b.el));
|
||||
blocks = blocks.map((b) => b.el);
|
||||
const bodySize = (() => { const m = new Map(); for (const b of flow) if (b.kind === 'text' && b.chars >= 40) m.set(Math.round(b.size), (m.get(Math.round(b.size)) || 0) + b.chars); return [...m.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || 16; })();
|
||||
const sections = blocks.slice(0, 40).map((s, i) => {
|
||||
const r = s.getBoundingClientRect(); const top = r.top + SY, bot = r.bottom + SY;
|
||||
const inside = flow.filter((b) => b.t >= top - 2 && b.b <= bot + 2 && s.contains(b.el));
|
||||
const own = inside.filter((b) => b.r - b.l < W * 0.97);
|
||||
const text = inside.filter((b) => b.kind === 'text');
|
||||
const media = inside.filter((b) => b.kind === 'media' && (b.r - b.l) * (b.b - b.t) >= 160 * 120);
|
||||
const words = text.reduce((a, b) => a + b.text.split(' ').length + Math.max(0, Math.round((b.chars - 50) / 6)), 0);
|
||||
const headRe = /^H[1-4]$/;
|
||||
const heading = text.filter((b) => (b.el.closest('h1,h2,h3,h4,[role="heading"]') || b.size >= Math.max(22, bodySize * 1.35)) && b.t <= top + Math.max(520, (bot - top) * 0.45)).sort((a, b) => b.size - a.size)[0];
|
||||
const cs = getComputedStyle(s);
|
||||
// the widest stretch across the section's content band that nothing covers, edge to edge excluded
|
||||
let middle = 0;
|
||||
if (own.length >= 2) {
|
||||
const iv = own.map((b) => [Math.max(0, b.l), Math.min(W, b.r)]).sort((a, b) => a[0] - b[0]);
|
||||
let end = iv[0][1];
|
||||
for (const [a, b2] of iv.slice(1)) { if (a > end) middle = Math.max(middle, a - end); end = Math.max(end, b2); }
|
||||
}
|
||||
return {
|
||||
sel: sel(s), tag: s.tagName.toLowerCase(), top: Math.round(top), height: Math.round(r.height), words, media: media.length, middle: Math.round(middle),
|
||||
heading: heading ? { text: heading.text, size: Math.round(heading.size), tag: (heading.el.closest('h1,h2,h3,h4,h5,h6') || heading.el).tagName.toLowerCase() } : null,
|
||||
left: own.length ? Math.round(Math.max(0, Math.min(...own.map((b) => b.l)))) : null, right: own.length ? Math.round(Math.max(0, W - Math.max(...own.map((b) => b.r)))) : null,
|
||||
textLeft: text.length ? Math.round(Math.min(...text.map((b) => b.l))) : null, textRight: text.length ? Math.round(W - Math.max(...text.map((b) => b.r))) : null,
|
||||
fullBleed: inside.some((b) => b.kind === 'media' && b.r - b.l >= W * 0.97), footer: s.tagName === 'FOOTER' || !!s.querySelector('footer') || i === blocks.length - 1,
|
||||
hero: top < H * 0.5, continues: continues.has(s), pinned: cs.position === 'sticky' || !!s.querySelector('.pin-spacer') || [...s.querySelectorAll('*')].slice(0, 400).some((e) => getComputedStyle(e).position === 'sticky' && e.getBoundingClientRect().height > H * 0.5),
|
||||
};
|
||||
});
|
||||
// 3. blank stretches: rows of the page with no text, picture or control, between the first and the last content.
|
||||
// A sticky element's track (its parent) and a pinning spacer are scroll distance, not blank page.
|
||||
const STEP = 4, rows = Math.ceil(pageH / STEP), occ = new Uint8Array(rows);
|
||||
const mark = (t, b, v) => { for (let y = Math.max(0, Math.floor(t / STEP)); y < Math.min(rows, Math.ceil(b / STEP)); y++) occ[y] = v; };
|
||||
for (const b of flow) if (b.layer !== 'sticky') mark(b.t, b.b, 1);
|
||||
for (const el of B.querySelectorAll('.pin-spacer')) { const r = el.getBoundingClientRect(); mark(r.top + SY, r.bottom + SY, 2); }
|
||||
let scanned = 0;
|
||||
for (const el of B.querySelectorAll('*')) { if (++scanned > 9000) break; const cs = getComputedStyle(el); if (cs.position === 'sticky' && el.parentElement) { const r = el.parentElement.getBoundingClientRect(); mark(r.top + SY, r.bottom + SY, 2); } }
|
||||
let first = 0; while (first < rows && occ[first] !== 1) first++;
|
||||
let last = rows - 1; while (last > 0 && occ[last] !== 1) last--;
|
||||
const gaps = []; let run = 0;
|
||||
for (let y = first; y <= last + 1; y++) { if (y <= last && occ[y] === 0) run++; else { if (run * STEP >= H * 0.3) gaps.push({ top: (y - run) * STEP, height: run * STEP }); run = 0; } }
|
||||
// 4. the title
|
||||
const h1s = [...document.querySelectorAll('h1')].filter((h) => { const r = h.getBoundingClientRect(); return r.width > 0 && r.height > 0 && shown(h); }).map((h) => { const r = h.getBoundingClientRect(); return { text: (h.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 80), size: Math.round(parseFloat(getComputedStyle(h).fontSize)), top: Math.round(r.top + SY), firstScreen: r.top + SY < H * 0.95 }; });
|
||||
// 5. text pressed against the edge of the screen (type deliberately bled past the edge is a different device)
|
||||
// fixed headers and corner labels count here: that is where text most often creeps to the edge
|
||||
const edge = 16;
|
||||
const edgeText = boxes.filter((b) => b.kind === 'text' && b.chars >= 3 && b.size < 40 && b.l >= -1 && b.r <= W + 1 && (b.l < edge || b.r > W - edge) && !b.el.closest('[class*="marquee" i],[class*="ticker" i],marquee,[data-ui],table')).slice(0, 8).map((b) => ({ text: b.text, side: b.l < edge ? 'left' : 'right', px: Math.round(b.l < edge ? b.l : W - b.r) }));
|
||||
// display type that runs off one edge by a sliver while it starts inside the margin: a fit error, not a bleed
|
||||
const slivers = boxes.filter((b) => b.kind === 'text' && b.size >= 40 && b.chars >= 3 && ((b.l >= 8 && b.r > W + 1 && b.r - W < b.size * 0.25) || (b.r <= W - 8 && b.l < -1 && -b.l < b.size * 0.25))).slice(0, 4).map((b) => ({ text: b.text.slice(0, 30), px: Math.round(b.r > W ? b.r - W : -b.l), side: b.r > W ? 'right' : 'left' }));
|
||||
// the largest empty rectangle on the first screen (below a 90px band for the navigation), on a 24 x 15 grid
|
||||
let voidShare = 0, voidBox = null;
|
||||
if (W >= 1000) {
|
||||
const C = 24, Rn = 15, cw = W / C, ch = H / Rn, occ2 = Array.from({ length: Rn }, () => new Array(C).fill(0));
|
||||
for (const b of boxes) { if (b.t - SY > H || b.b - SY < 0) continue; const c0 = Math.max(0, Math.floor(b.l / cw)), c1 = Math.min(C - 1, Math.floor((b.r - 1) / cw)), r0 = Math.max(0, Math.floor((b.t - SY) / ch)), r1 = Math.min(Rn - 1, Math.floor((b.b - SY - 1) / ch)); for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) occ2[r][c] = 1; }
|
||||
const top = Math.ceil(90 / ch), hgt = new Array(C).fill(0);
|
||||
for (let r = top; r < Rn; r++) {
|
||||
for (let c = 0; c < C; c++) hgt[c] = occ2[r][c] ? 0 : hgt[c] + 1;
|
||||
for (let c = 0; c < C; c++) { let mn = Infinity; for (let k = c; k < C && hgt[k]; k++) { mn = Math.min(mn, hgt[k]); const a = mn * (k - c + 1); if (a > voidShare) { voidShare = a; voidBox = { x: Math.round(c * cw), y: Math.round((r - mn + 1) * ch), w: Math.round((k - c + 1) * cw), h: Math.round(mn * ch) }; } } }
|
||||
}
|
||||
voidShare = Math.round((voidShare / (C * Rn)) * 100) / 100;
|
||||
}
|
||||
// a fixed picture layer that covers the screen (a canvas scene, a film) hides what the DOM cannot see
|
||||
const stage = boxes.some((b) => b.layer === 'fixed' && b.kind === 'media' && (b.r - b.l) * (b.b - b.t) >= W * H * 0.9);
|
||||
// the first screen: is the title a bare name, with nothing at reading size saying what this is?
|
||||
const firstText = flow.filter((b) => b.kind === 'text' && b.t < H * 0.95 && b.size >= 18 && !b.el.closest('h1, nav, header nav, button, a'));
|
||||
const explains = firstText.some((b) => b.text.split(' ').length >= 6 || b.chars >= 36);
|
||||
// 6. left edges used by running text and headings (left-aligned blocks only)
|
||||
// headings and paragraphs only: the items of a grid or a list each start their own column on purpose
|
||||
const lefts = flow.filter((b) => b.kind === 'text' && b.size >= 14 && !/center|right|end/.test(b.align) && b.l >= 0 && (b.el.closest('h1, h2, h3') || (b.chars >= 60 && b.el.closest('p, blockquote'))) && !b.el.closest('nav, footer, [data-ui], table, li, figcaption')).map((b) => b.l).sort((a, b) => a - b);
|
||||
const edges = []; for (const x of lefts) { const e = edges.find((g) => Math.abs(g.x - x) <= 10); if (e) e.n++; else edges.push({ x: Math.round(x), n: 1 }); }
|
||||
edges.sort((a, b) => b.n - a.n);
|
||||
return { viewport: { w: W, h: H }, pageH, margins: { left: L == null ? null : Math.round(L), right: R == null ? null : Math.round(R) }, sections, gaps, h1s, edgeText, edges: edges.slice(0, 12), leftBlocks: lefts.length, bodySize, stage, explains, slivers, voidShare, voidBox };
|
||||
}
|
||||
|
||||
// app() measures what an app screen (dashboard, admin, settings, inbox) gets right or wrong: the shell,
|
||||
// how much of the canvas is used, panels and their edges, numbers in tables and KPIs, chart colours,
|
||||
// the icon-in-a-tinted-square habit, placeholder data, and what happens to the sidebar on a phone.
|
||||
export function app() {
|
||||
const W = innerWidth, H = innerHeight, SY = scrollY, B = document.body;
|
||||
const cvs = document.createElement('canvas'); cvs.width = cvs.height = 1;
|
||||
const ctx = cvs.getContext('2d', { willReadFrequently: true });
|
||||
const rgba = (c) => { ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.fillStyle = c; ctx.fillRect(0, 0, 1, 1); const d = ctx.getImageData(0, 0, 1, 1).data; return { r: d[0], g: d[1], b: d[2], a: d[3] / 255 }; };
|
||||
const hex = (v) => '#' + [v.r, v.g, v.b].map((n) => n.toString(16).padStart(2, '0')).join('');
|
||||
const lum = (v) => { const f = (x) => { x /= 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(v.r) + 0.7152 * f(v.g) + 0.0722 * f(v.b); };
|
||||
const contrast = (a, b) => { const l1 = lum(a), l2 = lum(b); return Math.round(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)) * 100) / 100; };
|
||||
const hsl = (v) => { const r = v.r / 255, g = v.g / 255, b = v.b / 255, mx = Math.max(r, g, b), mn = Math.min(r, g, b); const l = (mx + mn) / 2; let h = 0, s = 0; if (mx !== mn) { const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60; } return { h: Math.round(h), s, l }; };
|
||||
const shown = (el, cs) => { cs = cs || getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.05) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
|
||||
const groundOf = (el) => { for (let p = el; p; p = p.parentElement) { const v = rgba(getComputedStyle(p).backgroundColor); if (v.a >= 0.9) return v; } return { r: 255, g: 255, b: 255, a: 1 }; };
|
||||
const pageBg = groundOf(B);
|
||||
const all = [...B.querySelectorAll('*')].slice(0, 12000);
|
||||
// 1. the shell: a tall column at one side (sidebar) and a bar across the top
|
||||
let sidebar = null, topbar = null;
|
||||
for (const el of all) {
|
||||
const r = el.getBoundingClientRect(); if (r.width < 40) continue;
|
||||
if (!sidebar && r.left <= 1 && r.top <= 80 && r.height >= H * 0.7 && r.width >= 48 && r.width <= Math.min(360, W * 0.45) && (el.querySelectorAll('a, button').length >= 3) && shown(el)) sidebar = { w: Math.round(r.width), tag: el.tagName.toLowerCase(), links: el.querySelectorAll('a, button').length, share: Math.round((r.width / W) * 100) / 100 };
|
||||
if (!topbar && r.top <= 1 && r.left <= (sidebar ? sidebar.w + 2 : 2) && r.width >= W * 0.6 && r.height >= 36 && r.height <= 96 && shown(el)) topbar = { h: Math.round(r.height) };
|
||||
if (sidebar && topbar) break;
|
||||
}
|
||||
// 2. how much of the canvas the content uses (right of the sidebar)
|
||||
const x0 = sidebar ? sidebar.w : 0;
|
||||
// the text itself (a block element's box runs to the edge whatever its text does), pictures, charts and controls
|
||||
const boxes = [];
|
||||
const tw = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); const trg = document.createRange();
|
||||
for (let n = tw.nextNode(); n && boxes.length < 5000; n = tw.nextNode()) {
|
||||
if (n.nodeValue.trim().length < 2 || !n.parentElement || n.parentElement.closest('svg')) continue;
|
||||
const cs = getComputedStyle(n.parentElement); if (cs.visibility === 'hidden' || cs.display === 'none' || +cs.opacity < 0.05) continue;
|
||||
trg.selectNodeContents(n); const r = trg.getBoundingClientRect(); if (r.width < 2 || r.left < x0 - 1 || (cs.position === 'fixed' && r.left > W * 0.5)) continue;
|
||||
boxes.push(r);
|
||||
}
|
||||
for (const el of B.querySelectorAll('svg, canvas, img, video, input, select, textarea, button, table')) {
|
||||
if (el.parentElement && el.parentElement.closest('svg')) continue;
|
||||
const r = el.getBoundingClientRect(); if (r.width < 8 || r.height < 8 || r.left < x0 - 1 || !shown(el)) continue;
|
||||
boxes.push(r);
|
||||
}
|
||||
const rights = boxes.map((r) => r.right).sort((a, b) => a - b), lefts = boxes.map((r) => r.left).sort((a, b) => a - b);
|
||||
const canvas = boxes.length ? { left: Math.round(lefts[Math.floor(lefts.length * 0.02)] - x0), right: Math.round(W - rights[Math.floor(rights.length * 0.98)]) } : null;
|
||||
// 3. panels: boxes that read as cards (a fill one step from the ground, a border, or a shadow)
|
||||
const panels = []; const radii = new Map(); let heavyBorders = 0, heavyShadows = 0, softShadows = 0, glass = 0; const borderSamples = [], shadowSamples = [];
|
||||
for (const el of all) {
|
||||
const r = el.getBoundingClientRect(); if (r.width < 140 || r.height < 72 || r.width > W * 0.98) continue;
|
||||
const cs = getComputedStyle(el); if (!shown(el, cs)) continue;
|
||||
const bg = rgba(cs.backgroundColor), bw = parseFloat(cs.borderTopWidth) || 0, bc = rgba(cs.borderTopColor);
|
||||
const ground = groundOf(el.parentElement || B);
|
||||
const filled = bg.a >= 0.9 && contrast(bg, ground) >= 1.04, bordered = bw >= 0.5 && cs.borderTopStyle !== 'none' && bc.a > 0.05;
|
||||
const sh = cs.boxShadow !== 'none' ? cs.boxShadow : '';
|
||||
if (!filled && !bordered && !sh) continue;
|
||||
if (!(el.innerText || '').trim()) continue;
|
||||
panels.push({ el, w: Math.round(r.width), h: Math.round(r.height), top: Math.round(r.top + SY), left: Math.round(r.left), bottom: Math.round(r.bottom + SY), right: Math.round(r.right) });
|
||||
const rad = Math.round(parseFloat(cs.borderTopLeftRadius) || 0); radii.set(rad, (radii.get(rad) || 0) + 1);
|
||||
if (bordered) { const c = contrast({ ...bc, a: 1 }, bg.a >= 0.9 ? bg : ground); if (c >= (lum(ground) > 0.4 ? 1.45 : 1.9)) { heavyBorders++; if (borderSamples.length < 3) borderSamples.push(`${hex(bc)} on ${hex(bg.a >= 0.9 ? bg : ground)} (${c}:1)`); } }
|
||||
if (sh) {
|
||||
for (const part of sh.split(/,(?![^(]*\))/)) {
|
||||
if (/inset/.test(part)) continue; const col = part.match(/rgba?\([^)]+\)/); const nums = part.replace(/rgba?\([^)]+\)/, '').trim().split(/\s+/).map(parseFloat);
|
||||
const a = col ? rgba(col[0]).a : 1; const blur = nums[2] || 0, y = Math.abs(nums[1] || 0);
|
||||
if (a <= 0.02 || (blur === 0 && y === 0 && !(nums[3] > 0))) continue;
|
||||
// on a dark ground a black shadow barely shows; there it only counts when it is near-opaque
|
||||
const darkGround = lum(ground) < 0.2;
|
||||
if (darkGround ? a >= 0.45 : ((a >= 0.12 && blur >= 12) || y >= 8 || a >= 0.25)) { heavyShadows++; if (shadowSamples.length < 3) shadowSamples.push(part.trim().slice(0, 70)); } else softShadows++;
|
||||
}
|
||||
}
|
||||
if (cs.backdropFilter && cs.backdropFilter !== 'none') glass++;
|
||||
}
|
||||
// top-level panels only (a card inside a panel is part of it): rows that end ragged, and how many
|
||||
// panels start on the first screen
|
||||
const panelEls = new Set(panels.map((p) => p.el));
|
||||
const tops = panels.filter((p) => { for (let a = p.el.parentElement; a && a !== B; a = a.parentElement) if (panelEls.has(a)) return false; return p.w < W * 0.97 || p.h < H * 2; });
|
||||
// a column of cards stacked with a gutter of 24px or less is one block: two cards on the left that end level with
|
||||
// one tall card on the right are not ragged. A pair is ragged only when the cards and their columns both end apart.
|
||||
const below = (p) => tops.find((q) => q !== p && Math.abs(q.left - p.left) <= 4 && Math.abs(q.w - p.w) <= 4 && q.top >= p.bottom - 2 && q.top - p.bottom <= 24);
|
||||
const columnBottom = (p) => { let b = p.bottom; for (let q = below(p), n = 0; q && n < 12; q = below(q), n++) b = q.bottom; return b; };
|
||||
const ragged = [];
|
||||
for (let i = 0; i < tops.length; i++) for (let k = i + 1; k < tops.length; k++) {
|
||||
const a = tops[i], b = tops[k];
|
||||
if (Math.abs(a.top - b.top) > 4 || a.w < 200 || b.w < 200 || (a.right > b.left - 4 && b.right > a.left - 4)) continue; // side by side, same row
|
||||
const diff = Math.min(Math.abs(a.bottom - b.bottom), Math.abs(columnBottom(a) - columnBottom(b))), tall = Math.max(a.h, b.h);
|
||||
if (diff > Math.max(24, tall * 0.12)) ragged.push({ diff, top: a.top });
|
||||
}
|
||||
// the largest blank band inside a card: the vertical gap between consecutive pieces of its content (text, charts,
|
||||
// pictures, controls, rules, fills), or from the last piece to the card's padding edge. A legend pinned to the foot
|
||||
// of a stretched card, or four rows in a card kept at its neighbour's height, leave one.
|
||||
const voids = [];
|
||||
const seen = new Map();
|
||||
const visibleIn = (el, root) => { if (seen.has(el)) return seen.get(el); let ok = true; for (let p = el; p && p !== root.parentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.05 || cs.position === 'fixed') { ok = false; break; } } seen.set(el, ok); return ok; };
|
||||
for (const p of tops) {
|
||||
if (p.el.matches('nav, aside, header, footer, dialog, [role="navigation"], [role="dialog"]') || p.el.querySelector('nav') || (sidebar && p.left < sidebar.w - 4) || p.h < 120) continue;
|
||||
if (panels.filter((q) => q.el !== p.el && p.el.contains(q.el)).length >= 2) continue; // a frame of cards, not a card
|
||||
const cs = getComputedStyle(p.el), pr = p.el.getBoundingClientRect();
|
||||
const inTop = pr.top + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0), inBottom = pr.bottom - (parseFloat(cs.borderBottomWidth) || 0) - (parseFloat(cs.paddingBottom) || 0);
|
||||
const bg = rgba(cs.backgroundColor), spans = [];
|
||||
const tw2 = document.createTreeWalker(p.el, NodeFilter.SHOW_TEXT), rg = document.createRange();
|
||||
// text that opens a section (a heading, a summary, or text at 14px/600 or 15px/500 and up) may follow a wider, placed gap
|
||||
for (let n = tw2.nextNode(); n; n = tw2.nextNode()) { if (!n.nodeValue.trim() || !n.parentElement || n.parentElement.closest('svg') || !visibleIn(n.parentElement, p.el)) continue; rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.height > 0 && r.width > 0) { const pcs = getComputedStyle(n.parentElement); const fs = parseFloat(pcs.fontSize), fw = +pcs.fontWeight; spans.push([r.top, r.bottom, !!n.parentElement.closest('h1, h2, h3, h4, h5, h6, [role="heading"], summary, legend, caption') || (fw >= 600 && fs >= 14) || (fw >= 500 && fs >= 15)]); } }
|
||||
for (const el of p.el.querySelectorAll('*')) {
|
||||
if (el.parentElement && el.parentElement.closest('svg')) continue;
|
||||
const r = el.getBoundingClientRect(); if (r.width < 2 || r.height < 1 || !visibleIn(el, p.el)) continue;
|
||||
if (/^(svg|canvas|img|video|input|select|textarea|button|hr|progress|meter|iframe)$/i.test(el.tagName)) { spans.push([r.top, r.bottom]); continue; }
|
||||
const ecs = getComputedStyle(el), fillC = rgba(ecs.backgroundColor);
|
||||
if (fillC.a >= 0.1 && (bg.a < 0.9 || contrast({ ...fillC, a: 1 }, bg) >= 1.03) && r.height >= 2) spans.push([r.top, r.bottom]);
|
||||
if ((parseFloat(ecs.borderTopWidth) || 0) >= 0.5 && ecs.borderTopStyle !== 'none' && rgba(ecs.borderTopColor).a > 0.05) spans.push([r.top, r.top + 1]);
|
||||
if ((parseFloat(ecs.borderBottomWidth) || 0) >= 0.5 && ecs.borderBottomStyle !== 'none' && rgba(ecs.borderBottomColor).a > 0.05) spans.push([r.bottom - 1, r.bottom]);
|
||||
}
|
||||
const ys = spans.filter(([a, b]) => b > inTop - 2 && a < inBottom + 2).sort((a, b) => a[0] - b[0]);
|
||||
if (!ys.length) continue;
|
||||
// a band counts when it is over 40px, or over 64px when the content after it opens a new section
|
||||
let end = ys[0][1], gap = 0, at = null;
|
||||
for (const [a, b] of ys.slice(1)) { const g = a - end, section = ys.some(([t, , hd]) => hd && Math.abs(t - a) < 6); if (g > (section ? 64 : 40) && g > gap) { gap = g; at = end; } end = Math.max(end, b); }
|
||||
if (inBottom - end > 40 && inBottom - end > gap) { gap = inBottom - end; at = end; }
|
||||
const head = (p.el.querySelector('h1, h2, h3, h4, [role="heading"], header') || p.el).innerText || '';
|
||||
if (gap > 40) voids.push({ gap: Math.round(gap), at: Math.round(at + SY), panel: head.trim().split('\n')[0].slice(0, 40) });
|
||||
}
|
||||
voids.sort((a, b) => b.gap - a.gap);
|
||||
const firstScreen = tops.filter((p) => p.top < H - 48).length;
|
||||
// the icon-in-a-tinted-square habit: small boxes with a coloured fill holding one icon, repeated
|
||||
const chips = []; let tiles = 0;
|
||||
for (const el of all) {
|
||||
const r = el.getBoundingClientRect(); if (r.width < 24 || r.width > 64 || Math.abs(r.width - r.height) > 4) continue;
|
||||
const cs = getComputedStyle(el); const bg = rgba(cs.backgroundColor); if (bg.a < 0.08) continue;
|
||||
const icon = el.querySelector('svg, i, img, [class*="icon" i]'); if (!icon || (el.innerText || '').trim().length > 2) continue;
|
||||
const c = hsl(bg); if (c.s < 0.18 && bg.a > 0.5) { if (r.width >= 28 && !el.closest('button, a, nav, aside, [role="navigation"]')) tiles++; continue; } // neutral: an avatar, a button, or a boxed icon
|
||||
chips.push({ hue: c.h, size: Math.round(r.width), radius: cs.borderTopLeftRadius });
|
||||
}
|
||||
const chipHues = new Set(chips.map((c) => Math.round(c.hue / 30)));
|
||||
// 5. numbers: tables and KPI figures
|
||||
const numRe = /^[−\-+]?[$€£¥]?\s?\d[\d,.\s]*(%|[kKmMbB]|\s?(ms|s|h|min|GB|MB|kB|x))?$/;
|
||||
const cells = [...B.querySelectorAll('td, [role="cell"], [role="gridcell"]')].filter((el) => numRe.test((el.innerText || '').trim()));
|
||||
const leftNums = cells.filter((el) => { const a = getComputedStyle(el).textAlign; const inner = el.firstElementChild ? getComputedStyle(el.firstElementChild).textAlign : a; return /left|start/.test(a) && /left|start/.test(inner) && getComputedStyle(el).justifyContent !== 'flex-end'; }).length;
|
||||
const figures = [];
|
||||
for (const el of all) { if (el.children.length) continue; const t = (el.innerText || '').trim(); if (!numRe.test(t) || t.length < 2) continue; const cs = getComputedStyle(el); if (!shown(el, cs)) continue; figures.push({ size: parseFloat(cs.fontSize), tabular: /tabular-nums/.test(cs.fontVariantNumeric) || /"tnum"/.test(cs.fontFeatureSettings), weight: +cs.fontWeight }); }
|
||||
const tabularShare = figures.length ? Math.round((figures.filter((f) => f.tabular).length / figures.length) * 100) / 100 : null;
|
||||
const rows = [...B.querySelectorAll('tbody tr, [role="row"]')].map((tr) => Math.round(tr.getBoundingClientRect().height)).filter((h) => h > 0);
|
||||
// 6. chart colours: saturated fills and strokes inside SVG charts and canvas-free charts
|
||||
const hues = new Map();
|
||||
for (const svg of B.querySelectorAll('svg')) {
|
||||
const r = svg.getBoundingClientRect(); if (r.width < 120 || r.height < 60) continue;
|
||||
for (const s of svg.querySelectorAll('path, rect, circle, line, polyline, polygon')) {
|
||||
const cs = getComputedStyle(s);
|
||||
for (const c of [cs.fill, cs.stroke]) { if (!c || c === 'none' || c.startsWith('url')) continue; const v = rgba(c); if (v.a < 0.3) continue; const h = hsl(v); if (h.s < 0.25 || h.l < 0.12 || h.l > 0.92) continue; const k = Math.round(h.h / 24); hues.set(k, (hues.get(k) || 0) + 1); }
|
||||
}
|
||||
}
|
||||
// bars and segments drawn with divs count too (a common way to fake a chart)
|
||||
// 7. placeholder data
|
||||
const text = B.innerText || '';
|
||||
const placeholders = [...new Set((text.match(/\b(John|Jane) Doe\b|\bAcme( Inc| Corp)?\b|\bLorem ipsum\b|\bexample\.com\b|\bUser Name\b|\bFirstname Lastname\b|\b123 Main St\b|\bfoo(bar)?\b/gi) || []).map((s) => s.toLowerCase()))];
|
||||
// 8. type at app sizes
|
||||
const sizes = new Map(); let tiny = 0; const tinySamples = [];
|
||||
const walker = document.createTreeWalker(B, NodeFilter.SHOW_TEXT);
|
||||
for (let n = walker.nextNode(); n; n = walker.nextNode()) { const t = n.nodeValue.trim(); if (t.length < 2 || !n.parentElement) continue; const cs = getComputedStyle(n.parentElement); if (cs.visibility === 'hidden' || cs.display === 'none') continue; const s = Math.round(parseFloat(cs.fontSize)); sizes.set(s, (sizes.get(s) || 0) + t.length); if (s < 11 && t.length >= 3) { tiny++; if (tinySamples.length < 4) tinySamples.push(t.slice(0, 24) + ' ' + s + 'px'); } }
|
||||
const total = [...sizes.values()].reduce((a, b) => a + b, 0) || 1;
|
||||
const under13 = Math.round(([...sizes.entries()].filter(([s]) => s < 13).reduce((a, [, c]) => a + c, 0) / total) * 100) / 100;
|
||||
const h1 = document.querySelector('h1'); const h1r = h1 && h1.getBoundingClientRect();
|
||||
// the screen's hero: its h1 when that is on the first screen, else the largest text there. Text is read with a
|
||||
// space between element children, so a figure and its unit ("£55.94" and "at cost") do not run together.
|
||||
const spaced = (el) => { const out = []; const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); for (let n = w.nextNode(); n; n = w.nextNode()) { const t = n.nodeValue.replace(/\s+/g, ' ').trim(); if (t && n.parentElement && !n.parentElement.closest('svg, script, style') && getComputedStyle(n.parentElement).display !== 'none') out.push(t); } return out.join(' ').replace(/\s+([,.;:!?%])/g, '$1').trim(); };
|
||||
let heroEl = h1 && h1r && h1r.width > 0 && h1r.top + SY < H * 0.95 ? h1 : null;
|
||||
if (!heroEl) {
|
||||
let best = null; const hw = document.createTreeWalker(B, NodeFilter.SHOW_TEXT);
|
||||
for (let n = hw.nextNode(); n; n = hw.nextNode()) {
|
||||
const el = n.parentElement; if (!el || n.nodeValue.trim().length < 2 || el.closest('svg, nav, aside, [role="navigation"]')) continue;
|
||||
const r = el.getBoundingClientRect(); if (r.top + SY > H * 0.95 || r.width < 1 || (sidebar && r.left < sidebar.w - 2)) continue;
|
||||
const cs = getComputedStyle(el); if (cs.visibility === 'hidden' || +cs.opacity < 0.05) continue;
|
||||
const size = parseFloat(cs.fontSize); if (!best || size > best.size) best = { el, size };
|
||||
}
|
||||
if (best) { heroEl = best.el; for (let p = heroEl; p && p !== B; p = p.parentElement) if (!/^inline/.test(getComputedStyle(p).display)) { heroEl = p; break; } }
|
||||
}
|
||||
let hero = null;
|
||||
if (heroEl) {
|
||||
const big = [...heroEl.querySelectorAll('*'), heroEl].map((e) => ({ e, s: parseFloat(getComputedStyle(e).fontSize) })).filter((o) => [...o.e.childNodes].some((c) => c.nodeType === 3 && c.nodeValue.trim())).sort((a, b) => b.s - a.s)[0]?.e || heroEl;
|
||||
const cs = getComputedStyle(big), size = parseFloat(cs.fontSize), txt = spaced(heroEl), r = heroEl.getBoundingClientRect();
|
||||
const tops2 = []; const lw = document.createTreeWalker(heroEl, NodeFilter.SHOW_TEXT); const lr = document.createRange();
|
||||
for (let n = lw.nextNode(); n; n = lw.nextNode()) { if (!n.nodeValue.trim()) continue; lr.selectNodeContents(n); for (const rc of lr.getClientRects()) { const mid = rc.top + rc.height / 2; if (rc.width >= 1 && !tops2.some((t) => Math.abs(t - mid) < size * 0.55)) tops2.push(mid); } }
|
||||
const lh = parseFloat(cs.lineHeight);
|
||||
hero = { text: txt.slice(0, 200), words: txt.split(' ').filter(Boolean).length, chars: txt.length, lines: Math.max(1, tops2.length), size: Math.round(size), vw: Math.round((size / W) * 1000) / 10, font: cs.fontFamily.split(',')[0].replace(/["']/g, '').trim(), weight: +cs.fontWeight, tracking: Math.round(((parseFloat(cs.letterSpacing) || 0) / size) * 100) / 100, leading: Number.isFinite(lh) ? Math.round((lh / size) * 100) / 100 : 1.2, top: Math.round(r.top + SY), tag: heroEl.tagName.toLowerCase(), kind: 'heading', align: 'left', position: 'left' };
|
||||
}
|
||||
// charts and timelines on the screen (for the data-hue check): sizeable SVGs with marks, or blocks named as one
|
||||
const chartRe = /(^|[\s_-])(chart|plot|graph|timeline|calendar|cal|gantt|heatmap|matrix|strip)([\s_-]|$)/i;
|
||||
const chartCount = [...B.querySelectorAll('svg')].filter((s) => { if (s.parentElement && s.parentElement.closest('svg')) return false; const r = s.getBoundingClientRect(); return r.width >= 200 && r.height >= 80 && s.querySelectorAll('path, rect, line, polyline, circle').length >= 6 && shown(s); }).length
|
||||
+ [...B.querySelectorAll('[class]')].filter((e) => typeof e.className === 'string' && chartRe.test(e.className) && !e.closest('svg') && (() => { const r = e.getBoundingClientRect(); return r.width >= 200 && r.height >= 80 && shown(e); })()).length;
|
||||
return {
|
||||
viewport: { w: W, h: H }, dark: lum(pageBg) < 0.2, ground: hex(pageBg),
|
||||
sidebar, topbar, canvas, hero, charts: chartCount,
|
||||
layout: { panels: tops.length, firstScreen, ragged: ragged.slice(0, 6), voids: voids.slice(0, 4) },
|
||||
tiles,
|
||||
panels: { count: panels.length, radii: [...radii.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6), heavyBorders, borderSamples, heavyShadows, softShadows, shadowSamples, glass },
|
||||
chips: { count: chips.length, hues: chipHues.size, samples: chips.slice(0, 4) },
|
||||
numbers: { cells: cells.length, leftAligned: leftNums, figures: figures.length, tabularShare, kpiMax: figures.length ? Math.max(...figures.map((f) => f.size)) : null },
|
||||
rows: rows.length ? { count: rows.length, median: rows.sort((a, b) => a - b)[Math.floor(rows.length / 2)] } : null,
|
||||
chartHues: hues.size,
|
||||
placeholders,
|
||||
type: { under13, tiny, tinySamples, sizes: [...sizes.keys()].sort((a, b) => a - b) },
|
||||
title: h1 && h1r.width > 0 ? { text: (h1.innerText || '').trim().slice(0, 60), size: Math.round(parseFloat(getComputedStyle(h1).fontSize)), top: Math.round(h1r.top + SY) } : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Share of a screenshot's pixels that are strongly coloured (saturated, neither near-black nor near-white).
|
||||
// The studied app screens keep it near 3%; pale tints barely count. Runs inside any page; takes a data: URL.
|
||||
export async function colourShare(dataUrl) {
|
||||
const img = new Image(); img.src = dataUrl; await img.decode();
|
||||
const w = 240, h = Math.max(1, Math.round((240 * img.height) / img.width));
|
||||
const c = document.createElement('canvas'); c.width = w; c.height = h;
|
||||
const x = c.getContext('2d', { willReadFrequently: true }); x.drawImage(img, 0, 0, w, h);
|
||||
const d = x.getImageData(0, 0, w, h).data; let n = 0;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const r = d[i] / 255, g = d[i + 1] / 255, b = d[i + 2] / 255, mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2;
|
||||
const s = mx === mn ? 0 : l > 0.5 ? (mx - mn) / (2 - mx - mn) : (mx - mn) / (mx + mn);
|
||||
if (s >= 0.4 && l >= 0.18 && l <= 0.82) n++;
|
||||
}
|
||||
return Math.round((n / (w * h)) * 1000) / 1000;
|
||||
}
|
||||
|
||||
// Dominant colours of a screenshot. Runs inside any page; takes a data: URL.
|
||||
export async function palette(dataUrl) {
|
||||
const img = new Image(); img.src = dataUrl; await img.decode();
|
||||
const w = 96, h = Math.max(1, Math.round((96 * img.height) / img.width));
|
||||
const c = document.createElement('canvas'); c.width = w; c.height = h;
|
||||
const x = c.getContext('2d', { willReadFrequently: true }); x.drawImage(img, 0, 0, w, h);
|
||||
const d = x.getImageData(0, 0, w, h).data; const bins = new Map();
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const k = (d[i] >> 4) << 8 | (d[i + 1] >> 4) << 4 | (d[i + 2] >> 4);
|
||||
const b = bins.get(k) || [0, 0, 0, 0]; b[0] += d[i]; b[1] += d[i + 1]; b[2] += d[i + 2]; b[3]++; bins.set(k, b);
|
||||
}
|
||||
const total = w * h;
|
||||
return [...bins.values()].sort((a, b) => b[3] - a[3]).slice(0, 6).map((b) => ({
|
||||
color: '#' + [0, 1, 2].map((i) => Math.round(b[i] / b[3]).toString(16).padStart(2, '0')).join(''), share: Math.round((b[3] / total) * 100) / 100,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Turns probe() metrics into findings. Each finding: { level: 'FAIL'|'WARN', id, message, fix }.
|
||||
// FAIL = a banned tell or a broken page; the work is not done while one remains.
|
||||
// WARN = usually wrong; keep only with a reason you could say out loud to a designer.
|
||||
|
||||
const inRange = (h, a, b) => (a <= b ? h >= a && h <= b : h >= a || h <= b);
|
||||
|
||||
// Colour families this skill treats as generated-page signatures.
|
||||
export function colourFamily(hsl) {
|
||||
if (!hsl) return null;
|
||||
const { h, s, l } = hsl;
|
||||
if (inRange(h, 22, 62) && s >= 0.16 && l >= 0.88 && l < 0.995) return 'cream';
|
||||
if (inRange(h, 6, 30) && s >= 0.38 && s <= 0.85 && l >= 0.42 && l <= 0.72) return 'terracotta';
|
||||
if (inRange(h, 244, 295) && s >= 0.42 && l >= 0.3 && l <= 0.8) return 'ai-purple';
|
||||
return null;
|
||||
}
|
||||
|
||||
const hexToHsl = (hex) => {
|
||||
const n = parseInt(hex.slice(1), 16); const r = (n >> 16) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255;
|
||||
const mx = Math.max(r, g, b), mn = Math.min(r, g, b); const l = (mx + mn) / 2; let h = 0, s = 0;
|
||||
if (mx !== mn) { const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60; }
|
||||
return { h: Math.round(h), s, l };
|
||||
};
|
||||
|
||||
export function judge(m, { width = m.viewport.w, allow = [] } = {}) {
|
||||
const out = [];
|
||||
const add = (level, id, message, fix) => { if (!allow.includes(id)) out.push({ level, id, width, message, fix }); };
|
||||
const mobile = width < 600;
|
||||
|
||||
// --- headlines
|
||||
// A title holds to two lines. A statement (a paragraph, quotation or list item set large) may run
|
||||
// longer, but only at statement size; set at display size it is a headline again. In blind tests jurors
|
||||
// read any large sentence of three lines or more as a long headline, so statement size is kept modest.
|
||||
const stmtMaxPx = width < 600 ? 24 : width < 1100 ? 32 : 36;
|
||||
const stmtMaxLines = width < 600 ? 4 : 3;
|
||||
const isStatement = (h) => h.kind === 'statement' && h.size <= stmtMaxPx;
|
||||
const long = m.headings.filter((h) => h.lines > 2 && !isStatement(h));
|
||||
if (long.length) {
|
||||
add('FAIL', 'headline-lines', `${long.length} headline(s) wrap past two lines at ${width}px: ` + long.slice(0, 4).map((h) => `"${h.text.slice(0, 48)}" → ${h.lines} lines (${h.words} words, ${h.size}px${h.kind === 'statement' ? ', a paragraph set above ' + stmtMaxPx + 'px' : ''})`).join('; '),
|
||||
'Cut words first (a headline is a title, not a sentence). Then give it a wider measure. Lower the size only as a last resort and never below the scale you chose. A real statement paragraph belongs in a <p> or <blockquote> at ' + stmtMaxPx + 'px or less.');
|
||||
}
|
||||
const longStatements = m.headings.filter((h) => isStatement(h) && h.lines > stmtMaxLines);
|
||||
if (longStatements.length) {
|
||||
add('WARN', 'statement-length', `${longStatements.length} large-set statement(s) run past ${stmtMaxLines} lines at ${width}px: ` + longStatements.slice(0, 3).map((h) => `"${h.text.slice(0, 40)}…" → ${h.lines} lines at ${h.size}px`).join('; '),
|
||||
'A statement set large is about 30 words. Cut it, or set the rest as ordinary body text.');
|
||||
}
|
||||
if (m.hero && m.hero.words > 10) add('WARN', 'hero-words', `Hero headline is ${m.hero.words} words ("${m.hero.text.slice(0, 60)}"). Gallery median is 5.`, 'Say the one thing. Move the rest to the supporting line.');
|
||||
if (m.headingSummary.mixedFontHeadings) add('WARN', 'mixed-font-headline', `${m.headingSummary.mixedFontHeadings} headline(s) switch typeface or to italic mid-line (the "one italic serif word" move).`, 'Set the whole headline in one face. Emphasis comes from the words.');
|
||||
|
||||
// --- banned furniture
|
||||
if (m.eyebrows.length) {
|
||||
add('FAIL', 'eyebrow', `${m.eyebrows.length} eyebrow/kicker label(s) above headings: ` + m.eyebrows.slice(0, 5).map((e) => `"${e.text}"${e.pill ? ' (pill)' : ''} above "${e.above}"`).join('; '),
|
||||
'Delete them. If the label carried real information, fold it into the headline or the supporting sentence.');
|
||||
}
|
||||
if (m.type.monoProseShare > 0) {
|
||||
add('FAIL', 'mono', `Monospace type outside code: ${m.type.monoFamilies.join(', ')} — e.g. ${m.type.monoSamples.slice(0, 4).map((s) => `"${s}"`).join(', ')}`,
|
||||
'Set it in the text face. Monospace is for literal code inside <pre>/<code> only.');
|
||||
}
|
||||
if (m.numbered.length) {
|
||||
add('FAIL', 'numbering', `Decorative index numbers: ${m.numbered.slice(0, 6).map((n) => `"${n.text}"`).join(', ')}`,
|
||||
'Remove them. If order truly matters, write it into the words ("First, …") or use a real <ol>.');
|
||||
}
|
||||
if (m.type.microLabels >= 3) add('FAIL', 'micro-label', `${m.type.microLabels} tiny uppercase letter-spaced labels (e.g. ${m.type.microSamples.slice(0, 4).map((s) => `"${s}"`).join(', ')}).`, 'Use sentence-case text at a readable size, or delete the label.');
|
||||
else if (m.type.microLabels > 0) add('WARN', 'micro-label', `${m.type.microLabels} tiny uppercase letter-spaced label(s): ${m.type.microSamples.slice(0, 3).map((s) => `"${s}"`).join(', ')}`, 'Prefer sentence case at body size.');
|
||||
if (m.page.emoji.length) add('FAIL', 'emoji', `Emoji in the page: ${m.page.emoji.join(' ')}`, 'Remove. Use words or real imagery.');
|
||||
|
||||
// --- surfaces
|
||||
const s = m.surfaces;
|
||||
if (s.gradients) add('FAIL', 'gradient', `${s.gradients} gradient fill(s): ${s.gradientSamples.slice(0, 3).join(' | ')}`, 'Use a flat colour or a real image. The only gradient allowed by default is a neutral scrim over a photo for legibility.');
|
||||
if (s.gradientText) add('FAIL', 'gradient-text', `${s.gradientText} gradient-filled text element(s).`, 'Solid colour text.');
|
||||
if (s.shadowStatic) add('FAIL', 'shadow', `${s.shadowStatic} box-shadow(s) on elements at rest: ${s.shadowSamples.slice(0, 3).join(' | ')}`, 'Remove. Separate surfaces with a tone step or space. Shadows are for things that really float (menus, dialogs, sticky bars).');
|
||||
if (s.textShadow) add('FAIL', 'text-shadow', `${s.textShadow} text-shadow(s).`, 'Remove; fix legibility with a scrim or a different crop.');
|
||||
if (m.buttons.withGradient || m.buttons.withShadow) add('FAIL', 'button-fx', `Buttons with ${m.buttons.withGradient ? 'gradient fill' : ''}${m.buttons.withGradient && m.buttons.withShadow ? ' and ' : ''}${m.buttons.withShadow ? 'shadow/glow' : ''}.`, 'Flat fill, one colour.');
|
||||
if (s.hairlines) add(s.hairlines >= 2 ? 'FAIL' : 'WARN', 'hairline', `${s.hairlines} thin rule(s)/divider line(s): ${s.hairlineSamples.slice(0, 4).join(' | ')}`, 'Delete. Separate with space, a tone change, or scale. Lines are only for real tables and form fields.');
|
||||
if (s.outlinedBoxes >= 3) add('FAIL', 'outlined-box', `${s.outlinedBoxes} boxes drawn with a 1px outline.`, 'Define a surface by its fill (one tone step from the page) or leave the content unboxed.');
|
||||
else if (s.outlinedBoxes) add('WARN', 'outlined-box', `${s.outlinedBoxes} box(es) drawn with a 1px outline.`, 'Prefer a filled surface or no box.');
|
||||
if (s.dashed) add('WARN', 'dashed', `${s.dashed} dashed/dotted border(s).`, 'Remove unless it is a real drop zone.');
|
||||
if (s.blurred > 1) add('WARN', 'glass', `${s.blurred} backdrop-blur surfaces.`, 'One blurred sticky bar at most; glass cards are a tell.');
|
||||
const iconRows = s.cardRows.filter((c) => c.iconTopped);
|
||||
if (iconRows.length) add('FAIL', 'icon-cards', `${iconRows.length} row(s) of identical cards each topped with a small icon (${iconRows.map((c) => c.count).join(', ')} cards).`, 'Replace with one composed section from the direction: a narrative split, a list set in large type, or real imagery per item.');
|
||||
else if (s.cardRows.length >= 2) add('WARN', 'card-rows', `${s.cardRows.length} rows of identical boxed cards.`, 'Vary the composition; not every section is a card grid.');
|
||||
if (m.nav?.floatingPill) add('WARN', 'pill-nav', 'Floating rounded pill navigation.', 'Use a plain bar: wordmark left, a few links, one action.');
|
||||
|
||||
// --- colour
|
||||
const fams = new Map();
|
||||
const note = (label, hsl) => { const f = colourFamily(hsl); if (f) fams.set(f, [...(fams.get(f) || []), label]); };
|
||||
note('page background ' + m.color.bodyBg, m.color.bodyBgHsl);
|
||||
for (const sb of m.color.sectionBgs) note('section ' + sb.color, sb.hsl || hexToHsl(sb.color));
|
||||
if (m.color.button) note('button ' + m.color.button.bg, m.color.button.hsl || hexToHsl(m.color.button.bg));
|
||||
for (const t of m.color.textColors) if (t.share >= 0.01) note('text ' + t.color, hexToHsl(t.color));
|
||||
if (fams.has('terracotta') && fams.has('cream')) add('FAIL', 'palette-claude', `Cream + terracotta palette (${[...fams.get('cream'), ...fams.get('terracotta')].slice(0, 4).join(', ')}).`, 'Re-draw the palette from the library. This pairing reads as an assistant brand, not yours.');
|
||||
else if (fams.has('terracotta')) add('FAIL', 'palette-terracotta', `Terracotta / rust-orange accent (${fams.get('terracotta').slice(0, 3).join(', ')}).`, 'Use the accent from the drawn palette.');
|
||||
else if (fams.has('cream')) add('WARN', 'palette-cream', `Yellow-cream background (${fams.get('cream').slice(0, 3).join(', ')}).`, 'Use the drawn palette. If you want warmth, take a low-chroma stone or bone, not a yellow cream.');
|
||||
if (fams.has('ai-purple')) add('FAIL', 'palette-purple', `Indigo / violet (${fams.get('ai-purple').slice(0, 3).join(', ')}).`, 'Use the accent from the drawn palette.');
|
||||
if (m.color.textColors.some((t) => t.color === '#0000ee' || t.color === '#551a8b')) add('WARN', 'default-link', 'Browser-default blue/purple link colour is showing.', 'Style every link: inherit the text colour and mark it with weight, underline offset, or the accent.');
|
||||
const veryLow = m.color.lowContrast.filter((c) => c.ratio < 2.6);
|
||||
if (veryLow.length) add('FAIL', 'contrast', `Unreadable text: ` + veryLow.slice(0, 4).map((c) => `"${c.text}" ${c.ratio}:1 (${c.fg} on ${c.bg})`).join('; '), 'Raise contrast to at least 4.5:1 for text, 3:1 for large display.');
|
||||
else if (m.color.lowContrast.length) add('WARN', 'contrast', `Low-contrast text: ` + m.color.lowContrast.slice(0, 4).map((c) => `"${c.text}" ${c.ratio}:1 needs ${c.need}:1`).join('; '), 'Darken or lighten the text colour.');
|
||||
|
||||
// --- type system
|
||||
const realFams = m.type.families.filter((f) => f.share >= 0.02);
|
||||
if (realFams.length > 3) add('FAIL', 'font-count', `${realFams.length} typefaces in use (${realFams.map((f) => f.family).join(', ')}).`, 'Two at most: one display, one text. Often one is enough.');
|
||||
else if (realFams.length === 3) add('WARN', 'font-count', `3 typefaces in use (${realFams.map((f) => f.family).join(', ')}).`, 'Two at most.');
|
||||
if (m.type.notLoaded.length) add('WARN', 'font-fallback', `Typeface(s) declared but not loaded, so a fallback is rendering: ${m.type.notLoaded.join(', ')}`, 'Fix the font URL / family name, or the screenshots you are judging are not the design.');
|
||||
if (m.type.sizeCount > 9) add('WARN', 'size-count', `${m.type.sizeCount} different font sizes (${m.type.sizes.join(', ')}).`, 'A page needs about five: display, heading, lead, body, small.');
|
||||
// Tiny text is the fault the study's best sites share (about a quarter of them); it is not copied here.
|
||||
if (m.type.body && m.type.body.size < 15) add('FAIL', 'body-size', `Body text is ${m.type.body.size}px (the size most of the running text is set in).`, 'Body copy 16–20px. Map small sizes from blueprints onto your body step.');
|
||||
else if (m.type.body && m.type.body.size < 16 && !mobile) add('WARN', 'body-size', `Body text is ${m.type.body.size}px.`, 'Body copy 16–20px.');
|
||||
if (m.type.body?.contrast && m.type.body.contrast < 7) add('WARN', 'grey-body', `The running text is a soft grey on its ground (${m.type.body.contrast}:1).`, 'Set body copy in the full ink (7:1 or more). Keep the muted ink for secondary notes; pages read thin and unfinished when everything is grey.');
|
||||
if (m.buttons.inHero === 0) add('WARN', 'first-action', `No visible button in the first ${mobile ? 'phone ' : ''}screen.`, mobile ? 'On a phone the first screen still holds the headline, the supporting sentence and the action; move navigation into one row or a menu so it does not push them down.' : 'Give the one primary action a real button in the first screen (a filled or solid-bordered control, 44px or taller), not only a link in the navigation.');
|
||||
// Models skip pictures when nothing makes them: the page must carry real ones (see SKILL.md, imagery).
|
||||
if (width >= 1100 && m.page.realPictures != null) {
|
||||
const pics = m.page.realPictures + m.page.uiPictures;
|
||||
if (pics < 4) add('FAIL', 'few-pictures', `Only ${pics} picture(s) on the page (${m.page.realPictures} photographs, footage or raster images, ${m.page.uiPictures} interface fragments). Drawings in SVG do not count.`, 'Find real pictures of the subject\'s world with scripts/assets.mjs (photographs, archival images, maps, engravings, footage), download them to assets/ and give them room: a hero picture, one per section that has a subject, a full-width band. At least four, usually six to twelve.');
|
||||
else if (m.page.realPictures < 2) add('WARN', 'no-real-pictures', `Only ${m.page.realPictures} real photograph(s) or clip(s); the rest are interface fragments.`, 'Add two or more real pictures of the customer\'s world from scripts/assets.mjs, not stock of people at laptops.');
|
||||
if (!m.page.videoCount) add('WARN', 'no-footage', 'No video anywhere on the page.', 'Look for one honest clip with scripts/assets.mjs --video (the place, the material, the process) and use it as a muted loop with a poster frame. Defend its absence only if no clip fits the subject.');
|
||||
}
|
||||
if (m.page.videosNoPoster) add('WARN', 'no-poster', `${m.page.videosNoPoster} clip(s) without a poster.`, 'Give every clip a poster frame: in the study of 100 Sites of the Day only 31% had one, and the blank or grey boxes before the clips played were recorded as faults (references/imagery.md §11).');
|
||||
if (m.page.sideScrollers) add('WARN', 'side-scroll', `${m.page.sideScrollers} row(s) scroll sideways with content past their edge.`, 'Jurors and most visitors read a row cut at the edge as clipped, not as scrollable. Wrap it onto more lines, or end it inside the grid.');
|
||||
if (m.page.blendText) add('WARN', 'blend-text', `${m.page.blendText} text element(s) use mix-blend-mode difference or exclusion.`, 'Over anything but black, white or grey, blended text takes random hues (blue on orange, lilac on green) that jurors read as a glitch. Use a solid ink chosen for the ground, or keep the blended text over neutral grounds only.');
|
||||
if (m.type.measureChars && m.type.measureChars > 88 && !mobile) add('WARN', 'measure', `Longest paragraph runs about ${m.type.measureChars} characters per line.`, 'Cap paragraphs at 45–75 characters (max-width: 60ch).');
|
||||
if (m.type.smallText > 3) add('FAIL', 'small-text', `${m.type.smallText} text blocks under 13px.`, 'Nothing meant to be read goes below 13px.');
|
||||
else if (m.type.smallText > 0) add('WARN', 'small-text', `${m.type.smallText} text block(s) under 13px.`, 'Nothing meant to be read goes below 13px.');
|
||||
if (m.type.centerShare > 0.72 && m.headings.length >= 4) add('WARN', 'all-centred', `${Math.round(m.type.centerShare * 100)}% of the text is centre-aligned.`, 'Centre one moment at most; anchor the rest to a shared left edge.');
|
||||
|
||||
// --- breakage
|
||||
if (m.page.uiAbuse) add('FAIL', 'ui-wrapper', 'A data-ui element wraps a heading or more than a screen and a half of the page.', 'data-ui marks a cropped interface fragment only. Remove it from page-level wrappers.');
|
||||
if (m.page.horizontalOverflow) add('FAIL', 'overflow', `Page scrolls sideways by ${m.page.horizontalOverflow}px at ${width}px.${m.page.overflowCulprits?.length ? ' Widest offenders: ' + m.page.overflowCulprits.join(', ') + '.' : ''}`, 'Constrain the element that is wider than the viewport (max-width: 100%, or overflow: clip on its section).');
|
||||
if (m.page.brokenImages.length) add('FAIL', 'broken-image', `Broken image(s): ${m.page.brokenImages.slice(0, 4).join(', ')}`, 'Fix the URL or replace the asset.');
|
||||
if (!m.hero) add('WARN', 'no-hero', 'No headline found in the first viewport.', 'The first screen needs a clear focal statement or image.');
|
||||
// One dominant thing per first screen: large type, or a picture that holds a third of it, or (for software) an interface fragment.
|
||||
else if (!mobile && m.hero.vw < 4.5 && (m.heroMedia[0]?.ratio || 0) < 0.3 && !m.page.uiFragments) {
|
||||
add('FAIL', 'focal-point', `The first screen has no dominant element: the largest type is ${m.hero.size}px (${m.hero.vw}vw) and no single picture holds a third of the viewport.`, 'Make one thing lead: the headline at display size, or one large picture. A grid of many small pictures is not one thing; in blind tests jurors marked a first screen without a headline down every time. On the strongest studied sites the largest type has a median of 8.4vw.');
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// The frame of the page, measured once per width by layout(): margins, balance, blank stretches, titles.
|
||||
// These catch the failures a reader names first: "no clear title", "random empty space", "it is all
|
||||
// pushed to one side", "text touching the edge".
|
||||
export function judgeLayout(L, { width = L.viewport.w, allow = [] } = {}) {
|
||||
const out = [];
|
||||
const add = (level, id, message, fix) => { if (!allow.includes(id)) out.push({ level, id, width, message, fix }); };
|
||||
const mobile = width < 600, H = L.viewport.h;
|
||||
// the outer frame: the two margins match (asymmetry belongs inside the grid, never in the frame)
|
||||
const { left, right } = L.margins;
|
||||
if (left != null && right != null) {
|
||||
const diff = Math.abs(left - right), wide = Math.max(left, right);
|
||||
if (diff >= Math.max(mobile ? 14 : 40, width * 0.045) && wide >= (mobile ? 28 : width * 0.07)) {
|
||||
add('FAIL', 'off-centre', `The page sits off-centre: its content comes to ${left}px from the left edge but only to ${right}px from the right, at ${width}px.`, `Give the page one pair of equal margins. Check the wrapper for a max-width without margin-inline: auto, a grid with an empty last column, or padding on one side only. If one side is meant to hold air, it must still be closed by something that reaches the margin (a picture, the navigation's last item, a figure).`);
|
||||
} else if (diff >= (mobile ? 12 : 16)) {
|
||||
// strong sites hold their two margins within a few pixels; a 20-30px drift is the "shifted" look
|
||||
add('WARN', 'off-centre', `The margins differ: ${left}px on the left, ${right}px on the right, at ${width}px.`, 'Put the navigation and the content on one pair of margins; check for padding on one side only, a scrollbar gutter, or a header on another inset than the page.');
|
||||
}
|
||||
}
|
||||
// sections whose content huddles on one side, leaving the other half of the screen empty
|
||||
if (!mobile && left != null) {
|
||||
const lop = L.sections.filter((s) => !s.hero && !s.footer && !s.fullBleed && !s.pinned && s.left != null && s.words >= 12 && s.height >= 180 && Math.min(s.left, s.right) <= Math.max(left, right) + 24 && Math.max(s.left, s.right) >= width * 0.42);
|
||||
if (lop.length >= 2) add('FAIL', 'lopsided', `${lop.length} sections keep all their content on one side and leave ${Math.round(Math.max(...lop.map((s) => Math.max(s.left, s.right))) / width * 100)}% of the width empty: ` + lop.slice(0, 4).map((s) => `${s.heading ? '"' + s.heading.text + '"' : s.sel} (${s.right > s.left ? 'right' : 'left'} ${Math.max(s.left, s.right)}px empty)`).join('; '), 'Use the whole width the page has set up: put the section\'s picture, its second column, its list or its figure in the empty half, or set the text larger across the grid. Empty space must be placed, with something on each side of it.');
|
||||
else if (lop.length) add('WARN', 'lopsided', `A section keeps its content on one side and leaves ${Math.max(lop[0].left, lop[0].right)}px of the width empty: ${lop[0].heading ? '"' + lop[0].heading.text + '"' : lop[0].sel}.`, 'Fill the other side with what the section has (its picture, a second column, the figure), or defend the void in one sentence.');
|
||||
// the same hole with the empty part in the middle: a card at the left, a lone button at the right
|
||||
const hollow = L.sections.filter((s) => !s.hero && !s.footer && !s.fullBleed && !s.pinned && s.words >= 12 && s.middle >= width * 0.45);
|
||||
if (hollow.length) add('WARN', 'empty-middle', `${hollow.length} section(s) have content at both edges and ${Math.max(...hollow.map((s) => s.middle))}px of nothing between: ` + hollow.slice(0, 3).map((s) => s.heading ? '"' + s.heading.text + '"' : s.sel).join('; '), 'Bring the two parts together on the grid, or put the picture or figure of the section in the middle. An empty middle third reads as a hole, the same as an empty half.');
|
||||
}
|
||||
// blank stretches: nothing at all for a long run down the page
|
||||
const big = L.gaps.filter((g) => g.height >= H * 0.6), mid = L.gaps.filter((g) => g.height >= H * 0.42 && g.height < H * 0.6);
|
||||
if (big.length || mid.length >= 2) add(L.stage ? 'WARN' : 'FAIL', 'blank-gap', `${big.length + mid.length} stretch(es) of the page hold nothing at all: ` + [...big, ...mid].slice(0, 4).map((g) => `${g.height}px at ${g.top}px`).join('; ') + ` (the screen is ${H}px tall).`, (L.stage ? 'A fixed picture layer covers the screen here, so the DOM cannot tell whether the stretch is empty: check the screenshots. ' : '') + 'Close the gap: a section gap is 120–240px on desktop and 72–112px on a phone. Look for a min-height: 100vh on a short section, an empty grid row, a hidden element still holding its space, or a reveal that never fired. A deliberate rest is a placed picture or colour field, not nothing.');
|
||||
else if (mid.length) add('WARN', 'blank-gap', `A ${mid[0].height}px stretch at ${mid[0].top}px holds nothing.`, 'Check that it is a deliberate rest (once per page at most); otherwise close it to the normal section gap.');
|
||||
// the title: one h1, on the first screen, that says what this is
|
||||
if (!L.h1s.length) add('FAIL', 'no-title', 'The page has no visible h1.', 'Give the first screen a clear title in an h1 that says what this is (a name with what it does, the product with what it is for, the event with its date and place).');
|
||||
else {
|
||||
const first = L.h1s[0];
|
||||
if (!first.firstScreen) add('FAIL', 'no-title', `The h1 ("${first.text.slice(0, 50)}") is not on the first screen (it starts ${first.top}px down).`, 'The title belongs on the first screen, where a visitor decides whether to stay.');
|
||||
else if (first.size < (mobile ? 26 : 32)) add('WARN', 'small-title', `The h1 is ${first.size}px ("${first.text.slice(0, 50)}").`, 'Set the title at display size, or make the large text of the first screen the h1.');
|
||||
if (first.firstScreen && first.text.split(' ').length <= 3 && L.explains === false) add('WARN', 'name-only-title', `The h1 is a bare name ("${first.text.slice(0, 40)}") and nothing else on the first screen says what this is.`, 'Keep the name if it leads, and give the line under it (18px or more) the plain words: what it is, for whom, where or when. In the study of 97 Sites of the Day, first screens that named nothing lost usability and content points.');
|
||||
if (L.h1s.length > 1) add('WARN', 'many-h1', `${L.h1s.length} h1 elements.`, 'One h1 per page; section titles are h2.');
|
||||
}
|
||||
// sections with running text but no heading: the reader cannot tell what they are
|
||||
const untitled = L.sections.filter((s) => !s.hero && !s.footer && !s.continues && !s.heading && s.words >= 30 && s.height >= 200);
|
||||
if (untitled.length >= 2) add('FAIL', 'untitled-section', `${untitled.length} sections carry text but no heading: ` + untitled.slice(0, 4).map((s) => `${s.sel} at ${s.top}px (${s.words} words)`).join('; '), 'Give each section a short heading that names what it holds (the menu, the rooms, how it works, the price), in the heading face at the section size.');
|
||||
else if (untitled.length) add('WARN', 'untitled-section', `A section carries text but no heading: ${untitled[0].sel} at ${untitled[0].top}px.`, 'Name it with a short heading unless it is the continuation of the section above.');
|
||||
// the first screen: a large empty rectangle that nothing closes reads as "random empty space"
|
||||
if (!mobile && L.voidShare >= 0.28 && !L.stage) add('WARN', 'hero-void', `${Math.round(L.voidShare * 100)}% of the first screen is one empty block (${L.voidBox.w}×${L.voidBox.h}px at ${L.voidBox.x},${L.voidBox.y}).`, 'Place the void on purpose or fill it: move the picture, the film or one fact into it, grow the headline to meet it, or shrink the screen to its content. A void reads as placed only when something closes it on the far side and it is the screen\'s one rest.');
|
||||
// display type that starts inside the margin but runs off the opposite edge by a sliver: a fit error
|
||||
if (L.slivers && L.slivers.length) add('WARN', 'one-sided-bleed', `${L.slivers.length} display line(s) start inside the margin but run off the ${L.slivers[0].side} edge by a few pixels: ` + L.slivers.map((t) => `"${t.text}" (${t.px}px)`).join('; '), 'Fit the line to the content box by measurement (a small fit script, or SVG text with textLength), or bleed it on purpose by a clear amount from the margin. A name sliced by a few pixels on one side reads as a mistake.');
|
||||
// text pressed against the screen edge
|
||||
const tight = L.edgeText.filter((t) => t.px < (mobile ? 12 : 16)), near = mobile ? L.edgeText.filter((t) => t.px >= 12) : [];
|
||||
if (near.length && !tight.length) add('WARN', 'edge-text', `${near.length} text block(s) sit 12–15px from the phone's edge: ` + near.slice(0, 3).map((t) => `"${t.text.slice(0, 30)}" (${t.px}px)`).join('; '), 'The page margin is 16–20px on a phone; the header and the corner labels sit on it too.');
|
||||
if (tight.length) add('FAIL', 'edge-text', `${tight.length} text block(s) sit within ${mobile ? 12 : 16}px of the screen edge: ` + tight.slice(0, 4).map((t) => `"${t.text.slice(0, 30)}" (${t.px}px from the ${t.side})`).join('; '), `Keep text inside the page margin (${mobile ? '16–20px' : '16–32px or more'}). Only display type deliberately bled past the edge may touch it.`);
|
||||
// too many left edges: nothing lines up
|
||||
if (!mobile && L.edges.length > 6 && L.leftBlocks >= 8) {
|
||||
const top3 = L.edges.slice(0, 3).reduce((a, e) => a + e.n, 0) / L.leftBlocks;
|
||||
if (top3 < 0.6) add('WARN', 'ragged-edges', `Left-aligned text starts at ${L.edges.length} different lines (${L.edges.slice(0, 7).map((e) => e.x + 'px').join(', ')}); the three most used hold only ${Math.round(top3 * 100)}% of it.`, 'Pick two or three anchor lines from the grid and start every heading and paragraph on one of them.');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// App screens (dashboards, admin, settings, inboxes) keep conventions a marketing page does not: tables
|
||||
// and panels may have quiet borders, labels are small, cards repeat, there is no hero, no picture floor
|
||||
// and no motion floor. These page rules do not apply to them; references/app-screens.md has the reasons.
|
||||
export const APP_SKIP = new Set(['hero-words', 'eyebrow', 'mono', 'name-only-title', 'empty-middle', 'numbering', 'hairline', 'outlined-box', 'dashed', 'icon-cards', 'card-rows', 'pill-nav', 'grey-body', 'measure', 'first-action', 'few-pictures', 'no-real-pictures', 'no-footage', 'focal-point', 'no-hero', 'body-size', 'small-text', 'statement-length', 'shadow', 'glass', 'still-page', 'empty-screens', 'long-pin', 'side-scroll', 'pinned-over-picture', 'off-centre', 'lopsided', 'ragged-edges', 'untitled-section', 'size-count', 'all-centred']);
|
||||
|
||||
export function judgeApp(A, m, { width = A.viewport.w, allow = [] } = {}) {
|
||||
const out = [];
|
||||
const add = (level, id, message, fix) => { if (!allow.includes(id)) out.push({ level, id, width, message, fix }); };
|
||||
const mobile = width < 600;
|
||||
if (mobile && A.sidebar && A.sidebar.share > 0.3) add('FAIL', 'sidebar-phone', `The sidebar still takes ${Math.round(A.sidebar.share * 100)}% of the width on a ${width}px phone.`, 'On a phone the navigation folds into a top bar with a menu button or a bottom bar of four or five destinations; the screen\'s content gets the full width.');
|
||||
if (!mobile && width >= 1100 && A.canvas && A.canvas.right > Math.max(120, width * 0.1) && A.canvas.right > A.canvas.left + 80) add('WARN', 'unused-canvas', `The content stops ${A.canvas.right}px short of the right edge while it starts ${A.canvas.left}px from the sidebar.`, 'Let the grid fill the canvas (columns in fr units), or centre a capped canvas with equal margins. A dead strip on one side reads as unfinished.');
|
||||
if (A.panels.heavyShadows) add('FAIL', 'heavy-shadow', `${A.panels.heavyShadows} panel shadow(s) heavy enough to read as floating: ${A.panels.shadowSamples.join(' | ')}`, 'Panels sit on the ground: separate them with a fill one step from the ground or a quiet 1px border. A shadow, if any, is under 8% black with a small blur. Real overlays (menus, dialogs, popovers) may float.');
|
||||
if (A.panels.glass) add('FAIL', 'glass', `${A.panels.glass} blurred glass panel(s).`, 'Opaque surfaces only.');
|
||||
if (A.panels.heavyBorders >= 3) add('WARN', 'heavy-borders', `${A.panels.heavyBorders} panel borders are dark against their ground: ${A.panels.borderSamples.join(' | ')}`, 'A panel border is a whisper: about 1.1–1.3:1 against its ground on a light screen (#e5e7eb–#ededed on white), and about 1.2–1.5:1 on a dark one (#1a1a1a on #000, #2e2e2e on #111). Darker lines turn the screen into a grid of boxes; keep the stronger line for controls.');
|
||||
const radii = A.panels.radii.filter(([, n]) => n >= 2).map(([r]) => r);
|
||||
if (radii.length > 3) add('WARN', 'radius-mix', `Panels use ${radii.length} corner radii (${radii.join(', ')}px).`, 'One radius for panels, one for controls, full round only for pills and avatars.');
|
||||
if (A.chips.count >= 3) add(A.chips.hues >= 2 ? 'FAIL' : 'WARN', 'icon-chips', `${A.chips.count} icons sit in tinted squares or circles${A.chips.hues >= 2 ? ` in ${A.chips.hues} different colours` : ''}.`, 'This is the commonest tell of a generated dashboard. Drop the tinted square: a KPI is a label and a number; an icon, if it helps, is plain and in the text colour.');
|
||||
if (A.numbers.cells >= 6 && A.numbers.leftAligned / A.numbers.cells >= 0.5) add('WARN', 'numeric-align', `${A.numbers.leftAligned} of ${A.numbers.cells} numeric table cells are left-aligned.`, 'Right-align numbers, amounts and percentages (and their column headers) so the digits line up.');
|
||||
if (A.numbers.figures >= 6 && A.numbers.tabularShare != null && A.numbers.tabularShare < 0.5) add('WARN', 'tabular', `Only ${Math.round(A.numbers.tabularShare * 100)}% of the figures use tabular numerals.`, 'Set font-variant-numeric: tabular-nums on tables, KPIs and anything that updates, so digits keep their width.');
|
||||
if (A.chartHues > 6) add('FAIL', 'rainbow-chart', `The charts use ${A.chartHues} different hues.`, 'One hue for the series that matters, a neutral for the rest, and at most four series colours in total; status colours only for status.');
|
||||
else if (A.chartHues > 4) add('WARN', 'rainbow-chart', `The charts use ${A.chartHues} different hues.`, 'Four series colours at most; prefer one accent and neutrals.');
|
||||
if (A.colourShare != null && A.colourShare > 0.12) add('WARN', 'colour-sprawl', `${Math.round(A.colourShare * 100)}% of the first screen is strongly coloured.`, 'Keep the screen neutral and spend colour on data and status: the studied screens sit near 3%, and only tinted styles reach 10–15%, as pale tints rather than saturated fills.');
|
||||
if (A.placeholders.length) add('FAIL', 'placeholder-data', `Placeholder data on screen: ${A.placeholders.join(', ')}.`, 'Use believable data for this product: real-looking names, amounts that add up, dates in order, units on every figure.');
|
||||
if (A.type.tiny > 2) add('FAIL', 'tiny-text', `${A.type.tiny} text blocks under 11px: ${A.type.tinySamples.join(', ')}.`, 'Nothing below 11px; secondary text 12px, data 13–14px.');
|
||||
else if (A.type.under13 > 0.45) add('WARN', 'tiny-text', `${Math.round(A.type.under13 * 100)}% of the text is under 13px.`, 'Data and labels read at 13–14px; keep 11–12px for sublines and axis ticks.');
|
||||
if (!mobile && A.layout) {
|
||||
if (A.layout.ragged.length) add('WARN', 'ragged-row', `${A.layout.ragged.length} pair(s) of cards in one row end at different heights (by ${A.layout.ragged.map((r) => r.diff + 'px').slice(0, 3).join(', ')}).`, 'Cards that share a row share a bottom edge: balance them by their content (the same number of rows, a block of similar height beside it), or stack them. In testing, ragged rows were marked down every time.');
|
||||
if (width >= 1100 && A.layout.panels >= 4 && A.layout.firstScreen / A.layout.panels < 0.5) add('WARN', 'below-fold', `Only ${A.layout.firstScreen} of ${A.layout.panels} blocks start on the first ${width}px screen.`, 'Every block the brief names shows its header and first rows on the first desktop screen: size the headline block to its question, cap long lists with "View all", and put time-sensitive blocks before long tables.');
|
||||
if (width >= 1100 && A.layout.voids?.length) add('WARN', 'panel-void', `${A.layout.voids.length} card(s) hold a blank band inside: ${A.layout.voids.slice(0, 3).map((v) => `${v.gap}px in "${v.panel}"`).join(', ')}.`, 'A card stretches to its neighbour only when it has content to fill. Keep a legend or a note directly under its chart (only an action or a total may sit at a stretched card\'s foot), let a short card keep its own height (align-items: start), and let a filtered block shrink to its rows.');
|
||||
}
|
||||
// an achromatic screen with a chart or a timeline and no hue on any data reads as a kit: the one chart hue goes on
|
||||
// the main data element (today's line, the booked bars, the margin that breaks)
|
||||
if (!mobile && A.charts > 0 && A.chartHues === 0 && A.colourShare != null && A.colourShare < 0.002) add('WARN', 'no-data-hue', `The screen has ${A.charts === 1 ? 'a chart' : A.charts + ' charts or timelines'} but no hue on any of its data (${(A.colourShare * 100).toFixed(1)}% coloured pixels).`, 'Put the style\'s one chart hue (or the brand\'s) on the screen\'s main data element: today\'s line, the booked bars, the current period, the cover margin. Keep the chrome neutral; the hue still stays under the style\'s colour budget.');
|
||||
// in app mode the screen's hero is its h1 or the largest text on the first screen, not the page probe's guess
|
||||
// (which can be a figure below the fold read without its spaces); the report and the MEASURED line use this one
|
||||
if (A.hero && m) m.hero = A.hero;
|
||||
if (A.title && A.title.size > (mobile ? 28 : 34)) add('WARN', 'app-title-size', `The h1 is ${A.title.size}px ("${A.title.text.slice(0, 40)}").`, 'An app screen is not a landing page: the title is 18-32px (20-24px on a phone).');
|
||||
if (A.numbers.kpiMax && A.numbers.kpiMax >= 48) add('WARN', 'kpi-size', `A figure is set at ${Math.round(A.numbers.kpiMax)}px.`, 'Figures run 16-40px on app screens; none of the studied screens reaches 48px. Size and isolation make a number loud, not display type.');
|
||||
if (A.type.sizes.length > 10) add('WARN', 'size-count', `${A.type.sizes.length} font sizes (${A.type.sizes.join(', ')}).`, 'App screens use 4-9 sizes (median 6): a scale such as 12/14/16/24 with one figure size.');
|
||||
if (A.tiles >= 4) add('WARN', 'icon-tiles', `${A.tiles} icons sit in neutral boxes repeated down the screen.`, 'A boxed icon on every row or card is still the template habit, tinted or not. Keep one plain 16px icon where it helps the eye, or none.');
|
||||
if (!A.title) add('FAIL', 'no-title', 'The screen has no h1.', 'Name the screen in an h1 at the top of the canvas (18–28px): "Overview", "Payouts", the project\'s name.');
|
||||
if (m && m.type && m.type.monoProseShare > 0.15) add('WARN', 'mono', `Monospace is ${Math.round(m.type.monoProseShare * 100)}% of the text.`, 'Monospace for IDs, keys and code only; numbers use tabular figures in the text face.');
|
||||
return out;
|
||||
}
|
||||
|
||||
export const RULE_IDS = ['headline-lines', 'statement-length', 'focal-point', 'hero-words', 'mixed-font-headline', 'eyebrow', 'mono', 'numbering', 'micro-label', 'emoji', 'gradient', 'gradient-text', 'shadow', 'text-shadow', 'button-fx', 'hairline', 'outlined-box', 'dashed', 'glass', 'icon-cards', 'card-rows', 'pill-nav', 'palette-claude', 'palette-terracotta', 'palette-cream', 'palette-purple', 'default-link', 'contrast', 'font-count', 'font-fallback', 'size-count', 'body-size', 'measure', 'small-text', 'all-centred', 'ui-wrapper', 'overflow', 'broken-image', 'no-hero', 'first-action', 'grey-body', 'blend-text', 'covered-text', 'clipped-text', 'pinned-over-picture', 'empty-screens', 'long-pin', 'side-scroll', 'few-pictures', 'no-real-pictures', 'no-footage', 'still-page', 'off-centre', 'lopsided', 'blank-gap', 'no-title', 'small-title', 'many-h1', 'name-only-title', 'empty-middle', 'untitled-section', 'edge-text', 'ragged-edges', 'sidebar-phone', 'unused-canvas', 'heavy-shadow', 'heavy-borders', 'radius-mix', 'icon-chips', 'numeric-align', 'tabular', 'rainbow-chart', 'placeholder-data', 'tiny-text', 'colour-sprawl', 'ragged-row', 'below-fold', 'app-title-size', 'kpi-size', 'icon-tiles', 'late-title', 'reduced-hidden', 'no-poster', 'hero-void', 'one-sided-bleed', 'font-not-dealt', 'panel-void', 'no-data-hue', 'scene-not-started', 'scene-not-live', 'many-scenes', 'scene-heavy'];
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
// tasteskill / lint — static scan of source files for the tells this skill bans.
|
||||
// Catches them before a render, plus the ones a render cannot see (copy habits, motion values).
|
||||
//
|
||||
// node lint.mjs [files or directories ...] [--app] [--json]
|
||||
//
|
||||
// Exit code 1 when any FAIL is found. Zero dependencies.
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, extname, relative, resolve } from 'node:path';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const asJson = args.includes('--json');
|
||||
// App screens (references/app-screens.md) keep quiet table and panel lines, faint shadows, data labels
|
||||
// and their own typefaces; a sheet dealt with draw.mjs --app switches this on by itself.
|
||||
let appMode = args.includes('--app');
|
||||
try { if (JSON.parse(readFileSync('.taste-skill/direction.json', 'utf8')).kind === 'app') appMode = true; } catch {}
|
||||
const APP_LEVEL = { hairline: null, 'outlined-box': null, 'default-font': null, numbering: 'WARN', mono: 'WARN', shadow: 'WARN', eyebrow: 'WARN', 'cta-copy': null, 'h-screen': null };
|
||||
const roots = args.filter((a) => !a.startsWith('--'));
|
||||
if (!roots.length) roots.push('.');
|
||||
const EXT = new Set(['.html', '.htm', '.css', '.scss', '.less', '.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte', '.astro', '.mdx']);
|
||||
const SKIP = new Set(['node_modules', '.git', '.next', '.nuxt', '.svelte-kit', 'dist', 'build', 'out', '.vercel', '.taste-skill', 'coverage', '.turbo', 'vendor']);
|
||||
|
||||
const files = []; const tooBig = [];
|
||||
const walk = (p) => {
|
||||
let st; try { st = statSync(p); } catch { return; }
|
||||
if (st.isDirectory()) { for (const f of readdirSync(p)) if (!SKIP.has(f) && !f.startsWith('.')) walk(join(p, f)); }
|
||||
else if (EXT.has(extname(p)) && !/\.min\.|\.d\.ts$|(^|[\\/])(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|[^\\/]+\.lock)$/.test(p)) { if (st.size < 4000000) files.push(p); else tooBig.push(p); }
|
||||
};
|
||||
for (const r of roots) {
|
||||
try { statSync(resolve(r)); } catch { console.error(`Not found: ${r}`); process.exit(2); }
|
||||
walk(resolve(r));
|
||||
}
|
||||
if (!files.length) { console.error(`No source files (${[...EXT].join(' ')}) under: ${roots.join(', ')}`); process.exit(2); }
|
||||
if (tooBig.length) console.error(`Skipped (over 4 MB, check by hand): ${tooBig.join(', ')}`);
|
||||
|
||||
// Each rule: [level, id, regex, message]. Tested per line. `code` lines (inside <pre>/<code>) skip the mono rule.
|
||||
const R = [
|
||||
['FAIL', 'mono', /\bfont-mono\b|font-family\s*:[^;{}]*(mono\b|monospace|Courier|Consolas|Menlo|Monaco)|(JetBrains|Geist|IBM\+?Plex|Space|DM|Roboto|Fragment|Azeret|Red\+?Hat|Martian|Commit|Departure|Fira)[+_ ]?Mono|Geist_Mono|GeistMono|Fira[+ ]Code|ui-monospace/i, 'Monospace type. Only literal code may be monospace.'],
|
||||
['FAIL', 'gradient', /(linear|radial|conic)-gradient\(|\bbg-(gradient|linear|radial|conic)-|\b(from|via)-(?!\[?0)[a-z]+-\d{2,3}\b/, 'Gradient fill. Use a flat colour or a real image (a neutral scrim over a photo is the one exception).'],
|
||||
['FAIL', 'gradient-text', /bg-clip-text|background-clip\s*:\s*text|text-transparent/, 'Gradient / clipped text. Use solid colour.'],
|
||||
['FAIL', 'shadow', /box-shadow\s*:\s*(?!none|0 0 #0000|var\(--tw)|\bshadow(-(sm|md|lg|xl|2xl|inner|\[[^\]]+\]))?(?=["'\s`])(?<!-shadow)|drop-shadow\(|\bdrop-shadow(-|\b)|text-shadow\s*:\s*(?!none)/, 'Shadow. Elements at rest carry none; only real overlays (menu, dialog, sticky bar) may.'],
|
||||
['FAIL', 'numbering', />\s*[(\[]?0[1-9][)\]]?\.?\s*<|["'`]\s*0[1-9]\s*["'`]|decimal-leading-zero|>\s*(Step|Phase|No\.?|Chapter)\s*0?\d\s*</i, 'Decorative index numbering (01 / 02 / 03).'],
|
||||
['WARN', 'numbering', /padStart\(\s*2\s*,\s*["']0["']\s*\)/, 'Zero-padding in script. Fine for a clock, a date or a price; if it builds 01 / 02 / 03 labels, remove it.'],
|
||||
['FAIL', 'eyebrow', /class(Name)?\s*=\s*["'`{][^"'`}]*\b(eyebrow|kicker|overline|pretitle|pre-title|suptitle|sup-title|tagline-label|section-label|section-tag)\b|\.(eyebrow|kicker|overline|pretitle|section-label|section-tag)\b\s*[{,]/i, 'Eyebrow / kicker label above a heading.'],
|
||||
['FAIL', 'micro-label', /(?=[^"'`]*\buppercase\b)(?=[^"'`]*\btracking-(wide|wider|widest|\[[^\]]+\])\b)(?=[^"'`]*\btext-(xs|\[1[0-3]px\]|\[0?\.[67]\d*rem\])\b)[^"'`]+/, 'Tiny uppercase letter-spaced label.'],
|
||||
['FAIL', 'purple', /\b(bg|text|from|to|via|border|ring|fill|stroke|shadow|decoration|outline)-(purple|violet|indigo|fuchsia)-\d{2,3}\b|#(6366f1|8b5cf6|7c3aed|a855f7|4f46e5|a78bfa|818cf8|9333ea|6d28d9|c084fc)\b/i, 'Indigo / violet — the generated-page accent.'],
|
||||
['FAIL', 'claude-palette', /#(d97757|da7756|c15f3c|cc785c|bd5d3a|f0eee6|faf9f5|f5f4ed|eeece2|e8e6dc)\b/i, 'Claude-brand cream / terracotta colour.'],
|
||||
['FAIL', 'emoji', /[\u{1F300}-\u{1FAFF}\u{2700}-\u{27BF}\u{2600}-\u{26FF}]/u, 'Emoji used as UI.'],
|
||||
['FAIL', 'lorem', /lorem ipsum|dolor sit amet/i, 'Placeholder copy shipped.'],
|
||||
['FAIL', 'hairline', /<hr\b|\bdivide-[xy](-\d|\b)|\b(h|w)-px\b|border-(top|bottom|left|right)\s*:\s*(0?\.5|1)px|\bborder-[tblrxy](?![-\w])|\bborder-[tblrxy]-(?!0\b|none)\[?1?(px)?\]?(?=["'\s`])|height\s*:\s*1px/, 'Thin rule / divider line. Separate with space, tone or scale; lines belong to real tables and form fields.'],
|
||||
['WARN', 'outlined-box', /border\s*:\s*1px\s+solid|\bborder\b(?=[^"'`]*\brounded)/, 'A 1px outlined box. Prefer a filled surface or no box.'],
|
||||
['WARN', 'glass', /backdrop-blur|backdrop-filter\s*:/, 'Backdrop blur. One sticky bar at most; glass cards are a tell.'],
|
||||
['WARN', 'copy', /\b(unlock|unleash|supercharge|seamless(ly)?|elevate|empower(s|ing)?|revolutioni[sz]e|game[- ]chang(er|ing)|cutting[- ]edge|next[- ]gen(eration)?|world[- ]class|best[- ]in[- ]class|all[- ]in[- ]one|effortless(ly)?|blazing(ly)?[- ]fast|lightning[- ]fast|reimagin(e|ed|ing)|delve|tapestry|leverage|synergy|streamline|robust|holistic|turbocharge|transform the way|take your .{1,30} to the next level|say goodbye to|in today'?s|the future of|built for the modern|not just .{1,40}[,—-] (it'?s|but)|more than (just )?a)\b/i, 'Stock marketing phrase. Say the specific thing the product does.'],
|
||||
['WARN', 'cta-copy', />\s*(Learn more|Get started|Click here|Read more|Discover more|Explore now|Submit)\s*</i, 'Generic button label. Name the action or the thing ("Book a table", "See the plans").'],
|
||||
['WARN', 'default-font', /family=(Inter|Poppins|Montserrat|Roboto|Open\+Sans|Space\+Grotesk|Instrument\+Serif|Playfair\+Display|Plus\+Jakarta\+Sans|Outfit|Sora|Lato|Nunito|Raleway)\b|font-family\s*:\s*["']?(Inter|Poppins|Montserrat|Roboto|Space Grotesk|Instrument Serif|Playfair Display|Plus Jakarta Sans|Outfit|Sora)\b|from ["']next\/font\/google["'].*\b(Inter|Poppins|Montserrat|Space_Grotesk|Instrument_Serif|Playfair_Display|Plus_Jakarta_Sans|Outfit|Sora)\b|\b(Inter|Poppins|Space_Grotesk|Instrument_Serif|Playfair_Display)\s*\(\s*\{/, 'A default typeface. Use the faces named by the drawn direction.'],
|
||||
['WARN', 'transition-all', /transition\s*:\s*all\b|\btransition-all\b/, 'transition: all. Name the properties (transform, opacity).'],
|
||||
['WARN', 'ease-in', /(?<![-\w])ease-in(?!-out)\b/, 'ease-in on UI feels sluggish. Use a strong ease-out for enters and exits.'],
|
||||
['WARN', 'scale-zero', /scale\(\s*0\s*\)|\bscale-0\b/, 'Entering from scale(0). Start at 0.95–0.98 with opacity 0.'],
|
||||
['WARN', 'h-screen', /\bh-screen\b|height\s*:\s*100vh/, '100vh jumps on mobile. Use 100svh / 100dvh.'],
|
||||
['WARN', 'bounce', /animate-(bounce|ping|pulse)\b/, 'Looping attention animation (bounce / ping / pulse dot).'],
|
||||
];
|
||||
if (appMode) {
|
||||
for (const r of R) if (r[1] in APP_LEVEL) r[0] = APP_LEVEL[r[1]];
|
||||
R.push(['FAIL', 'placeholder-data', /\b(John|Jane) Doe\b|\bAcme( Inc| Corp)?\b|\bexample\.com\b|\bFirstname Lastname\b|\b123 Main St\b/, 'Placeholder data. Use believable names, amounts that add up and real units for this product.']);
|
||||
R.push(['WARN', 'framework-colour', /#(3b82f6|2563eb|1d4ed8|60a5fa|10b981|22c55e|ef4444|f59e0b)\b/i, 'A framework default colour (Tailwind blue, green, red, amber). Take the accent and status colours from the dealt style.']);
|
||||
}
|
||||
|
||||
const findings = [];
|
||||
let emDashes = 0;
|
||||
for (const file of files) {
|
||||
const rel = relative(process.cwd(), file).split('\\').join('/');
|
||||
const lines = readFileSync(file, 'utf8').split(/\r?\n/);
|
||||
let inCode = false; let selector = ''; let pending = '';
|
||||
lines.forEach((line, i) => {
|
||||
// track the CSS selector we are inside, so rules scoped to [data-ui] (interface fragments) keep their own lines
|
||||
if (line.includes('{')) { selector = pending + line.slice(0, line.indexOf('{')); pending = ''; } else if (!line.includes('}')) pending = (pending + ' ' + line).slice(-300);
|
||||
const inUi = /data-ui/.test(selector) || /data-ui/.test(line);
|
||||
if (line.includes('}') && !line.includes('{')) { selector = ''; pending = ''; }
|
||||
if (/<(pre|code)\b/i.test(line)) inCode = true;
|
||||
const codeLine = inCode || /<(pre|code|kbd|samp)\b/i.test(line);
|
||||
if (/<\/(pre|code)>/i.test(line)) inCode = false;
|
||||
if (line.length > 2000) return; // minified or data
|
||||
if (/taste-skill-ignore/.test(line)) return;
|
||||
if (!/^\s*(\/\/|\/\*|\*|<!--)/.test(line)) emDashes += (line.replace(/\/\/.*$|\/\*.*?\*\/|<!--.*?-->/g, '').match(/—/g) || []).length; // comments are not copy
|
||||
for (const [level, id, re, message] of R) {
|
||||
if (!level) continue;
|
||||
if (id === 'mono' && (codeLine || /(^|[\s,>+~(])(pre|code|kbd|samp)\b|code/i.test(selector))) continue; // real code may be monospace
|
||||
if ((id === 'hairline' || id === 'outlined-box') && /(^|[\s,>+~(])(table|thead|tbody|tfoot|tr|td|th|input|textarea|select|fieldset)\b/i.test(selector)) continue; // real tables and form fields keep their lines
|
||||
if (inUi && (id === 'hairline' || id === 'outlined-box' || id === 'shadow')) continue;
|
||||
if (id === 'hairline' && /sr-only|visually-hidden|clip(-path)?\s*:|outline/i.test(line + selector)) continue; // screen-reader helpers and focus outlines are not rules
|
||||
if ((id === 'copy' || id === 'cta-copy' || id === 'emoji') && /^\s*(\/\/|\/\*|\*|import |@import|<!--)/.test(line)) continue;
|
||||
const m = line.match(re);
|
||||
if (m) findings.push({ level, id, file: rel, line: i + 1, match: m[0].trim().slice(0, 70), message });
|
||||
}
|
||||
});
|
||||
}
|
||||
if (emDashes > 4) findings.push({ level: 'WARN', id: 'em-dash', file: '(all files)', line: 0, match: `${emDashes} em dashes`, message: 'Em-dash habit. Use a full stop or a comma; keep at most one or two on a page.' });
|
||||
|
||||
if (asJson) { console.log(JSON.stringify({ files: files.length, findings }, null, 1)); process.exit(findings.some((f) => f.level === 'FAIL') ? 1 : 0); }
|
||||
|
||||
const byId = new Map();
|
||||
for (const f of findings) { const k = f.level + ' ' + f.id; if (!byId.has(k)) byId.set(k, []); byId.get(k).push(f); }
|
||||
console.log(`TASTESKILL · LINT ${files.length} file(s)`);
|
||||
if (!findings.length) console.log('\nNo banned patterns in source. Now run inspect.mjs on the rendered page.');
|
||||
for (const lvl of ['FAIL', 'WARN']) {
|
||||
const keys = [...byId.keys()].filter((k) => k.startsWith(lvl));
|
||||
if (!keys.length) continue;
|
||||
console.log(`\n${lvl}`);
|
||||
for (const k of keys) {
|
||||
const list = byId.get(k);
|
||||
console.log(` [${list[0].id}] ${list[0].message} (${list.length}×)`);
|
||||
for (const f of list.slice(0, 6)) console.log(` ${f.file}:${f.line} ${f.match}`);
|
||||
if (list.length > 6) console.log(` … ${list.length - 6} more`);
|
||||
}
|
||||
}
|
||||
const fails = findings.filter((f) => f.level === 'FAIL').length;
|
||||
console.log(`\nRESULT: ${fails} FAIL, ${findings.length - fails} WARN${fails ? ' — fix the FAILs before rendering.' : ''}`);
|
||||
console.log('Interface fragments marked data-ui keep their own borders and shadows. Any other line that must keep a flagged pattern for a real reason can carry the comment taste-skill-ignore.');
|
||||
process.exit(fails ? 1 : 0);
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
// tasteskill / scenes — photograph every scroll scene on a page at exact points of its progress.
|
||||
//
|
||||
// node scenes.mjs <url | path/to/file.html> [--out DIR] [--steps 10] [--scene "#id"] [--wait 900]
|
||||
//
|
||||
// For each <section class="scene"> (references/scenes.md): the screen before it, 0, 10, … 100% of its
|
||||
// hold, and the screen after it, on desktop (1440x900) and on a phone (390x844), as labelled contact
|
||||
// sheets; then the reduced-motion page screen by screen. Prints each scene's height in screens, frame
|
||||
// timing while the page is wheeled through its scenes, and console errors. Read every sheet it writes.
|
||||
//
|
||||
// Exit code 0 = every scene ran; 1 = a scene never started, fell back to rest on desktop, or the page
|
||||
// logged errors. Zero dependencies: drives an installed Chrome/Edge/Chromium. Node 22+.
|
||||
import { existsSync, mkdirSync, statSync, createReadStream } from 'node:fs';
|
||||
import { resolve, join, dirname, extname, sep, relative } from 'node:path';
|
||||
import { createServer } from 'node:http';
|
||||
import { Browser, sleep } from './lib/cdp.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (k, d) => { const i = args.indexOf('--' + k); if (i === -1) return d; const v = args[i + 1]; args.splice(i, 2); return v; };
|
||||
const outDir = resolve(flag('out', '.taste-skill/scenes'));
|
||||
const STEPS = Math.max(2, +flag('steps', 10)), WAIT = +flag('wait', 900);
|
||||
const only = flag('scene', '');
|
||||
const target = args[0];
|
||||
if (!target || target.startsWith('--')) { console.error('usage: node scenes.mjs <url | file.html> [--out DIR] [--steps 10] [--scene "#id"] [--wait 900]'); process.exit(2); }
|
||||
|
||||
// A local file is served from 127.0.0.1 (the working directory when the file is inside it, else the
|
||||
// file's folder), so relative assets and CDN scripts behave as they will when the page is hosted.
|
||||
let url = target, server = null;
|
||||
if (!/^https?:\/\//.test(target)) {
|
||||
const file = resolve(target);
|
||||
if (!existsSync(file)) { console.error('No such file: ' + file); process.exit(2); }
|
||||
const cwd = resolve('.');
|
||||
const root = file.startsWith(cwd + sep) ? cwd : dirname(file);
|
||||
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', '.mp4': 'video/mp4', '.webm': 'video/webm', '.woff': 'font/woff', '.woff2': 'font/woff2' };
|
||||
server = createServer((req, res) => {
|
||||
try {
|
||||
let f = resolve(join(root, decodeURIComponent(new URL(req.url, 'http://x').pathname)));
|
||||
if (f !== root && !f.startsWith(root + sep)) { res.writeHead(403); return res.end(); }
|
||||
if (statSync(f).isDirectory()) f = join(f, 'index.html');
|
||||
res.writeHead(200, { 'content-type': MIME[extname(f).toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-store' });
|
||||
createReadStream(f).pipe(res);
|
||||
} catch { res.writeHead(404); res.end('not found'); }
|
||||
});
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
url = `http://127.0.0.1:${server.address().port}/${relative(root, file).split(sep).map(encodeURIComponent).join('/')}`;
|
||||
}
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const browser = await Browser.launch();
|
||||
const helper = await browser.newPage({ width: 1600, height: 1000 });
|
||||
const problems = [];
|
||||
|
||||
async function sheet(frames, labels, cellW, cellH, cols, path) {
|
||||
const rows = Math.ceil(frames.length / cols);
|
||||
await helper.setViewport({ width: cols * cellW, height: rows * (cellH + 22), dsf: 1 });
|
||||
await helper.eval((a) => {
|
||||
document.body.style.cssText = `margin:0;background:#777;display:grid;grid-template-columns:repeat(${a.cols},${a.cellW}px);font:12px/22px system-ui;color:#fff`;
|
||||
document.body.innerHTML = a.frames.map((s, i) => `<figure style="margin:0"><img src="data:image/jpeg;base64,${s}" style="width:${a.cellW}px;height:${a.cellH}px;display:block;outline:1px solid #777"><figcaption style="padding-left:6px;background:#111">${a.labels[i]}</figcaption></figure>`).join('');
|
||||
return Promise.all([...document.images].map((i) => i.decode().catch(() => {})));
|
||||
}, { frames, labels, cellW, cellH, cols });
|
||||
await helper.screenshot({ path, quality: 78 });
|
||||
}
|
||||
|
||||
const SCENES = `[...document.querySelectorAll(${JSON.stringify(only || 'section.scene, .scene[data-scene]')})].map((el, i) => { const r = el.getBoundingClientRect(); return { id: el.id || el.dataset.scene || ('scene-' + (i + 1)), top: Math.round(r.top + scrollY), height: Math.round(r.height), live: el.classList.contains('is-live'), rest: el.classList.contains('is-rest') }; })`;
|
||||
const go = (y) => `(() => { const y = ${y}; if (window.Stage && Stage.lenis) Stage.lenis.scrollTo(y, { immediate: true, force: true }); else window.scrollTo(0, y); return new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); })()`;
|
||||
const rel = (p) => relative(process.cwd(), p).split('\\').join('/');
|
||||
|
||||
async function run(label, vp) {
|
||||
const page = await browser.newPage(vp);
|
||||
await page.goto(url, { timeoutMs: 40000, settleMs: 1200 });
|
||||
await page.eval('document.fonts.ready.then(() => true)').catch(() => {});
|
||||
await sleep(500);
|
||||
const scenes = await page.eval(SCENES);
|
||||
if (!scenes.length) console.log(`${label}: no <section class="scene"> on the page`);
|
||||
const H = vp.height;
|
||||
for (const sc of scenes) {
|
||||
if (!sc.live && !sc.rest) problems.push(`${label} ${sc.id}: never started (stage.js, the scene file or Stage.init() missing)`);
|
||||
if (label === 'desktop' && sc.rest) problems.push(`${label} ${sc.id}: shows its finished composition instead of running (GSAP not loaded, or the scene threw)`);
|
||||
const frames = [], labels = [];
|
||||
const span = Math.max(0, sc.height - H);
|
||||
const points = [{ y: Math.max(0, sc.top - H * 0.5), l: 'before' }, ...Array.from({ length: STEPS + 1 }, (_, k) => ({ y: sc.top + (span * k) / STEPS, l: `${Math.round((k / STEPS) * 100)}%` })), { y: sc.top + span + H * 0.6, l: 'after' }];
|
||||
for (const p of points) {
|
||||
await page.eval(go(Math.round(p.y))).catch(() => {});
|
||||
await sleep(WAIT);
|
||||
frames.push(await page.screenshot({ quality: 72 }));
|
||||
labels.push(`${sc.id} ${p.l}`);
|
||||
}
|
||||
const cw = vp.width >= 1000 ? 480 : 240, ch = Math.round((cw * H) / vp.width);
|
||||
const path = join(outDir, `${sc.id}-${label}.jpg`);
|
||||
await sheet(frames, labels, cw, ch, vp.width >= 1000 ? 4 : 7, path);
|
||||
console.log(`${label.padEnd(8)} ${sc.id}: ${(sc.height / H).toFixed(1)} screens tall, ${sc.live ? 'running' : sc.rest ? 'finished composition' : 'not started'} → ${rel(path)}`);
|
||||
}
|
||||
if (label === 'desktop' && scenes.length) {
|
||||
// frame timing while the page is wheeled through its scenes
|
||||
await page.eval(go(Math.max(0, scenes[0].top - H))).catch(() => {});
|
||||
await sleep(400);
|
||||
await page.eval(`(() => { window.__f = []; let last = performance.now(); const tick = (t) => { window.__f.push(t - last); last = t; if (window.__f.length < 600) requestAnimationFrame(tick); }; requestAnimationFrame(tick); })()`);
|
||||
await page.wheel(Math.min(scenes.reduce((a, s) => a + s.height, 0), 9000), { step: 120, pauseMs: 16 });
|
||||
await sleep(600);
|
||||
const f = await page.eval('window.__f.slice(2)');
|
||||
const sorted = f.slice().sort((a, b) => a - b);
|
||||
console.log(`smoothness: ${f.length} frames, median ${(sorted[Math.floor(f.length / 2)] || 0).toFixed(1)} ms, ${f.filter((d) => d > 34).length} over 34 ms (headless timing is rough; a handful is normal)`);
|
||||
}
|
||||
if (page.consoleErrors?.length) { problems.push(`${label} console: ${page.consoleErrors.slice(0, 3).join(' | ')}`); }
|
||||
await page.close();
|
||||
}
|
||||
|
||||
try {
|
||||
await run('desktop', { width: 1440, height: 900, dsf: 1 });
|
||||
await run('phone', { width: 390, height: 844, dsf: 1, mobile: true });
|
||||
const rp = await browser.newPage({ width: 1440, height: 900, dsf: 1, reducedMotion: true });
|
||||
await rp.goto(url, { timeoutMs: 40000, settleMs: 1200 });
|
||||
const h = await rp.eval('document.documentElement.scrollHeight');
|
||||
const frames = [], labels = [];
|
||||
for (let y = 0, k = 0; y < h && k < 16; y += 900, k++) { await rp.eval(`window.scrollTo(0, ${y})`); await sleep(350); frames.push(await rp.screenshot({ quality: 70 })); labels.push(`reduced motion, screen ${k + 1}`); }
|
||||
await sheet(frames, labels, 360, 225, 4, join(outDir, 'reduced-desktop.jpg'));
|
||||
console.log(`reduced ${frames.length} screens → ${rel(join(outDir, 'reduced-desktop.jpg'))}`);
|
||||
await rp.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
if (server) server.close();
|
||||
}
|
||||
if (problems.length) { console.log('\nPROBLEMS'); for (const p of problems) console.log(' ' + p); }
|
||||
console.log(problems.length ? `\nRESULT: ${problems.length} problem(s). Fix them, then look at every sheet.` : '\nRESULT: every scene ran. Now look at every sheet: does each frame read, and is the scene as bold as it should be?');
|
||||
process.exit(problems.length ? 1 : 0);
|
||||
Reference in New Issue
Block a user