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,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'];
|
||||
Reference in New Issue
Block a user