Merge remote-tracking branch 'origin/feat/design-lint-render' into feat/design-lint

This commit is contained in:
garrytan committed 2026-10-26 06:18:21 +00:00
commit 5232f86770
9 files changed
+1660 -30

No files matched your search

+43 -20
View File
@@ -133,11 +133,13 @@ export type RenderStep =
* the result is written to that file: strings verbatim; `data:` URLs are
* decoded to bytes; other values as JSON. Without `out`, the result comes
* back in `RenderResult.evals` (strings are truncated to `maxInline` chars).
* With `width`, the viewport is set before the eval the way a sized
* screenshot sets it, and cleared after.
*/
| { kind: 'eval'; expression: string; out?: string; maxInline?: number };
| { kind: 'eval'; expression: string; out?: string; maxInline?: number; width?: number; height?: number; deviceScaleFactor?: number; mobile?: boolean };
export interface RenderSpec {
/** Absolute path of the HTML file to open. */
/** Absolute path of the HTML file to open, or an http(s) URL (opened as it is, nothing served). */
file: string;
/** Directory served over loopback (default: the file's directory). Must contain `file`. */
serveRoot?: string;
@@ -247,6 +249,9 @@ export function buildRenderScript(url: string, spec: RenderSpec): string {
L.push(`console.log("STEP_OK ${i}");`);
} else {
// eval: promises are awaited by evaluate; write or inline the result
if (step.width) {
L.push(`await pg._sendToTarget("Emulation.setDeviceMetricsOverride", ${JSON.stringify({ width: step.width, height: step.height ?? Math.round(step.width * DEFAULT_ASPECT), deviceScaleFactor: step.deviceScaleFactor ?? DEFAULT_DEVICE_SCALE, mobile: step.mobile ?? step.width <= MOBILE_MAX_WIDTH })}); await sleep(250);`);
}
L.push(`{ const v = await pg.evaluate((src) => (0, eval)(src), ${JSON.stringify(step.expression)});`);
if (step.out) {
L.push(` const name = ${JSON.stringify(artifactName(i, step.out))};`);
@@ -260,6 +265,7 @@ export function buildRenderScript(url: string, spec: RenderSpec): string {
// must never be able to forge ASIDE_DIR= or the sentinel below.
L.push(` const s = typeof v === "string" ? v : JSON.stringify(v); console.log("EVAL ${i} " + Buffer.from(String(s ?? "").slice(0, ${max}), "utf8").toString("base64")); console.log("STEP_OK ${i}"); }`);
}
if (step.width) L.push(`await pg._sendToTarget("Emulation.clearDeviceMetricsOverride", {});`);
}
});
L.push(`console.log("PAGE_ERRORS=" + JSON.stringify(await pg.evaluate(() => window.__gstackErrs || [])));`);
@@ -340,17 +346,33 @@ export async function renderWithAside(spec: RenderSpec): Promise<RenderResult> {
return { ...(await asideRender(spec)), engine: 'aside' };
}
async function asideRender(spec: RenderSpec): Promise<RenderResult> {
/** What the engine opens: an http(s) `file` as it is, else the file served from its root over loopback. */
type PageTarget = { kind: 'url'; url: string } | { kind: 'file'; root: string; rel: string } | { kind: 'error'; error: string };
function pageTarget(spec: RenderSpec): PageTarget {
if (/^https?:\/\//.test(spec.file)) return { kind: 'url', url: spec.file };
const file = path.resolve(spec.file);
if (!fs.existsSync(file)) return { ok: false, outputs: [], evals: {}, stdout: '', error: `HTML file not found: ${file}` };
if (!fs.existsSync(file)) return { kind: 'error', error: `HTML file not found: ${file}` };
const root = path.resolve(spec.serveRoot ?? path.dirname(file));
const rel = path.relative(root, file);
if (rel.startsWith('..')) return { ok: false, outputs: [], evals: {}, stdout: '', error: `file ${file} is outside serveRoot ${root}` };
if (rel.startsWith('..')) return { kind: 'error', error: `file ${file} is outside serveRoot ${root}` };
return { kind: 'file', root, rel };
}
const srv = serveDir(root);
/** Serve a file target for one render; an http(s) target needs no server. */
function openTarget(target: Exclude<PageTarget, { kind: 'error' }>): { url: string; stop: () => void } {
if (target.kind === 'url') return { url: target.url, stop: () => {} };
const srv = serveDir(target.root);
return { url: `${srv.url}/${target.rel.split(path.sep).map(encodeURIComponent).join('/')}`, stop: srv.stop };
}
async function asideRender(spec: RenderSpec): Promise<RenderResult> {
const target = pageTarget(spec);
if (target.kind === 'error') return { ok: false, outputs: [], evals: {}, stdout: '', error: target.error };
const srv = openTarget(target);
try {
const url = `${srv.url}/${rel.split(path.sep).map(encodeURIComponent).join('/')}`;
const script = buildRenderScript(url, spec);
const script = buildRenderScript(srv.url, spec);
// Async spawn: a synchronous wait would block this event loop, and the
// loopback server above runs on it — Page.navigate would then time out.
const proc = await runProc('aside', ['repl', script], (spec.timeoutMs ?? DEFAULT_TIMEOUT_MS) + processSlackMs());
@@ -492,11 +514,8 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
const log: string[] = [];
const fail = (error: string): RenderResult => ({ ok: false, engine: 'browse', outputs, evals, stdout: log.join('\n'), error });
if (!bin) return fail(`${NO_BROWSER}: ${NO_BROWSER_HELP}`);
const file = path.resolve(spec.file);
if (!fs.existsSync(file)) return fail(`HTML file not found: ${file}`);
const root = path.resolve(spec.serveRoot ?? path.dirname(file));
const rel = path.relative(root, file);
if (rel.startsWith('..')) return fail(`file ${file} is outside serveRoot ${root}`);
const target = pageTarget(spec);
if (target.kind === 'error') return fail(target.error);
const deadline = Date.now() + (spec.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const run = async (args: string[]): Promise<string> => {
@@ -525,7 +544,7 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
let tab: number | undefined;
try {
work = fs.mkdtempSync(path.join(SAFE_TMP_DIR, 'gstack-render-browse-'));
srv = serveDir(root);
srv = openTarget(target);
// The first CLI call auto-starts the daemon; on a cold start it can answer
// "Unable to connect" once while the server is still coming up. One retry
// after a short pause turns that into the wait it really is.
@@ -555,7 +574,7 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
throw new Error(`${what} (waited ${timeoutMs}ms)`);
};
await run(['goto', `${srv.url}/${rel.split(path.sep).map(encodeURIComponent).join('/')}`, ...T]);
await run(['goto', srv.url, ...T]);
// Best-effort: once `$B cookie-import` has run, the daemon blocks `js` on
// every other origin (127.0.0.1 included). pdf/screenshot/`js --out` steps
// must still run; a waitFor or eval step that is genuinely blocked fails
@@ -590,12 +609,16 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
// Aside clears its device override after each shot; restore the daemon's
// default so a later un-sized screenshot is not taken at this width.
if (step.width) await run(['viewport', '1280x720', ...T]);
} else if (step.out) {
const tmp = path.join(work, artifactName(i, step.out));
await js(step.expression, ['--out', tmp]); // the daemon decodes data: URLs to bytes itself
copyOut(tmp, step.out, i);
} else {
evals[i] = (await js(step.expression)).slice(0, step.maxInline ?? DEFAULT_MAX_INLINE);
if (step.width) await run(['viewport', `${step.width}x${step.height ?? Math.round(step.width * DEFAULT_ASPECT)}`, ...T]);
if (step.out) {
const tmp = path.join(work, artifactName(i, step.out));
await js(step.expression, ['--out', tmp]); // the daemon decodes data: URLs to bytes itself
copyOut(tmp, step.out, i);
} else {
evals[i] = (await js(step.expression)).slice(0, step.maxInline ?? DEFAULT_MAX_INLINE);
}
if (step.width) await run(['viewport', '1280x720', ...T]);
}
}
const errs = await bestEffortJs('JSON.stringify(window.__gstackErrs || [])', 'PAGE_ERRORS');
+76
View File
@@ -0,0 +1,76 @@
/**
* A small PNG reader for the render tier: 8-bit greyscale, RGB, palette and
* alpha variants, non-interlaced, inflated with node:zlib. Enough to sample
* the pixels behind a run of text or around a focus ring in a screenshot the
* browser took; anything else returns null and the caller keeps its computed
* estimate.
*/
import { inflateSync } from 'node:zlib';
export interface Png { width: number; height: number; channels: number; data: Uint8Array }
export interface SampleRect { x: number; y: number; w: number; h: number; inner?: { l: number; t: number; r: number; b: number } }
const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
export function decodePng(buf: Uint8Array): Png | null {
if (buf.length < 8 || SIGNATURE.some((b, i) => buf[i] !== b)) return null;
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
let width = 0, height = 0, depth = 0, colorType = 0, interlace = 0;
let palette: Uint8Array | null = null;
const idat: Uint8Array[] = [];
for (let pos = 8; pos + 8 <= buf.length;) {
const len = view.getUint32(pos);
const type = String.fromCharCode(buf[pos + 4], buf[pos + 5], buf[pos + 6], buf[pos + 7]);
const body = buf.subarray(pos + 8, pos + 8 + len);
if (type === 'IHDR') { width = view.getUint32(pos + 8); height = view.getUint32(pos + 12); depth = buf[pos + 16]; colorType = buf[pos + 17]; interlace = buf[pos + 20]; }
else if (type === 'PLTE') palette = body;
else if (type === 'IDAT') idat.push(body);
else if (type === 'IEND') break;
pos += 12 + len;
}
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colorType];
if (!width || !height || depth !== 8 || interlace !== 0 || !channels || (colorType === 3 && !palette)) return null;
let raw: Uint8Array;
try { raw = inflateSync(Buffer.concat(idat.map((c) => Buffer.from(c)))); } catch { return null; }
const stride = width * channels;
if (raw.length < (stride + 1) * height) return null;
const out = new Uint8Array(stride * height);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
const src = y * (stride + 1) + 1, dst = y * stride, prev = (y - 1) * stride;
for (let i = 0; i < stride; i++) {
const x = raw[src + i];
const a = i >= channels ? out[dst + i - channels] : 0;
const b = y > 0 ? out[prev + i] : 0;
const c = y > 0 && i >= channels ? out[prev + i - channels] : 0;
let v: number;
if (filter === 0) v = x;
else if (filter === 1) v = x + a;
else if (filter === 2) v = x + b;
else if (filter === 3) v = x + ((a + b) >> 1);
else if (filter === 4) { const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); v = x + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c); }
else return null;
out[dst + i] = v & 255;
}
}
if (colorType !== 3) return { width, height, channels, data: out };
const rgb = new Uint8Array(width * height * 3);
for (let i = 0; i < width * height; i++) { const p = out[i] * 3; rgb[i * 3] = palette![p]; rgb[i * 3 + 1] = palette![p + 1]; rgb[i * 3 + 2] = palette![p + 2]; }
return { width, height, channels: 3, data: rgb };
}
/** The median colour (0..1 per channel) of a rectangle, leaving out an inner rectangle when one is given; null when nothing is sampled. */
export function medianRgb(png: Png, rect: SampleRect): number[] | null {
const x0 = Math.max(0, Math.floor(rect.x)), y0 = Math.max(0, Math.floor(rect.y));
const x1 = Math.min(png.width, Math.ceil(rect.x + rect.w)), y1 = Math.min(png.height, Math.ceil(rect.y + rect.h));
const ch: number[][] = [[], [], []];
const grey = png.channels < 3;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
if (rect.inner) { const ix = x - rect.x, iy = y - rect.y; if (ix >= rect.inner.l && ix < rect.inner.r && iy >= rect.inner.t && iy < rect.inner.b) continue; }
const i = (y * png.width + x) * png.channels;
ch[0].push(png.data[i]); ch[1].push(grey ? png.data[i] : png.data[i + 1]); ch[2].push(grey ? png.data[i] : png.data[i + 2]);
}
if (!ch[0].length) return null;
return ch.map((a) => a.sort((p, q) => p - q)[a.length >> 1] / 255);
}
+193
View File
@@ -0,0 +1,193 @@
/**
* The render tier's in-page measurements of quality at 1440: heading rhythm,
* cramped boxes, over-long prose lines, weak field edges, text that strains,
* the weight count and the lonely grid item. Same rules as render-probe.ts:
* each function is serialised into the page and may touch nothing outside its
* arguments.
*
* Ported from Nutlope/hallmark's sloplint-render.mjs observeQuality (MIT, see
* NOTICE.md): gates 71, 72, 25, 40 (field edges), 75, F19, F20.
*/
import type { ProbeFinding, Probes } from './render-probe';
// sloplint-render observeQuality: the quality checks in one eval
export function glQuality(P: Probes): ProbeFinding[] {
const { vis } = P.H;
const quiet = (el: Element) => !!el.closest('script, style, noscript, [aria-hidden="true"], dialog:not([open])') || !!(el.parentElement && el.parentElement.closest('svg'));
const all = Array.from(document.body.querySelectorAll('*')).filter((el) => !quiet(el) && vis(el));
return [...P.rhythm(P, all), ...P.padding(P, all), ...P.prose(P, all), ...P.fields(P, all), ...P.strain(P), ...P.grid(P, all)];
}
// sloplint-render observeQuality, the crowded head (gate 71)
export function glRhythm(P: Probes, all: Element[]): ProbeFinding[] {
const { vis, name, text, clear, at } = P.H;
const out: ProbeFinding[] = [];
const quiet = (el: Element) => !!el.closest('script, style, noscript, [aria-hidden="true"], dialog:not([open])') || !!(el.parentElement && el.parentElement.closest('svg'));
const abs = (r: DOMRect) => ({ top: r.top + scrollY, bottom: r.bottom + scrollY, left: r.left, right: r.right });
const boxes: Array<{ el: Element; top: number; bottom: number; left: number; right: number }> = [];
for (const el of all) {
const media = /^(IMG|svg|CANVAS|VIDEO|PICTURE|IFRAME|INPUT|BUTTON|SELECT|TEXTAREA)$/.test(el.tagName) || el.hasAttribute('data-slot');
if (!media && !Array.from(el.childNodes).some((n) => n.nodeType === 3 && (n.textContent || '').trim())) continue;
boxes.push({ el, ...abs(el.getBoundingClientRect()) });
}
const overlapX = (a: { left: number; right: number }, b: { left: number; right: number }) => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 8;
const crowded: Array<{ text: string; el: Element }> = [];
for (const h of document.querySelectorAll('h2, h3, h4')) {
if (!vis(h) || quiet(h) || h.closest('nav, footer, table, [data-slot]')) continue;
let small = false;
for (let p = h.parentElement; p && p !== document.body; p = p.parentElement) {
const cs = getComputedStyle(p);
if (!clear(cs.backgroundColor) || parseFloat(cs.borderTopWidth) > 0 || cs.boxShadow !== 'none') { small = p.getBoundingClientRect().height < 200; break; }
}
if (small) continue;
const hb = abs(h.getBoundingClientRect());
let above: typeof boxes[number] | null = null, below: typeof boxes[number] | null = null;
for (const b of boxes) {
if (h.contains(b.el) || b.el.contains(h) || !overlapX(b, hb)) continue;
if (b.bottom <= hb.top + 1 && (!above || b.bottom > above.bottom)) above = b;
if (b.top >= hb.bottom - 1 && (!below || b.top < below.top)) below = b;
}
if (!above || !below) continue;
const up = hb.top - above.bottom, down = below.top - hb.bottom;
if (down < 6 || down > 160) continue;
if (up < 0.75 * down && down - up >= 12) crowded.push({ text: name(h) + ' "' + text(h).slice(0, 18) + '" ' + Math.round(up) + 'px over, ' + Math.round(down) + 'px under', el: h });
}
if (crowded.length >= 2) out.push({ rule: 'heading-rhythm', grade: 'fail', evidence: 'heads nearer what precedes them than what they head: ' + crowded.slice(0, 2).map((c) => c.text).join('; '), at: at(crowded[0].el) });
return out;
}
// sloplint-render observeQuality, the cramped box (gate 72)
export function glPadding(P: Probes, all: Element[]): ProbeFinding[] {
const { vis, name, clear, at } = P.H;
const out: ProbeFinding[] = [];
const tight: Array<{ text: string; el: Element }> = [];
for (const el of all) {
if (tight.length >= 3) break;
if (/^(SECTION|MAIN|HEADER|FOOTER|NAV|ARTICLE|INPUT|TEXTAREA|SELECT|svg|IMG|TABLE|THEAD|TBODY|TR|TD|TH|PRE|CODE|IFRAME|VIDEO|CANVAS|DIALOG)$/.test(el.tagName) || el.closest('[data-slot], table')) continue;
const r = el.getBoundingClientRect();
if (r.width <= 100 || r.height <= 30 || r.width > innerWidth * 0.9) continue;
const cs = getComputedStyle(el);
if (cs.display === 'inline') continue;
if (/auto|scroll/.test(cs.overflowX + cs.overflowY)) continue;
const bw = [cs.borderTopWidth, cs.borderRightWidth, cs.borderBottomWidth, cs.borderLeftWidth].map((v) => parseFloat(v) || 0);
const bc = [cs.borderTopColor, cs.borderRightColor, cs.borderBottomColor, cs.borderLeftColor];
const inked = (k: number) => bw[k] >= 1 && !clear(bc[k]);
let under = 'rgb(255, 255, 255)';
for (let p = el.parentElement; p; p = p.parentElement) { const c = getComputedStyle(p).backgroundColor; if (!clear(c)) { under = c; break; } }
const filled = !clear(cs.backgroundColor) && cs.backgroundColor !== under;
if (!filled && !(inked(1) && inked(3))) continue;
let L = Infinity, T = Infinity, Rr = -Infinity, B = -Infinity, fs = parseFloat(cs.fontSize) || 16;
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
for (let t = walk.nextNode(); t; t = walk.nextNode()) {
const host = t.parentElement;
if (!(t.textContent || '').trim() || !host || host.closest('svg') || !vis(host)) continue;
let nested = false;
for (let p: Element | null = host; p && p !== el; p = p.parentElement) {
const ps = getComputedStyle(p);
if (/absolute|fixed/.test(ps.position) || (!clear(ps.backgroundColor) && ps.backgroundColor !== cs.backgroundColor) || (parseFloat(ps.borderTopWidth) >= 1 && parseFloat(ps.borderLeftWidth) >= 1)) { nested = true; break; }
}
if (nested) continue;
const rg = document.createRange(); rg.selectNodeContents(t);
const hf = parseFloat(getComputedStyle(host).fontSize) || 16; const trim = hf >= 40 ? 0.2 * hf : 0;
for (const q of rg.getClientRects()) { if (!q.width || !q.height) continue; L = Math.min(L, q.left); T = Math.min(T, q.top + trim); Rr = Math.max(Rr, q.right); B = Math.max(B, q.bottom - trim); fs = hf; }
}
if (L === Infinity) continue;
const side = Math.max(8, Math.min(0.5 * fs, 24)) - 0.5, end = Math.max(4, Math.min(0.3 * fs, 12)) - 0.5;
const inset: Array<[string, number, number]> = [['left', L - (r.left + bw[3]), side], ['right', r.right - bw[1] - Rr, side], ['top', T - (r.top + bw[0]), end], ['bottom', r.bottom - bw[2] - B, end]];
const short = inset.filter(([, v, min]) => v < min);
if (short.length) tight.push({ text: name(el) + ' ' + short.map(([k, v]) => k + ' ' + Math.round(v) + 'px').join(', '), el });
}
if (tight.length) out.push({ rule: 'cramped-padding', grade: 'fail', evidence: 'text crowds the edge of its box: ' + tight.map((t) => t.text).join('; '), fix: 'pad the box: max(8px, 0.5em) at the sides, max(4px, 0.3em) above and below', at: at(tight[0].el) });
return out;
}
// sloplint-render observeQuality, running prose set too wide (gate 25)
export function glProse(P: Probes, all: Element[]): ProbeFinding[] {
const { name, text, at } = P.H;
const out: ProbeFinding[] = [];
const long: Array<{ text: string; el: Element }> = [];
for (const el of all) {
if (long.length >= 2 || el.tagName !== 'P' || el.closest('nav, footer, table, figure, [data-slot]')) continue;
const t = text(el);
if (t.length < 240) continue;
const cs = getComputedStyle(el);
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.4;
let cols = 1;
for (let q: Element | null = el; q && q !== document.body && cols === 1; q = q.parentElement) { const qc = getComputedStyle(q); if (qc.columnCount !== 'auto' || qc.columnWidth !== 'auto') cols = Math.max(1, Math.round(q.getBoundingClientRect().width / Math.max(1, (el.getClientRects()[0] && el.getClientRects()[0].width) || q.getBoundingClientRect().width))); }
const lines = Math.round(el.getBoundingClientRect().height / lh) * cols;
if (lines >= 3 && t.length / lines > 88) long.push({ text: name(el) + ' about ' + Math.round(t.length / lines) + ' characters a line', el });
}
if (long.length) out.push({ rule: 'line-length', grade: 'fail', evidence: 'running prose set too wide: ' + long.map((l) => l.text).join(', '), fix: 'cap the measure at 45 to 75ch', at: at(long[0].el) });
return out;
}
// sloplint-render observeQuality, a field's edge under 3:1 (gate 40)
export function glFields(P: Probes, all: Element[]): ProbeFinding[] {
const { name, rgb, ratio, over, bgOf, at } = P.H;
const out: ProbeFinding[] = [];
const weak: Array<{ text: string; el: Element }> = [];
const fields = all.filter((el) => /^(TEXTAREA|SELECT)$/.test(el.tagName) || (el.tagName === 'INPUT' && /^(text|email|search|tel|url|password|number|date|time)$/.test((el as HTMLInputElement).type)));
for (const f of fields) {
const fh = f.getBoundingClientRect().height;
const around = bgOf(f.parentElement);
let best = ratio(over(rgb(getComputedStyle(f).backgroundColor), around), around);
for (let n: Element | null = f, d = 0; n && d < 3 && n.getBoundingClientRect().height <= fh + 48; n = n.parentElement, d++) {
const cs = getComputedStyle(n); const back = d ? bgOf(n.parentElement) : around;
const sides: Array<[string, string, string]> = [[cs.borderTopWidth, cs.borderTopStyle, cs.borderTopColor], [cs.borderRightWidth, cs.borderRightStyle, cs.borderRightColor], [cs.borderBottomWidth, cs.borderBottomStyle, cs.borderBottomColor], [cs.borderLeftWidth, cs.borderLeftStyle, cs.borderLeftColor]];
for (const [w, s, c] of sides) if (parseFloat(w) >= 1 && s !== 'none') best = Math.max(best, ratio(over(rgb(c), back), back));
for (const m of (cs.boxShadow || '').matchAll(/((?:rgba?|oklch|oklab|color)\([^)]*\)|#[0-9a-f]{3,8})\s+(-?[\d.]+)px\s+(-?[\d.]+)px\s+([\d.]+)px\s+(-?[\d.]+)px/gi)) if (+m[4] === 0 && (+m[5] >= 1 || Math.abs(+m[3]) >= 1)) best = Math.max(best, ratio(over(rgb(m[1]), back), back));
}
if (best < 2.95) weak.push({ text: name(f) + ' ' + best.toFixed(2) + ':1', el: f });
}
if (weak.length) out.push({ rule: 'low-contrast', grade: 'fail', evidence: 'a field\'s edge is under 3:1 against what surrounds it: ' + weak.slice(0, 3).map((w) => w.text).join(', '), fix: 'draw the field in the muted token, or fill it a step off the surface it sits on', at: at(weak[0].el) });
return out;
}
// sloplint-render observeQuality, text that strains and the weight count (gates 75 and F19)
export function glStrain(P: Probes): ProbeFinding[] {
const { vis, name, at } = P.H;
const out: ProbeFinding[] = [];
const quiet = (el: Element) => !!el.closest('script, style, noscript, [aria-hidden="true"], dialog:not([open])') || !!(el.parentElement && el.parentElement.closest('svg'));
const weights = new Map<number, number>();
const strained: Array<{ el: Element; why: string }> = [];
const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
for (let t = tw.nextNode(); t; t = tw.nextNode()) {
const host = t.parentElement; const txt = (t.textContent || '').trim();
if (!txt || !host || quiet(host) || host.closest('svg') || !vis(host)) continue;
const hs = getComputedStyle(host);
const w = Math.round((parseFloat(hs.fontWeight) || 400) / 100) * 100;
weights.set(w, (weights.get(w) || 0) + txt.length);
if (strained.length >= 4 || txt.length < 3 || host.closest('sup, sub, [data-slot], figure svg') || strained.some((x) => x.el === host)) continue;
const size = parseFloat(hs.fontSize) || 16;
const prose = !!host.closest('p, li, dd, blockquote') && txt.length > 60 && size < 28;
const lh = parseFloat(hs.lineHeight);
if (size < 11 || (prose && size < 12)) strained.push({ el: host, why: name(host) + ' at ' + Math.round(size * 10) / 10 + 'px' });
else if (prose && lh && lh / size < 1.3 && host.getBoundingClientRect().height > lh * 2.2) strained.push({ el: host, why: name(host) + ' leaded ' + (lh / size).toFixed(2) });
}
if (strained.length) out.push({ rule: 'tiny-text', grade: 'fail', evidence: 'text that strains the eye: ' + strained.map((x) => x.why).join(', '), fix: 'running text 12px or more (16px for body), functional text 11px or more, multi-line prose leaded 1.3 or more', at: at(strained[0].el) });
const used = Array.from(weights).filter(([, n]) => n >= 30).map(([w]) => w).sort((a, b) => a - b);
if (used.length > 3) out.push({ rule: 'font-weight-count', grade: 'warn', evidence: used.length + ' font weights in running use (' + used.join(', ') + ')', fix: 'three weights at most, display and body together, picked as extremes' });
return out;
}
// sloplint-render observeQuality, the lonely grid item (gate F20)
export function glGrid(P: Probes, all: Element[]): ProbeFinding[] {
const { vis, name, at } = P.H;
const out: ProbeFinding[] = [];
for (const g of all) {
const cs = getComputedStyle(g);
const grid = /grid/.test(cs.display);
const wrap = /flex/.test(cs.display) && cs.flexWrap === 'wrap' && cs.flexDirection.startsWith('row');
if ((!grid && !wrap) || g.closest('nav, footer, form')) continue;
const kids = Array.from(g.children).filter((c) => vis(c) && !/absolute|fixed/.test(getComputedStyle(c).position));
if (kids.length < 3) continue;
const rects = kids.map((c) => c.getBoundingClientRect());
if (grid && Math.max(...rects.map((r) => r.width)) > Math.min(...rects.map((r) => r.width)) * 1.15) continue;
if (wrap && !kids.every((c) => c.querySelector('img, svg') || /^(IMG|svg)$/.test(c.tagName) || c.getBoundingClientRect().width >= 120)) continue;
const rows: Array<{ top: number; n: number }> = [];
for (const r of rects) { const row = rows.find((x) => Math.abs(x.top - r.top) < 4); if (row) row.n++; else rows.push({ top: r.top, n: 1 }); }
rows.sort((a, b) => a.top - b.top);
if (rows.length >= 2 && rows[rows.length - 1].n === 1 && rows[rows.length - 2].n >= 2) { out.push({ rule: 'lonely-grid-item', grade: 'warn', evidence: name(g) + ' leaves one item alone on its last row (' + rows.map((x) => x.n).join(' + ') + ')', at: at(g) }); break; }
}
return out;
}
+415
View File
@@ -0,0 +1,415 @@
/**
* The render tier's in-page measurements of space: the hero, the word budget,
* slots and slivers, drawn illustrations, empty pictures, counting marks, the
* photo wall, the dead band, thin sections and the 1920 checks. Same rules as
* render-probe.ts: each function is serialised into the page and may touch
* nothing outside its arguments.
*
* Ported from Nutlope/hallmark's sloplint-render.mjs observeSpace (MIT, see
* NOTICE.md): gates 44a, 44c, 44d, 58, 77, 78, 79, 80, 81, 82, 83, F17, F18.
* The posture, hero-shape, photo-shape and job guards are gone: the strict
* form applies.
*/
import type { ProbeFinding, Probes } from './render-probe';
export interface Box { top: number; bottom: number; left: number; right: number }
export interface Fig extends Box { w: number; h: number; el: Element; side: 'left' | 'right'; colEdge: number }
export interface HeroCtx {
secs: Element[];
hero: Element | null;
h1: HTMLElement | null;
heroEnd: number | null;
/** Text blocks in the hero before its first action. */
stack: number;
textBox: Box | null;
fig: Fig | null;
/** The painted extent of a section's content, in page coordinates. */
content: (s: Element) => { top: number; bottom: number };
}
// sloplint-render observeSpace, the hero: its sections, text block, visual and content extents
export function glHero(P: Probes): HeroCtx {
const { vis, abs, text } = P.H;
const vw = innerWidth;
let secs = Array.from(document.querySelectorAll('section, main > div, footer')).filter((s) => vis(s) && s.getBoundingClientRect().height > 40);
secs = secs.filter((s) => !secs.some((o) => o !== s && o.contains(s)));
const h1 = document.querySelector('h1');
const heroish = (el: Element) => /^(SECTION|HEADER)$/.test(el.tagName) || Array.from(el.classList).some((c) => /^hero(-[\w-]+)?$/i.test(c) || /(^|-)hero$/i.test(c));
let hero: Element | null = null;
for (let n: Element | null = h1; n && n !== document.body; n = n.parentElement) if (heroish(n)) { hero = n; break; }
hero = hero || Array.from(document.querySelectorAll('section, header, [class]')).find(heroish) || secs[0] || null;
const heroEnd = hero ? abs(hero).bottom : null;
let stack = 0, textBox: Box | null = null;
const best: { fig: Fig | null; area: number } = { fig: null, area: 0 };
if (hero && h1) {
const cta = Array.from(hero.querySelectorAll('a, button')).filter((c) => vis(c) && (c.textContent || '').trim() && !c.closest('nav'))[0];
const ctaTop = cta ? abs(cta).top : Infinity;
const texts = Array.from(hero.querySelectorAll('h1, h2, p, small, [class*="sub"], [class*="lede"], [class*="eyebrow"], [class*="kicker"], [class*="label"]')).filter((e) => vis(e) && text(e) && !e.closest('nav, svg, table, figure, [data-slot], form, fieldset, label, output, [role="tabpanel"], [role="group"]') && abs(e).top < ctaTop);
const seen: Element[] = [];
for (const e of texts) { if (seen.some((o) => o.contains(e) || e.contains(o))) continue; seen.push(e); }
stack = seen.length;
const ink = (el: Element): Box => {
let L = Infinity, T = Infinity, R = -Infinity, B = -Infinity;
const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
for (let t = w.nextNode(); t; t = w.nextNode()) { if (!(t.textContent || '').trim() || t.parentElement!.closest('svg')) continue; const rg = document.createRange(); rg.selectNodeContents(t); for (const q of rg.getClientRects()) { if (!q.width || !q.height) continue; L = Math.min(L, q.left); T = Math.min(T, q.top); R = Math.max(R, q.right); B = Math.max(B, q.bottom); } }
return L === Infinity ? abs(el) : { top: T + scrollY, bottom: B + scrollY, left: L, right: R };
};
const boxes = [h1, ...seen.filter((e) => Math.abs(abs(e).left - abs(h1).left) < 40)].map(ink);
const tb: Box = { top: Math.min(...boxes.map((b) => b.top)), bottom: Math.max(...boxes.map((b) => b.bottom), cta ? abs(cta).bottom : 0), left: Math.min(...boxes.map((b) => b.left)), right: Math.max(...boxes.map((b) => b.right)) };
textBox = tb;
const clipped = (el: Element) => { const r = el.getBoundingClientRect(); let L = r.left, T = r.top, R = r.right, B = r.bottom; for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { const cs = getComputedStyle(p); if (/hidden|clip/.test(cs.overflow + ' ' + cs.overflowX + ' ' + cs.overflowY)) { const q = p.getBoundingClientRect(); L = Math.max(L, q.left); T = Math.max(T, q.top); R = Math.min(R, q.right); B = Math.min(B, q.bottom); } } R = Math.min(R, vw); return { top: T + scrollY, bottom: B + scrollY, left: L, right: R, w: Math.max(0, R - L), h: Math.max(0, B - T) }; };
const beside = (b: Box) => b.left >= tb.right - 40 || b.right <= tb.left + 40;
const consider = (e: Element) => {
if (!vis(e) || e.closest('nav') || e.contains(h1) || seen.some((t) => e.contains(t))) return;
const b = clipped(e); const a = b.w * b.h;
if (b.w > 160 && b.h > 120 && a > best.area && b.w < vw * 0.95 && beside(b)) { best.fig = { ...b, el: e, side: 'right', colEdge: 0 }; best.area = a; }
};
for (const e of hero.querySelectorAll('svg, canvas, img, figure, video, picture, [data-slot], [class*="fig"], [class*="visual"], [class*="device"], [class*="demo"], [class*="shot"], [class*="illus"], [class*="panel"]')) consider(e);
if (!best.fig) {
const heroBg = getComputedStyle(hero).backgroundColor;
for (const e of hero.querySelectorAll('div, span, aside, i, b')) {
const cs = getComputedStyle(e);
if (cs.backgroundImage !== 'none' || (cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== heroBg) || parseFloat(cs.borderTopWidth) > 0) consider(e);
}
}
const f = best.fig;
if (f) {
f.side = f.left >= tb.right - 40 ? 'right' : 'left';
let lca = h1.parentElement;
while (lca && !lca.contains(f.el)) lca = lca.parentElement;
let col: Element = h1;
while (col.parentElement && col.parentElement !== lca) col = col.parentElement;
const cb = abs(col);
f.colEdge = f.side === 'right' ? Math.max(tb.right, cb.right) : Math.min(tb.left, cb.left);
}
}
const clipAbs = (el: Element, stop: Element) => { const r0 = el.getBoundingClientRect(); let T = r0.top, B = r0.bottom; for (let q = el.parentElement; q && q !== document.body; q = q.parentElement) { const cs = getComputedStyle(q); if (/hidden|clip/.test(cs.overflow + ' ' + cs.overflowY)) { const qr = q.getBoundingClientRect(); T = Math.max(T, qr.top); B = Math.min(B, qr.bottom); } if (q === stop) break; } return { top: T + scrollY, bottom: B + scrollY }; };
const content = (s: Element) => {
let top = Infinity, bottom = -Infinity;
for (const c of s.querySelectorAll('*')) {
if (!vis(c)) continue;
const painted = text(c) || /^(SVG|IMG|CANVAS|VIDEO)$/i.test(c.tagName) || getComputedStyle(c).backgroundColor !== 'rgba(0, 0, 0, 0)';
if (!painted) continue;
if (c.children.length && !Array.from(c.childNodes).some((n) => n.nodeType === 3 && (n.textContent || '').trim()) && !/^svg$/i.test(c.tagName) && getComputedStyle(c).backgroundColor === 'rgba(0, 0, 0, 0)') continue;
const b = clipAbs(c, s);
if (b.bottom <= b.top) continue;
top = Math.min(top, b.top); bottom = Math.max(bottom, b.bottom);
}
return top === Infinity ? abs(s) : { top, bottom };
};
return { secs, hero, h1, heroEnd, stack, textBox, fig: best.fig, content };
}
// sloplint-render observeSpace at 1440 and 1920: the space checks in one eval
export function glSpace(P: Probes, atLaptop: boolean): ProbeFinding[] {
const ctx = P.hero(P);
if (!atLaptop) return P.wide(P, ctx);
return [...P.words(P, ctx), ...P.slots(P), ...P.pictures(P), ...P.marks(P), ...P.photos(P, ctx), ...P.heroSpace(P, ctx), ...P.thin(P, ctx)];
}
// sloplint-render observeSpace, the word budget (gate 58)
export function glWords(P: Probes, ctx: HeroCtx): ProbeFinding[] {
const { vis } = P.H;
const out: ProbeFinding[] = [];
const words: Array<{ top: number; bottom: number; c: number }> = [];
const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
for (let n = tw.nextNode(); n; n = tw.nextNode()) {
const p = n.parentElement; if (!p || !vis(p) || p.closest('script, style, noscript, svg, [data-slot], [data-credits]')) continue;
const t = (n.textContent || '').trim(); if (!t) continue;
const pr = p.getBoundingClientRect(); const c = t.split(/\s+/).filter((w) => /[A-Za-z]{2,}/.test(w)).length;
if (c) words.push({ top: pr.top + scrollY, bottom: pr.bottom + scrollY, c });
}
const H = document.documentElement.scrollHeight;
let maxWords = 0, at = 0;
for (let y = 0; y < H; y += 50) {
const w = Math.round(words.reduce((a, x) => { const h = Math.max(1, x.bottom - x.top); const ov = Math.max(0, Math.min(x.bottom, y + 900) - Math.max(x.top, y)); return a + x.c * Math.min(1, ov / h); }, 0));
if (w > maxWords) { maxWords = w; at = y; }
}
if (maxWords > 95) out.push({ rule: 'screen-word-budget', grade: 'fail', evidence: maxWords + ' words on one 900px screen (from y=' + at + ')', fix: 'one idea per screen: cut to about 80 words and make the section an object' });
else if (maxWords > 80) out.push({ rule: 'screen-word-budget', grade: 'warn', evidence: maxWords + ' words on one 900px screen (from y=' + at + '), close to the 80-word budget' });
if (ctx.stack > 2) out.push({ rule: 'screen-word-budget', grade: 'fail', evidence: 'the hero carries ' + ctx.stack + ' text blocks before its actions', fix: 'headline, one sentence, actions; delete the rest', at: ctx.h1 ? P.H.at(ctx.h1) : undefined });
return out;
}
// sloplint-render observeSpace, slots and slivers at 1440 (gates F18 and F17)
export function glSlots(P: Probes): ProbeFinding[] {
const { vis, at, text, name } = P.H;
const out: ProbeFinding[] = [];
for (const sl of document.querySelectorAll('[data-slot]')) {
const cs = getComputedStyle(sl);
if (cs.display === 'none' || cs.visibility === 'hidden' || !sl.getClientRects().length || sl.closest('[hidden]')) continue;
const r = sl.getBoundingClientRect();
if (r.width < 40 || r.height < 40) { out.push({ rule: 'tiny-slot', grade: 'warn', evidence: 'slot "' + sl.getAttribute('data-slot') + '" renders at ' + Math.round(r.width) + ' x ' + Math.round(r.height) + 'px', fix: 'the slot collapsed: give it a width from its track and a real aspect-ratio, never a percentage of a shrink-to-fit parent', at: at(sl) }); break; }
}
for (const e of document.querySelectorAll('p, li, dd, td, th, h2, h3, h4, figcaption, blockquote')) {
if (!vis(e) || e.closest('nav, svg, [aria-hidden="true"]')) continue;
const t = text(e);
if (t.length < 24) continue;
const cs = getComputedStyle(e);
const fs = parseFloat(cs.fontSize) || 16;
const lh = parseFloat(cs.lineHeight) || fs * 1.4;
const r = e.getBoundingClientRect();
if (r.width < fs * 8 && r.height / lh >= 3.6) { out.push({ rule: 'text-sliver', grade: 'warn', evidence: '"' + t.slice(0, 28) + '" squeezed to ' + Math.round(r.width) + 'px wide over ' + Math.round(r.height / lh) + ' lines (' + name(e) + ')', fix: 'a grid placement or a span ran wide; give the text its column back', at: at(e) }); break; }
}
return out;
}
// sloplint-render observeSpace, drawn illustrations and empty pictures (gates 80 and 81)
export function glPictures(P: Probes): ProbeFinding[] {
const { vis, at, text } = P.H;
const out: ProbeFinding[] = [];
const vw = innerWidth;
const nameOf = (e: Element) => e.tagName.toLowerCase() + (e.getAttribute('data-slot') ? '[data-slot=' + e.getAttribute('data-slot') + ']' : e.classList[0] ? '.' + e.classList[0] : '');
const dataish = /chart|graph|plot|spark|map|diagram|logo|wordmark|brand|icon|flag|qr|avatar/i;
const drawn: Array<{ text: string; el: Element }> = [];
for (const svg of document.querySelectorAll('svg')) {
if (!vis(svg) || !svg.parentElement || svg.parentElement.closest('svg')) continue;
const r = svg.getBoundingClientRect();
if (r.width < 120 || r.height < 120) continue;
let named = false;
for (let q: Element | null = svg; q && q !== document.body && !named; q = q.parentElement) named = dataish.test((q.getAttribute('class') || '') + ' ' + (q.id || '') + ' ' + (q.getAttribute('aria-label') || '') + ' ' + (q.getAttribute('data-kind') || ''));
if (named || svg.querySelector('text')) continue;
const shapes = Array.from(svg.querySelectorAll('path, ellipse, circle, polygon'));
const curvy = shapes.filter((s) => s.tagName === 'ellipse' || (s.tagName === 'circle' && parseFloat(s.getAttribute('r') || '0') > 6) || /[CcQqSsAa]/.test(s.getAttribute('d') || '')).length;
const fills = new Set(shapes.map((s) => getComputedStyle(s).fill).filter((f) => f && f !== 'none'));
if (curvy >= 3 || (shapes.length >= 6 && fills.size >= 3)) drawn.push({ text: nameOf(svg.parentElement || svg) + ' (' + Math.round(r.width) + 'x' + Math.round(r.height) + ', ' + shapes.length + ' shapes)', el: svg });
}
if (drawn.length) out.push({ rule: 'hand-drawn-svg', grade: 'fail', evidence: 'drawn illustration: ' + drawn.slice(0, 3).map((d) => d.text).join('; '), fix: 'a photograph, the product built, or type at scale; SVG is for icons, logos and labelled charts or diagrams', at: at(drawn[0].el) });
const empty: Array<{ text: string; el: Element }> = [];
const shows = (box: Element) => {
const b = box.getBoundingClientRect();
let area = 0;
for (const m of box.querySelectorAll('img, video, picture, canvas, iframe, svg')) {
if (!vis(m) || (m.tagName.toLowerCase() === 'svg' && m.parentElement && m.parentElement.closest('svg'))) continue;
if (m.tagName === 'IMG' && !(m.getAttribute('src') || m.getAttribute('srcset') || '').trim()) continue;
const q = m.getBoundingClientRect(); area += q.width * q.height;
if (area >= b.width * b.height * 0.25) return true;
}
const bits = Array.from(box.querySelectorAll('*')).filter((e) => vis(e) && (text(e) || /^(INPUT|SELECT|TEXTAREA|BUTTON)$/.test(e.tagName)) && !e.children.length);
return bits.length >= 4 || text(box).length >= 80 || box.matches('[data-kind="product"]') || !!box.closest('[data-kind="product"]');
};
for (const sl of document.querySelectorAll('[data-slot]')) { if (!vis(sl) || sl.closest('[hidden]')) continue; const r = sl.getBoundingClientRect(); if (r.width < 160 || r.height < 120) continue; if (!shows(sl)) empty.push({ text: nameOf(sl) + ' ' + Math.round(r.width) + 'x' + Math.round(r.height), el: sl }); }
for (const e of document.querySelectorAll('div, figure, span, i, aside')) {
if (e.hasAttribute('data-slot') || e.closest('[data-slot]') || !vis(e)) continue;
const r = e.getBoundingClientRect(); const cs = getComputedStyle(e);
if (r.width < 300 || r.height < 200 || r.width > vw * 0.9 || !/gradient/.test(cs.backgroundImage) || /url\(/.test(cs.backgroundImage)) continue;
if (text(e).length > 60 || e.querySelector('img, video, canvas, svg, picture, input, table')) continue;
if (!shows(e)) empty.push({ text: nameOf(e) + ' ' + Math.round(r.width) + 'x' + Math.round(r.height) + ', a gradient with nothing in it', el: e });
}
if (empty.length) out.push({ rule: 'empty-picture', grade: 'fail', evidence: empty.length + ' empty picture' + (empty.length > 1 ? 's' : '') + ': ' + empty.slice(0, 3).map((d) => d.text).join('; '), fix: 'put the picture in: the user\'s image, a credited photograph or the product at full fidelity; the wash is only a loading colour', at: at(empty[0].el) });
return out;
}
// sloplint-render observeSpace, the counting device (gate 82)
export function glMarks(P: Probes): ProbeFinding[] {
const { vis, at } = P.H;
const out: ProbeFinding[] = [];
const nameOf = (e: Element) => e.tagName.toLowerCase() + (e.getAttribute('data-slot') ? '[data-slot=' + e.getAttribute('data-slot') + ']' : e.classList[0] ? '.' + e.classList[0] : '');
const marks: Array<{ el: Element; text: string }> = [];
const colourOf = (e: Element) => { const cs = getComputedStyle(e); return e instanceof SVGElement ? cs.fill + '|' + cs.stroke : cs.backgroundColor + '|' + cs.borderTopColor + '|' + cs.color; };
for (const p of document.querySelectorAll('*')) {
if (p.children.length < 24 || !vis(p)) continue;
const kids = Array.from(p.children).filter((k) => !/^(defs|title|desc|style|script)$/i.test(k.tagName));
if (kids.length < 24) continue;
const small = kids.filter((k) => { const q = k.getBoundingClientRect(); return !(k.textContent || '').trim() && q.width > 0 && q.height > 0 && ((q.width <= 24 && q.height <= 48) || q.width <= 6 || q.height <= 6); });
if (small.length < 24 || small.length < kids.length * 0.8) continue;
const tally = new Map<string, number>(); for (const k of small) { const c = colourOf(k); tally.set(c, (tally.get(c) || 0) + 1); }
if (Math.max(...tally.values()) < small.length * 0.6 || tally.size > 4) continue;
const sizes = small.map((k) => { const q = k.getBoundingClientRect(); return Math.round(q.width) * 1000 + Math.round(q.height); });
const common = Math.max(...Array.from(new Set(sizes)).map((s) => sizes.filter((x) => x === s).length));
if (common < small.length * 0.7) continue;
if (marks.some((m) => m.el.contains(p) || p.contains(m.el))) continue;
marks.push({ el: p, text: nameOf(p) + ' holds ' + small.length + ' identical marks' });
}
const ONE_FOR_EACH = /\bone (?:mark|dot|stroke|tick|line|square|bar|cell|pin|block|circle|seed|bead|stripe|notch)s? (?:for|per) (?:each|every)\b/i;
const ONE_FOR_EACH_BARE = /\bone (?:for|per) (?:each|every)\b/i;
for (const e of document.querySelectorAll('[aria-label], figcaption, svg title, p, small, span')) {
if (marks.length >= 2) break;
const t = (e.getAttribute('aria-label') || '') + ' ' + ((e.textContent || '').length < 160 ? e.textContent : '');
const counting = ONE_FOR_EACH.test(t) || (ONE_FOR_EACH_BARE.test(t) && (e.matches('figcaption') || (e.getAttribute('aria-label') && e.closest('svg, figure, [role="img"]'))));
if (counting && !marks.some((m) => m.el.contains(e))) marks.push({ el: e, text: '"' + t.trim().slice(0, 60) + '"' });
}
if (marks.length) out.push({ rule: 'counting-marks', grade: 'fail', evidence: 'counting device: ' + marks.slice(0, 2).map((m) => m.text).join('; '), fix: 'set the number in type at the size it deserves; never one mark for each thing', at: at(marks[0].el) });
return out;
}
// sloplint-render observeSpace, the photo wall (gate 83): the strict third, no photographic shape
export function glPhotos(P: Probes, ctx: HeroCtx): ProbeFinding[] {
const { vis, abs } = P.H;
const out: ProbeFinding[] = [];
if (ctx.heroEnd == null) return out;
const vw = innerWidth, vh = innerHeight;
const foot = Array.from(document.querySelectorAll('body footer, [role="contentinfo"]')).filter(vis).pop();
const docH = foot ? Math.min(document.documentElement.scrollHeight, abs(foot).top) : document.documentElement.scrollHeight, from = Math.max(ctx.heroEnd, vh);
const box = document.querySelector('.container') || document.body;
const cw = Math.min(box.getBoundingClientRect().width || vw, vw);
let area = 0, n = 0;
const seenA: Element[] = [];
let first: Element | null = null;
const add = (el: Element) => {
if (!vis(el) || el.closest('footer') || seenA.some((o) => o.contains(el))) return;
const b = abs(el); const top = Math.max(b.top, from), bot = Math.min(b.bottom, docH);
if (bot - top < 80 || b.w < 160) return;
seenA.push(el); area += (bot - top) * Math.min(b.w, cw); n++;
first = first || el;
};
for (const m of document.querySelectorAll('img, video')) {
if (m.closest('[data-kind="product"], [data-product]')) continue;
const nw = (m as HTMLImageElement).naturalWidth || (m as HTMLVideoElement).videoWidth || 0;
if (m.tagName === 'IMG' && /\.svg(\?|$)/i.test((m as HTMLImageElement).currentSrc || (m as HTMLImageElement).src || '')) continue;
if (m.tagName === 'IMG' && nw && nw < 200) continue;
add(m);
}
for (const e of document.querySelectorAll('section *, main *')) { const bi = getComputedStyle(e).backgroundImage; if (/url\(/.test(bi) && !/svg|data:image\/svg/i.test(bi)) add(e); }
const share = area / Math.max(1, (docH - from) * cw);
if (share > 0.36 && n >= 3) out.push({ rule: 'photo-wall', grade: 'fail', evidence: 'photographs cover ' + Math.round(share * 100) + '% of the page below the hero (' + n + ' pictures)', fix: 'cut or shrink pictures until they cover a quarter to a third of the page, and carry the freed sections in type, figures, the product or a list', at: first ? P.H.at(first) : undefined });
return out;
}
// sloplint-render observeSpace, the hero's balance and its dead band (gates 44a, 44c, 79)
export function glHeroSpace(P: Probes, ctx: HeroCtx): ProbeFinding[] {
const { vis, abs, text, at } = P.H;
const out: ProbeFinding[] = [];
const { hero, secs, heroEnd, textBox, fig } = ctx;
const vw = innerWidth, vh = innerHeight;
const heroAt = hero ? at(hero) : undefined;
let heroFoot: { cBottom: number; navBottom: number } | null = null;
if (hero) {
const hb = abs(hero);
let cTop = Infinity, cBottom = -Infinity;
for (const c of hero.querySelectorAll('*')) {
const nv = c.closest('nav');
if (!vis(c) || (nv && abs(nv).top <= hb.top + 220) || c === hero) continue;
const cs = getComputedStyle(c);
const painted = text(c) || /^(SVG|IMG|CANVAS|VIDEO|PICTURE)$/i.test(c.tagName) || cs.backgroundColor !== 'rgba(0, 0, 0, 0)' || cs.backgroundImage !== 'none' || parseFloat(cs.borderTopWidth) > 0;
if (!painted || /fixed|sticky/.test(cs.position)) continue;
const b = abs(c); cTop = Math.min(cTop, b.top); cBottom = Math.max(cBottom, Math.min(b.bottom, hb.bottom));
}
let navBottom = hb.top;
for (const n of document.querySelectorAll('header, nav, [class*="nav"], [class*="bar"]')) {
if (!vis(n) || (hero.contains(n) && !/fixed|sticky/.test(getComputedStyle(n).position))) continue;
const cs = getComputedStyle(n); const b = abs(n);
if ((/fixed|sticky/.test(cs.position) || hero.contains(n)) && b.top <= hb.top + 40 && b.bottom < hb.top + 220) navBottom = Math.max(navBottom, b.bottom);
}
const after = secs.find((x) => x !== hero && !hero.contains(x) && abs(x).top >= hb.bottom - 4);
const bottomEdge = after ? Math.min(ctx.content(after).top, hb.bottom + 240) : hb.bottom;
const topPad = cTop - navBottom, bottomPad = bottomEdge - cBottom;
if (isFinite(topPad) && isFinite(bottomPad) && topPad > 24 && bottomPad < topPad - 8) out.push({ rule: 'hero-balance', grade: 'fail', evidence: 'the hero content sits low: ' + Math.round(topPad) + 'px above it, ' + Math.round(bottomPad) + 'px below', fix: 'centre the content in its screen: the space below 1.0 to 1.5 times the space above', at: heroAt });
heroFoot = { cBottom, navBottom };
}
if (heroEnd != null) {
if (heroEnd > vh + 8) out.push({ rule: 'hero-balance', grade: 'fail', evidence: 'the hero runs past the 1440x900 fold (it ends at ' + Math.round(heroEnd) + 'px)', fix: 'size the display and the visual so the hero is one screen', at: heroAt });
else if (heroEnd < vh * 0.84 && secs.length > 1) out.push({ rule: 'hero-balance', grade: 'fail', evidence: 'the hero ends at ' + Math.round(heroEnd) + 'px of a 900px screen', fix: 'fill the first screen with the visual or the headline, not with copy', at: heroAt });
}
if (fig && textBox) {
const off = Math.round((fig.top + fig.bottom) / 2 - (textBox.top + textBox.bottom) / 2);
const gap = Math.round(fig.side === 'right' ? fig.left - fig.colEdge : fig.colEdge - fig.right);
const edge = Math.round(fig.side === 'right' ? vw - fig.right : fig.left);
const figAt = at(fig.el);
if (Math.abs(off) > 60) out.push({ rule: 'hero-balance', grade: 'fail', evidence: 'the hero visual\'s centre is ' + off + 'px from the text block\'s centre', fix: 'align the visual beside the text', at: figAt });
const inkGap = Math.round(fig.side === 'right' ? fig.left - textBox.right : textBox.left - fig.right);
if (inkGap > 240 && gap <= 120) out.push({ rule: 'hero-balance', grade: 'fail', evidence: 'the hero text ends ' + inkGap + 'px short of its visual', fix: 'widen the text column or bring the visual in: text and visual stand 48 to 120px apart', at: figAt });
if (gap > 120 || (edge < 8 && gap > 48)) out.push({ rule: 'hero-balance', grade: 'fail', evidence: 'the hero visual sits ' + gap + 'px from the text and ' + edge + 'px from the viewport edge', fix: 'bring the visual to within 48 to 120px of the text; never pin it to the corner', at: figAt });
}
if (heroFoot && hero && isFinite(heroFoot.cBottom)) {
const hb = abs(hero);
const after = secs.find((x) => x !== hero && !hero.contains(x) && abs(x).top >= hb.bottom - 4);
const nextTop = after ? ctx.content(after).top : Infinity;
const band = Math.round(Math.min(vh, nextTop) - heroFoot.cBottom);
if (heroFoot.cBottom < vh && band > 160) out.push({ rule: 'dead-band', grade: 'fail', evidence: band + 'px of nothing between the hero\'s last content (y ' + Math.round(heroFoot.cBottom) + ') and ' + (nextTop < vh ? 'the next section' : 'the fold'), at: heroAt });
}
return out;
}
// sloplint-render observeSpace, section heads and thin sections below the hero (gates 78 and 77)
export function glThin(P: Probes, ctx: HeroCtx): ProbeFinding[] {
const { vis, abs, text, at } = P.H;
const out: ProbeFinding[] = [];
const { hero, secs, h1 } = ctx;
const vw = innerWidth;
const below = secs.filter((x) => x !== hero && !(hero && (hero.contains(x) || x.contains(hero))) && x.tagName !== 'FOOTER' && !x.closest('footer, nav') && (!hero || abs(x).top >= abs(hero).bottom - 4));
const leavesOf = (root: Element): Box[] => {
const leaves: Box[] = [];
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
for (let t = w.nextNode(); t; t = w.nextNode()) { if (!(t.textContent || '').trim()) continue; const p = t.parentElement; if (!p || !vis(p) || p.closest('script, style, noscript')) continue; const rg = document.createRange(); rg.selectNodeContents(t); for (const q of rg.getClientRects()) if (q.width > 1 && q.height > 1) leaves.push({ top: q.top + scrollY, bottom: q.bottom + scrollY, left: q.left, right: q.right }); }
for (const e of root.querySelectorAll('*')) {
if (!vis(e) || (e.closest('svg') && e.tagName.toLowerCase() !== 'svg')) continue;
const cs = getComputedStyle(e);
const border = (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.borderBottomWidth) || 0) + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.borderRightWidth) || 0);
const painted = /^(IMG|SVG|CANVAS|VIDEO|PICTURE|IFRAME|INPUT|TEXTAREA|SELECT|TABLE)$/i.test(e.tagName) || e.hasAttribute('data-slot') || cs.backgroundImage !== 'none' || cs.backgroundColor !== 'rgba(0, 0, 0, 0)' || border >= 2;
if (!painted) continue;
const b = e.getBoundingClientRect(); if (b.width * b.height < 64 || (b.width > vw * 0.97 && !/^(IMG|PICTURE|VIDEO|CANVAS|SVG)$/i.test(e.tagName) && !e.hasAttribute('data-slot'))) continue;
leaves.push({ top: b.top + scrollY, bottom: b.bottom + scrollY, left: b.left, right: b.right });
}
return leaves;
};
const span = (ls: Box[]) => ls.filter((b) => b.right > 0 && b.left < vw).reduce((a, b) => ({ left: Math.min(a.left, Math.max(0, b.left)), right: Math.max(a.right, Math.min(vw, b.right)) }), { left: Infinity, right: -Infinity });
const all = span([...(hero ? [hero] : []), ...below].flatMap(leavesOf));
const W = all.right - all.left;
const h1fs = h1 ? parseFloat(getComputedStyle(h1).fontSize) : 0;
const heads = below.flatMap((x) => Array.from(x.querySelectorAll('h2'))).filter((h) => vis(h) && !h.closest('nav, footer'));
if (heads.length) {
const sizes = heads.map((h) => parseFloat(getComputedStyle(h).fontSize)).sort((a, b) => a - b);
const med = sizes[Math.floor(sizes.length / 2)];
if (med < 40) out.push({ rule: 'small-section-heads', grade: 'fail', evidence: 'section heads render at ' + Math.round(med) + 'px' + (h1fs ? ' under a ' + Math.round(h1fs) + 'px hero head' : ''), fix: 'h2 at the section size (about 52px at 1440), item titles at the title size', at: at(heads[0]) });
}
const thin: Array<{ text: string; el: Element }> = [];
let bands = 0;
if (W > 400) for (const sec of below) {
const ls = leavesOf(sec);
if (!ls.length) continue;
const u = span(ls);
const wPct = (Math.min(u.right, all.right) - Math.max(u.left, all.left)) / W;
const words = text(sec).split(/\s+/).filter((x) => /[A-Za-z0-9]/.test(x)).length;
let mass = false, statement = false;
const secBg = getComputedStyle(sec).backgroundColor;
for (const e of sec.querySelectorAll('img, svg, canvas, video, picture, figure, iframe, [data-slot]')) { if (!vis(e) || (e.tagName.toLowerCase() === 'svg' && e.closest('[data-slot], figure') && e.closest('[data-slot], figure') !== e)) continue; if (e.getBoundingClientRect().height >= 300) { mass = true; break; } }
if (!mass) for (const e of sec.querySelectorAll('div, article, aside, ul, ol, dl, table, form, a')) {
if (!vis(e)) continue;
const cs = getComputedStyle(e); const r = e.getBoundingClientRect();
const border = (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.borderBottomWidth) || 0) + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.borderRightWidth) || 0);
const filled = (cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== secBg) || cs.backgroundImage !== 'none' || border >= 2;
if (filled && r.height >= 240 && r.width >= W * 0.35 && r.width < vw * 0.97) { mass = true; break; }
}
if (!mass) for (const t of sec.querySelectorAll('table, ul, ol, dl, div, [class]')) {
if (!vis(t)) continue;
if (t.tagName === 'TABLE') { const r = t.getBoundingClientRect(); if (t.querySelectorAll('tr').length >= 4 && r.width >= W * 0.6) { mass = true; break; } continue; }
const kids = Array.from(t.children).filter((k) => vis(k) && k.getBoundingClientRect().height > 20);
if (kids.length < 3) continue;
const rs = kids.map((k) => k.getBoundingClientRect());
const across = rs.every((r) => Math.abs(r.top - rs[0].top) < 12) && new Set(rs.map((r) => Math.round(r.left))).size === rs.length;
const spread = Math.max(...rs.map((r) => r.right)) - Math.min(...rs.map((r) => r.left));
const tall = Math.max(...rs.map((r) => r.bottom)) - Math.min(...rs.map((r) => r.top));
if ((across && spread >= W * 0.7) || (!across && spread >= W * 0.4 && tall >= 120)) { mass = true; break; }
}
if (!mass && Array.from(sec.querySelectorAll('input:not([type=hidden]), select, textarea')).filter(vis).length >= 2) mass = true;
for (const e of sec.querySelectorAll('h2, h3, p, blockquote, h2 span, p span, div, output, [contenteditable]')) {
if (!vis(e) || (/^(DIV|OUTPUT)$/.test(e.tagName) && !Array.from(e.childNodes).some((n) => n.nodeType === 3 && (n.textContent || '').trim()))) continue;
const cs = getComputedStyle(e); const fs = parseFloat(cs.fontSize); const lh = parseFloat(cs.lineHeight) || fs * 1.15; const r = e.getBoundingClientRect(); const n = text(e).split(/\s+/).filter(Boolean).length;
if ((fs >= 40 && n <= 40 && r.height >= lh * 1.8 && r.width >= W * 0.5) || fs >= 72) { statement = true; break; }
}
if (statement) mass = true;
const painted = getComputedStyle(sec).backgroundColor !== 'rgba(0, 0, 0, 0)' || getComputedStyle(sec).backgroundImage !== 'none';
if (!mass && painted && words <= 40 && sec.querySelector('a, button, form') && !bands) { bands++; continue; }
if ((wPct < 0.72 && !statement) || !mass) {
const head = sec.querySelector('h2, h3');
thin.push({ text: (head && text(head) ? '"' + text(head).slice(0, 28) + '"' : sec.tagName.toLowerCase() + (sec.classList[0] ? '.' + sec.classList[0] : '')) + ' (' + Math.round(wPct * 100) + '% wide' + (mass ? '' : ', no mass') + ')', el: sec });
}
}
if (thin.length) out.push({ rule: 'thin-section', grade: 'fail', evidence: thin.length + ' thin section' + (thin.length > 1 ? 's' : '') + ': ' + thin.slice(0, 3).map((t) => t.text).join('; '), fix: 'widen the body to ten of twelve columns and give it mass: a visual 300px tall or more, a set of three across, rows, or a statement', at: at(thin[0].el) });
return out;
}
// sloplint-render observeSpace at 1920 (gate 44d)
export function glWide(P: Probes, ctx: HeroCtx): ProbeFinding[] {
const { vis, text, at } = P.H;
const out: ProbeFinding[] = [];
const vh = innerHeight;
if (ctx.textBox && ctx.textBox.left < 120) out.push({ rule: 'wide-container', grade: 'fail', evidence: 'the hero text starts ' + Math.round(ctx.textBox.left) + 'px from the edge at 1920', fix: 'cap the container at 1360 to 1520px and centre it', at: ctx.h1 ? at(ctx.h1) : undefined });
if (ctx.heroEnd != null && ctx.heroEnd > vh + 8) out.push({ rule: 'wide-container', grade: 'fail', evidence: 'the hero runs past the 1920x1080 screen (it ends at ' + Math.round(ctx.heroEnd) + 'px)', fix: 'cap vw-sized visuals and display sizes', at: ctx.hero ? at(ctx.hero) : undefined });
const inkWidth = (e: Element) => { const range = document.createRange(); range.selectNodeContents(e); return range.getBoundingClientRect().width; };
const wide = Array.from(document.querySelectorAll('p, h1, h2, h3, li')).filter((e) => vis(e) && !e.querySelector('p, h1, h2, h3, h4, div, figure, ul, ol, table, section') && text(e).length > 40 && inkWidth(e) > 1600);
if (wide.length) out.push({ rule: 'wide-container', grade: 'fail', evidence: wide.length + ' text block' + (wide.length > 1 ? 's run' : ' runs') + ' wider than 1600px at 1920 (first: ' + wide[0].tagName.toLowerCase() + ' "' + text(wide[0]).slice(0, 28) + '")', fix: 'cap the container; hold the measure', at: at(wide[0]) });
return out;
}
+485
View File
@@ -0,0 +1,485 @@
/**
* The render tier's in-page measurements. Each function here runs inside the
* page: it is serialised with Function.prototype.toString, installed once on
* window.__gstackLint by the first eval step, and called by later steps at
* the viewport they need. Nothing here may touch module scope: a function
* sees only its arguments, the helpers object and the DOM.
*
* Ported from Nutlope/hallmark's sloplint-render.mjs (MIT, see NOTICE.md):
* observe (gates 34, 44b, 49a, 49b, 40, 41, 23, F18 and 72 at 375),
* focusRing (26a), observeOcclusion (73) and pixelBehind's preparation. The
* space and quality measurements are in render-probe-space.ts and
* render-probe-quality.ts.
*/
import { glHero, glHeroSpace, glMarks, glPhotos, glPictures, glSlots, glSpace, glThin, glWide, glWords } from './render-probe-space';
import { glFields, glGrid, glPadding, glProse, glQuality, glRhythm, glStrain } from './render-probe-quality';
export interface ProbeAt { tag: string; cls: string; text: string; /** A distinguishing attribute in the open tag, like data-slot="hero" or id="main". */ attr?: string }
export interface ProbeFinding {
rule: string;
/** fail: the tell is measured; warn: suspected (a blocking rule lands at ask). */
grade: 'fail' | 'warn';
evidence: string;
fix?: string;
at?: ProbeAt;
/** A contrast claim to confirm against painted pixels: the element's path, its ink and its threshold. */
probe?: { path: string; fg: number[]; threshold: number };
}
export interface RingProbe {
key: string;
path: string;
at: ProbeAt;
bare: boolean;
ratio: number | null;
ring?: number[];
pad?: number;
}
export interface SetupReport { lost: string[] }
export interface PixelItem { path: string; kind: 'text' | 'ring'; pad?: number }
export interface PixelRect { x: number; y: number; w: number; h: number; inner?: { l: number; t: number; r: number; b: number } }
/** Shared in-page helpers; built once by the install step. */
export function glHelpers() {
const vis = (el: Element): boolean => {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) return false;
const r = el.getBoundingClientRect();
return r.width > 1 && r.height > 1;
};
const clsOf = (el: Element): string[] => (el.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean);
const describe = (el: Element): string => el.tagName.toLowerCase() + (clsOf(el).length ? '.' + clsOf(el).slice(0, 2).join('.') : '');
const name = (el: Element): string => el.tagName.toLowerCase() + (clsOf(el)[0] ? '.' + clsOf(el)[0] : '');
const text = (el: Element): string => ((el as HTMLElement).innerText || el.textContent || '').replace(/\s+/g, ' ').trim();
const at = (el: Element): ProbeAt => {
const hint = el.getAttribute('data-slot') ? 'data-slot="' + el.getAttribute('data-slot') + '"' : el.id ? 'id="' + el.id + '"' : '';
return { tag: el.tagName.toLowerCase(), cls: clsOf(el)[0] || '', text: text(el).slice(0, 40), ...(hint ? { attr: hint } : {}) };
};
const pathOf = (el: Element): string => {
const parts: string[] = [];
for (let n: Element | null = el; n && n !== document.documentElement; n = n.parentElement) {
const p = n.parentElement;
parts.unshift(n.tagName.toLowerCase() + ':nth-child(' + (p ? Array.prototype.indexOf.call(p.children, n) + 1 : 1) + ')');
}
return 'html > ' + parts.join(' > ');
};
const abs = (el: Element) => { const r = el.getBoundingClientRect(); return { top: r.top + scrollY, bottom: r.bottom + scrollY, left: r.left, right: r.right, w: r.width, h: r.height }; };
const cv = document.createElement('canvas'); cv.width = cv.height = 1;
const cx = cv.getContext('2d', { willReadFrequently: true }) as CanvasRenderingContext2D;
const rgb = (s: string): number[] => { cx.clearRect(0, 0, 1, 1); cx.fillStyle = 'rgba(0, 0, 0, 0)'; cx.fillStyle = s; cx.fillRect(0, 0, 1, 1); const d = cx.getImageData(0, 0, 1, 1).data; return [d[0] / 255, d[1] / 255, d[2] / 255, d[3] / 255]; };
const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const lum = (c: number[]) => 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]);
const ratio = (a: number[], b: number[]) => (Math.max(lum(a), lum(b)) + 0.05) / (Math.min(lum(a), lum(b)) + 0.05);
const over = (c: number[], b: number[]) => [0, 1, 2].map((i) => c[i] * c[3] + b[i] * (1 - c[3]));
const clear = (c: string) => /^(transparent|rgba\(0, 0, 0, 0\))$/.test(c);
const bgOf = (n0: Element | null): number[] => {
for (let n = n0; n && n !== document.documentElement; n = n.parentElement) { const c = rgb(getComputedStyle(n).backgroundColor); if (c[3] >= 0.9) return c; }
const b = rgb(getComputedStyle(document.body).backgroundColor);
return b[3] > 0 ? b : [1, 1, 1, 1];
};
const settle = async () => {
if (document.fonts && document.fonts.status !== 'loaded') await Promise.race([document.fonts.ready, new Promise((r) => setTimeout(r, 3000))]);
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
};
return { vis, clsOf, describe, name, text, at, pathOf, abs, rgb, lum, ratio, over, clear, bgOf, settle };
}
export type Helpers = ReturnType<typeof glHelpers>;
/** Everything installed on window.__gstackLint: the helpers and every measurement. */
export interface Probes {
H: Helpers;
setup: typeof glSetup;
layout: typeof glLayout;
contrast: typeof glContrast;
accent: typeof glAccent;
focus: typeof glFocus;
occlusion: typeof glOcclusion;
hide: typeof glHide;
restore: typeof glRestore;
hero: typeof glHero;
space: typeof glSpace;
words: typeof glWords;
slots: typeof glSlots;
pictures: typeof glPictures;
marks: typeof glMarks;
photos: typeof glPhotos;
heroSpace: typeof glHeroSpace;
thin: typeof glThin;
wide: typeof glWide;
quality: typeof glQuality;
rhythm: typeof glRhythm;
padding: typeof glPadding;
prose: typeof glProse;
fields: typeof glFields;
strain: typeof glStrain;
grid: typeof glGrid;
}
const PROBE_FNS = { setup: glSetup, layout: glLayout, contrast: glContrast, accent: glAccent, focus: glFocus, occlusion: glOcclusion, hide: glHide, restore: glRestore, hero: glHero, space: glSpace, words: glWords, slots: glSlots, pictures: glPictures, marks: glMarks, photos: glPhotos, heroSpace: glHeroSpace, thin: glThin, wide: glWide, quality: glQuality, rhythm: glRhythm, padding: glPadding, prose: glProse, fields: glFields, strain: glStrain, grid: glGrid };
export const PROBE_GLOBAL = 'window.__gstackLint';
/** The JS that installs every probe on the page, as one expression. */
export function installScript(): string {
const entries = Object.entries(PROBE_FNS).map(([k, fn]) => k + ': (' + fn.toString() + ')');
return '(' + PROBE_GLOBAL + ' = { H: (' + glHelpers.toString() + ')(), ' + entries.join(', ') + ' })';
}
/**
* An eval expression that runs one probe and returns its JSON, or {error} when
* it throws. Written without the word await: the browse daemon wraps an
* expression that mentions it, and a wrapper it cannot parse drops the value.
*/
export function probeScript(call: string): string {
return '(() => { const P = ' + PROBE_GLOBAL + '; return Promise.resolve().then(() => (' + call + ')).then((v) => JSON.stringify(v), (e) => JSON.stringify({ error: String((e && e.stack) || e).slice(0, 300) })); })()';
}
// sloplint-render renderCheck's page preparation (settleImages, the quiet style, reduced motion)
export async function glSetup(P: Probes): Promise<SetupReport> {
const quiet = document.createElement('style');
quiet.textContent = 'nextjs-portal, astro-dev-toolbar { display: none !important } * { transition: none !important } html, body { scroll-behavior: auto !important }';
document.head.appendChild(quiet);
for (const a of document.getAnimations()) { try { a.finish(); } catch { /* an infinite animation keeps running */ } }
for (const i of document.querySelectorAll('img[loading="lazy"]')) (i as HTMLImageElement).loading = 'eager';
for (let y = 0; y < document.documentElement.scrollHeight; y += 700) { window.scrollTo(0, y); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); }
window.scrollTo(0, 0);
const waiting = Array.from(document.images).filter((i) => !i.complete);
await Promise.race([Promise.all(waiting.map((i) => new Promise((r) => { i.addEventListener('load', r, { once: true }); i.addEventListener('error', r, { once: true }); }))), new Promise((r) => setTimeout(r, 5000))]);
await Promise.all(Array.from(document.images).map((i) => (i.complete && i.naturalWidth && i.decode ? i.decode().catch(() => {}) : null)));
await P.H.settle();
const lost: string[] = [];
for (const l of document.querySelectorAll('link[rel~="stylesheet"][href]')) if (!(l as HTMLLinkElement).sheet) lost.push((l.getAttribute('href') || '').split('/').pop() || 'stylesheet');
for (const e of performance.getEntriesByType('resource') as PerformanceResourceTiming[]) if ((e.initiatorType === 'link' || e.initiatorType === 'css') && e.responseStatus >= 400) lost.push(e.name.split('?')[0].split('/').pop() || e.name);
if (document.fonts) for (const f of document.fonts) if (f.status === 'error') lost.push(f.family.replace(/^["']|["']$/g, ''));
return { lost: Array.from(new Set(lost)) };
}
// sloplint-render observe (gates 34, 49a, 49b, 44b; F18 and 72 at 375)
export function glLayout(P: Probes, checkFold: boolean): ProbeFinding[] {
const { vis, describe, at, text } = P.H;
const out: ProbeFinding[] = [];
const vw = innerWidth, sw = document.documentElement.scrollWidth;
if (sw > vw + 1) out.push({ rule: 'horizontal-scroll', grade: 'fail', evidence: 'the page scrolls sideways: it is ' + sw + 'px wide in a ' + vw + 'px viewport', fix: 'overflow-x: clip on html and body, then find the block that overflows' });
if (vw === 375) {
const clipped = (el: Element) => { for (let q = el.parentElement; q && q !== document.body && q !== document.documentElement; q = q.parentElement) { const cs = getComputedStyle(q); if (/hidden|clip|auto|scroll/.test(cs.overflowX + ' ' + cs.overflow)) return true; } return false; };
const spill = Array.from(document.querySelectorAll('body *')).find((el) => { if (!vis(el) || getComputedStyle(el).position === 'fixed' || el.closest('svg') || clipped(el)) return false; const r = el.getBoundingClientRect(); return r.right > vw + 2 || r.left < -2; });
if (spill && sw <= vw + 1) { const r = spill.getBoundingClientRect(); out.push({ rule: 'horizontal-scroll', grade: 'fail', evidence: describe(spill) + ' runs ' + Math.round(Math.max(r.right - vw, -r.left)) + 'px past the 375px screen, hidden only by the page\'s clip', fix: 'stack it below 60rem: drop the desktop column gap, widths and offsets on the phone', at: at(spill) }); }
for (const sl of document.querySelectorAll('[data-slot]')) {
const cs = getComputedStyle(sl);
if (cs.display === 'none' || !sl.getClientRects().length || sl.closest('[hidden]')) continue;
const r = sl.getBoundingClientRect();
if (r.width < 40 || r.height < 40) { out.push({ rule: 'tiny-slot', grade: 'warn', evidence: 'slot "' + sl.getAttribute('data-slot') + '" renders at ' + Math.round(r.width) + ' x ' + Math.round(r.height) + 'px on a phone', fix: 'the slot collapsed: give it width: 100% and an aspect-ratio in the stacked layout', at: at(sl) }); break; }
}
const flush = Array.from(document.querySelectorAll('p, li, h1, h2, h3, blockquote, dd')).find((el) => {
if (!vis(el) || el.closest('nav, pre, table, [data-slot]')) return false;
const own = Array.from(el.childNodes).filter((n) => n.nodeType === 3 && (n.textContent || '').trim().length >= 20);
if (own.reduce((n, t) => n + (t.textContent || '').trim().length, 0) < 40) return false;
let left = Infinity, right = -Infinity;
for (const t of own) { const range = document.createRange(); range.selectNodeContents(t); for (const r of range.getClientRects()) { if (r.width < 2) continue; left = Math.min(left, r.left); right = Math.max(right, r.right); } }
return right - left > 120 && right <= vw + 1 && (left < 8 || right > vw - 8);
});
if (flush) out.push({ rule: 'cramped-padding', grade: 'fail', evidence: 'text runs to the edge of the phone screen (' + describe(flush) + ')', fix: 'give the page a gutter: 16px or more at each side on a phone', at: at(flush) });
}
for (const el of document.querySelectorAll('a, button, [role="button"], [role="tab"]')) {
if (!vis(el)) continue;
const label = (el.textContent || '').trim();
if (!label) continue;
const isButton = el.tagName === 'BUTTON' || el.getAttribute('role') === 'button' || /btn|button|cta/i.test(el.getAttribute('class') || '');
const block = el.closest('p, li, dd, td, blockquote, figcaption');
if (!isButton && block && text(block).length > label.length + 12) continue;
if (Array.from(el.querySelectorAll('*')).some((d) => !d.closest('svg') && /^(block|flex|grid|list-item|table)$/.test(getComputedStyle(d).display))) continue;
const cs = getComputedStyle(el);
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.4;
const tops: number[] = [];
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
const host = n.parentElement;
if (!(n.textContent || '').trim() || !host || host.closest('svg')) continue;
const hr = host.getBoundingClientRect();
if (hr.width <= 2 || hr.height <= 2) continue;
const range = document.createRange();
range.selectNodeContents(n);
for (const r of range.getClientRects()) if (r.width > 0 && r.height > 0) tops.push(r.top);
}
tops.sort((a, b) => a - b);
let lines = tops.length ? 1 : 0;
for (let i = 1; i < tops.length; i++) if (tops[i] - tops[i - 1] > lh * 0.6) lines++;
if (lines <= 1) continue;
const primary = isButton || (!!el.closest('nav') && !el.closest('footer') && !el.closest('nav[aria-label*="bread" i]'));
out.push({ rule: primary ? 'button-label-wraps' : 'link-wraps', grade: 'fail', evidence: (primary ? 'control' : 'link') + ' "' + label.slice(0, 32) + '" wraps to ' + lines + ' lines (' + describe(el) + ')', fix: 'shorten the label, or white-space: nowrap', at: at(el) });
}
if (checkFold) {
const h1 = document.querySelector('h1');
if (h1 && vis(h1)) {
const hr = h1.getBoundingClientRect();
const scope = h1.closest('section, header, main, div') || document.body;
const cta = Array.from(scope.querySelectorAll('a, button')).find((c) => vis(c) && (c.textContent || '').trim());
const fold = 800;
if (hr.bottom > fold || hr.top < 0) out.push({ rule: 'hero-fold', grade: 'fail', evidence: 'the headline crosses the 1280x800 fold (its bottom is at ' + Math.round(hr.bottom) + 'px)', fix: 'pull the display clamp down so the headline fits the fold', at: at(h1) });
else if (cta) {
const cr = cta.getBoundingClientRect();
if (cr.bottom > fold) out.push({ rule: 'hero-fold', grade: 'fail', evidence: 'the primary action sits below the 1280x800 fold (its bottom is at ' + Math.round(cr.bottom) + 'px)', fix: 'trim the hero padding so the action fits the fold', at: at(cta) });
}
}
}
return out;
}
// sloplint-render observe, the contrast walk (gates 40 and 41); pixelBehind confirms the probes afterwards
export function glContrast(P: Probes): ProbeFinding[] {
const { vis, describe, at, text, rgb, ratio, over, pathOf } = P.H;
const out: ProbeFinding[] = [];
const parse = (s: string): number[] | null => {
if (!s || s === 'transparent') return null;
const m = /^rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)$/.exec(s.trim());
if (m) return [+m[1] / 255, +m[2] / 255, +m[3] / 255, m[4] == null ? 1 : +m[4]];
const alpha = /\/\s*([\d.]+%?)\s*\)$/.exec(s);
const c = rgb(s);
if (alpha) c[3] = alpha[1].endsWith('%') ? parseFloat(alpha[1]) / 100 : parseFloat(alpha[1]);
return c;
};
const bgOf = (el: Element): number[] => {
for (let node: Element | null = el; node && node !== document.documentElement; node = node.parentElement) { const c = parse(getComputedStyle(node).backgroundColor); if (c && c[3] >= 0.9) return c; }
const root = parse(getComputedStyle(document.body).backgroundColor);
return root && root[3] > 0 ? root : [1, 1, 1, 1];
};
const gradStops = (el: Element, base: number[]): number[][] => {
const stops: number[][] = [];
for (let node: Element | null = el; node && node !== document.documentElement; node = node.parentElement) {
const cs2 = getComputedStyle(node);
if (/gradient/.test(cs2.backgroundImage)) {
const layers: string[] = []; let depth = 0, cur = '';
for (const ch of cs2.backgroundImage) { if (ch === '(') depth++; if (ch === ')') depth--; if (ch === ',' && depth === 0) { layers.push(cur); cur = ''; } else cur += ch; }
layers.push(cur);
const sizes = cs2.backgroundSize.split(',');
layers.forEach((layer, li) => {
const bh = /\s([\d.]+)px\s*$/.exec((sizes[li] || sizes[0] || '').trim()); if (bh && parseFloat(bh[1]) <= 6) return;
if (!/^\s*(linear|radial|conic)-gradient/.test(layer) || /[\s,(]\d(?:\.\d+)?px\b/.test(layer)) return;
for (const m of layer.matchAll(/(?:oklch|oklab|lab|lch|rgba?|hsla?|color)\([^()]*\)|#[0-9a-fA-F]{3,8}\b/g)) stops.push(over(rgb(m[0]), base));
});
}
const c = parse(cs2.backgroundColor);
if (c && c[3] >= 0.9) break;
}
return stops;
};
let reported = 0;
const seen = new Set<string>();
for (const el of document.querySelectorAll('body *')) {
if (reported >= 10) break;
if (!vis(el) || el.closest('svg')) continue;
if (!Array.from(el.childNodes).some((n) => n.nodeType === 3 && (n.textContent || '').trim().length > 2)) continue;
const cs = getComputedStyle(el);
const fg = parse(cs.color);
if (!fg || fg[3] < 0.9) continue;
const bg = bgOf(el);
const size = parseFloat(cs.fontSize), weight = parseInt(cs.fontWeight, 10) || 400;
const threshold = size >= 24 || (size >= 18 && weight >= 700) ? 3.0 : 4.5;
const key = describe(el);
const where = key + ' "' + text(el).slice(0, 24) + '"';
const stops = gradStops(el, bg);
if (stops.length && !seen.has(key)) {
const rs = stops.map((c) => ratio(fg, c));
const worst = Math.min(...rs), best = Math.max(...rs);
if (best < threshold - 0.05) { seen.add(key); reported++; out.push({ rule: 'low-contrast', grade: 'fail', evidence: 'contrast ' + best.toFixed(2) + ':1 at best over the gradient behind ' + where, fix: 'raise the contrast to threshold, or give the text its own ground', at: at(el) }); }
else if (worst < threshold - 0.05) { seen.add(key); reported++; out.push({ rule: 'low-contrast', grade: 'warn', evidence: 'contrast drops to ' + worst.toFixed(2) + ':1 over the gradient behind ' + where, fix: 'check the text where the gradient is darkest or lightest; give the label its own ground', at: at(el) }); }
continue;
}
const r = ratio(fg, bg);
if (r >= threshold - 0.05 || seen.has(key)) continue;
seen.add(key);
reported++;
const probe = { path: pathOf(el), fg: fg.slice(0, 3), threshold };
out.push({ rule: 'low-contrast', grade: 'fail', evidence: 'computed contrast ' + r.toFixed(2) + ':1 under ' + threshold + ':1 on ' + where, at: at(el), probe });
if (el.closest('a, button, [class*="btn"], [class*="cta"]') && r < 1.6) out.push({ rule: 'gray-on-color', grade: 'fail', evidence: 'button text within a step of its fill on ' + key + ' (' + r.toFixed(2) + ':1)', fix: 'the paired ink token: the accent\'s ink or the paper for the label', at: at(el), probe });
}
return out;
}
// sloplint-render observe, the accent footprint (gate 23): the strict form, no posture and no genre
export function glAccent(P: Probes): ProbeFinding[] {
const { vis } = P.H;
const out: ProbeFinding[] = [];
const cs0 = getComputedStyle(document.documentElement);
const cv = document.createElement('canvas'); cv.width = cv.height = 1;
const cx = cv.getContext('2d', { willReadFrequently: true }) as CanvasRenderingContext2D;
const toRgb = (raw: string): number[] | null => {
const s = (raw || '').trim();
if (!s || s === 'transparent') return null;
cx.clearRect(0, 0, 1, 1); cx.fillStyle = 'rgba(1, 2, 3, 0.5)'; cx.fillStyle = s;
if (cx.fillStyle === 'rgba(1, 2, 3, 0.5)') return null;
cx.fillRect(0, 0, 1, 1);
const d = cx.getImageData(0, 0, 1, 1).data;
return [d[0], d[1], d[2], d[3] / 255];
};
const targets = ['--color-accent', '--color-accent-2', '--accent', '--accent-2'].map((t) => toRgb(cs0.getPropertyValue(t))).filter((c): c is number[] => !!c);
if (!targets.length) return out;
const near = (str: string) => { const p = toRgb(str); return !!p && p[3] >= 0.5 && targets.some((t) => Math.abs(t[0] - p[0]) + Math.abs(t[1] - p[1]) + Math.abs(t[2] - p[2]) <= 30); };
const CW = 16, COLS = Math.ceil(innerWidth / CW), ROWS = Math.ceil(800 / CW);
const grid = new Uint8Array(COLS * ROWS);
let walked = 0;
for (const el of document.querySelectorAll('body, body *')) {
if (++walked > 4000) break;
if (el !== document.body && !vis(el)) continue;
const cs = getComputedStyle(el);
const isBg = near(cs.backgroundColor);
const isTxt = near(cs.color) && parseFloat(cs.fontSize) >= 32 && Array.from(el.childNodes).some((nd) => nd.nodeType === 3 && (nd.textContent || '').trim());
if (!isBg && !isTxt) continue;
const r = el.getBoundingClientRect();
const x0 = Math.max(0, Math.floor(r.left / CW)), x1 = Math.min(COLS - 1, Math.floor((Math.min(r.right, innerWidth) - 1) / CW));
const y0 = Math.max(0, Math.floor(r.top / CW)), y1 = Math.min(ROWS - 1, Math.floor((Math.min(r.bottom, 800) - 1) / CW));
const turned = cs.transform !== 'none' && !/^matrix\(1, 0, 0, 1, /.test(cs.transform);
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) {
if (turned) { const hit = document.elementFromPoint(x * CW + CW / 2, y * CW + CW / 2); if (!hit || !(hit === el || el.contains(hit))) continue; }
grid[y * COLS + x] = 1;
}
}
let filled = 0;
for (const c of grid) filled += c;
const pct = (100 * filled) / (COLS * ROWS);
if (pct > 8) out.push({ rule: 'accent-footprint', grade: 'fail', evidence: 'the accent paints about ' + pct.toFixed(1) + '% of the 1280x800 viewport (over 8%)', fix: 'retreat the accent to 5% or less: large colour belongs to the paper and field tokens' });
else if (pct > 5) out.push({ rule: 'accent-footprint', grade: 'warn', evidence: 'the accent paints about ' + pct.toFixed(1) + '% of the 1280x800 viewport', fix: 'retreat the accent to 5% or less, or keep the surface on the record' });
return out;
}
// sloplint-render focusRing (gate 26a): each focusable element is focused in turn instead of pressing Tab
export function glFocus(P: Probes): RingProbe[] {
const { vis, rgb, lum, pathOf, at } = P.H;
const out: RingProbe[] = [];
const seen = new Set<string>();
const declared = (el: Element): boolean => {
for (const sheet of Array.from(document.styleSheets)) {
let rules: CSSRuleList;
try { rules = sheet.cssRules; } catch { continue; }
for (const r of Array.from(rules)) {
const sel = (r as CSSStyleRule).selectorText;
if (!sel || !/:focus-visible/.test(sel)) continue;
try { if (el.matches(sel.replace(/:focus-visible/g, '')) || el.matches(sel.replace(/:focus-visible/g, '*'))) return true; } catch { /* a selector the engine cannot test */ }
}
}
return false;
};
const candidates = Array.from(document.querySelectorAll('a[href], button, input, select, textarea, [tabindex]')).filter((el) => vis(el) && el.getAttribute('tabindex') !== '-1' && !(el as HTMLButtonElement).disabled && !el.closest('[hidden], dialog:not([open])')).slice(0, 16);
for (const target of candidates) {
(target as HTMLElement).focus({ preventScroll: true });
if (document.activeElement !== target) continue;
let el: Element | null = target;
const box = el.getBoundingClientRect();
if (box.width < 4 || box.height < 4) el = ((target as HTMLInputElement).labels && (target as HTMLInputElement).labels![0]) || target.closest('label') || target.parentElement;
if (!el) continue;
(el as HTMLElement).style.setProperty('transition', 'none', 'important');
const cs = getComputedStyle(el);
const key = el.tagName.toLowerCase() + (el.classList[0] ? '.' + el.classList[0] : '') + ((el.textContent || '').trim() ? ' "' + (el.textContent || '').trim().slice(0, 18) + '"' : '');
if (seen.has(key)) continue;
seen.add(key);
const base = { key, path: pathOf(target), at: at(target) };
if (cs.outlineStyle === 'none' || !(parseFloat(cs.outlineWidth) > 0)) {
if (!target.matches(':focus-visible')) { out.push({ ...base, bare: !declared(target), ratio: null }); continue; }
const chain: Element[] = []; for (let n: Element | null = el, i = 0; n && n !== document.body && i < 5; n = n.parentElement, i++) chain.push(n);
const look = () => chain.flatMap((n) => [null, '::before', '::after'].map((ps) => { const c = getComputedStyle(n, ps); return [c.outlineStyle, c.outlineWidth, c.boxShadow, c.borderTopColor, c.borderBottomColor, c.borderLeftColor, c.borderBottomWidth, c.backgroundColor, c.backgroundImage, c.color, c.textDecorationLine, c.opacity].join('|'); })).join('~');
const focused = look();
(target as HTMLElement).blur();
const rest = look();
(target as HTMLElement).focus({ preventScroll: true });
out.push({ ...base, bare: focused === rest, ratio: null });
continue;
}
let node: Element | null = parseFloat(cs.outlineOffset) < 0 ? el : el.parentElement;
let bg: number[] | null = null;
for (; node && node !== document.documentElement; node = node.parentElement) { const c = rgb(getComputedStyle(node).backgroundColor); if (c[3] >= 0.9) { bg = c; break; } }
if (!bg) { const b = rgb(getComputedStyle(document.body).backgroundColor); bg = b[3] > 0 ? b : [1, 1, 1, 1]; }
const ring = rgb(cs.outlineColor);
if (ring[3] < 0.5) { out.push({ ...base, bare: false, ratio: null }); continue; }
const a = lum(ring), b = lum(bg);
out.push({ ...base, bare: false, ring: ring.slice(0, 3), ratio: (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05), pad: Math.max(0, parseFloat(cs.outlineOffset)) + parseFloat(cs.outlineWidth) + 3 });
}
if (document.activeElement && document.activeElement !== document.body) (document.activeElement as HTMLElement).blur();
window.scrollTo(0, 0);
return out;
}
// sloplint-render observeOcclusion (gate 73)
export async function glOcclusion(P: Probes): Promise<ProbeFinding[]> {
const { text, at } = P.H;
const out: ProbeFinding[] = [];
const vis = (el: Element) => { const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) return false; const r = el.getBoundingClientRect(); return r.width > 3 && r.height > 3; };
const pinned = (el: Element) => { for (let p: Element | null = el; p && p !== document.body; p = p.parentElement) if (/fixed|sticky/.test(getComputedStyle(p).position)) return true; return false; };
const unseen = (el: Element) => { const cs = getComputedStyle(el); return parseFloat(cs.opacity) < 0.05 || (/^(transparent|rgba\(0, 0, 0, 0\))$/.test(cs.backgroundColor) && cs.backgroundImage === 'none' && !text(el) && !/^(IMG|svg|CANVAS|VIDEO|PICTURE)$/.test(el.tagName) && !el.querySelector('img, svg, canvas, video')); };
const runs: Array<{ t: Node; host: Element }> = [];
const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
for (let t = tw.nextNode(); t; t = tw.nextNode()) {
const host = t.parentElement; const txt = (t.textContent || '').trim();
if (!txt || !host || host.closest('script, style, noscript, svg, [aria-hidden="true"], nav, header, dialog, [popover], [role="menu"], details:not([open]), [hidden]')) continue;
let gone = false;
for (let p: Element | null = host; p && p !== document.body; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) { gone = true; break; } }
if (!gone && !host.closest('[data-slot]') && vis(host)) runs.push({ t, host });
}
const bad: Array<{ text: string; el: Element }> = [];
const H = document.documentElement.scrollHeight;
for (let y = 0; y < H && bad.length < 3; y += Math.max(200, innerHeight - 100)) {
window.scrollTo(0, y);
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
for (const { t, host } of runs) {
if (bad.length >= 3) break;
if (pinned(host)) continue;
const rg = document.createRange(); rg.selectNodeContents(t);
for (const q of rg.getClientRects()) {
if (q.width < 6 || q.height < 6) continue;
const cx = q.left + q.width / 2, cy = q.top + q.height / 2;
if (cy < 0 || cy >= innerHeight || cx < 0 || cx >= innerWidth) continue;
const ghost = getComputedStyle(host).pointerEvents === 'none'; if (ghost) (host as HTMLElement).style.setProperty('pointer-events', 'auto', 'important');
const top = document.elementFromPoint(cx, cy);
if (ghost) (host as HTMLElement).style.removeProperty('pointer-events');
if (!top || top === host || host.contains(top) || top.contains(host) || pinned(top) || unseen(top)) continue;
const tr = top.getBoundingClientRect();
if (!(cx >= tr.left && cx <= tr.right && cy >= tr.top && cy <= tr.bottom)) {
const clearPseudo = (ps: CSSStyleDeclaration) => /^(none|normal)$/.test(ps.content) || (/^(transparent|rgba\(0, 0, 0, 0\))$/.test(ps.backgroundColor) && ps.backgroundImage === 'none');
if (clearPseudo(getComputedStyle(top, '::after')) && clearPseudo(getComputedStyle(top, '::before'))) continue;
}
bad.push({ text: top.tagName.toLowerCase() + (top.classList[0] ? '.' + top.classList[0] : '') + ' over "' + (t.textContent || '').trim().slice(0, 16) + '"', el: host });
break;
}
}
}
window.scrollTo(0, 0);
if (bad.length) out.push({ rule: 'text-occlusion', grade: 'fail', evidence: 'text painted under another element: ' + bad.map((b) => b.text).join('; '), at: at(bad[0].el) });
return out;
}
// sloplint-render pixelBehind and pixelsAroundRing, the preparation half: hide the ink, report where to sample in page coordinates
export function glHide(P: Probes, items: PixelItem[]): Array<PixelRect | null> {
window.scrollTo(0, 0);
const vw = innerWidth;
return items.map((item) => {
const el = document.querySelector(item.path) as HTMLElement | null;
if (!el) return null;
if (item.kind === 'text') {
for (const [k, v] of [['color', 'transparent'], ['text-shadow', 'none'], ['-webkit-text-stroke', '0'], ['text-decoration-color', 'transparent']]) el.style.setProperty(k, v, 'important');
const r = el.getBoundingClientRect();
const x = Math.max(0, r.left), y = Math.max(0, r.top + scrollY);
return { x, y, w: Math.min(r.right, vw) - x, h: Math.min(r.bottom + scrollY, y + 400) - y };
}
el.style.setProperty('outline-style', 'none', 'important');
el.style.setProperty('box-shadow', 'none', 'important');
const r = el.getBoundingClientRect(), pad = item.pad || 4;
const x = Math.max(0, r.left - pad), y = Math.max(0, r.top + scrollY - pad);
return { x, y, w: Math.min(vw, r.right + pad) - x, h: r.bottom + scrollY + pad - y, inner: { l: r.left - x, t: r.top + scrollY - y, r: r.right - x, b: r.bottom + scrollY - y } };
});
}
// sloplint-render pixelBehind's restore
export function glRestore(P: Probes, items: PixelItem[]): number {
let n = 0;
for (const item of items) {
const el = document.querySelector(item.path) as HTMLElement | null;
if (!el) continue;
for (const k of ['color', 'text-shadow', '-webkit-text-stroke', 'text-decoration-color', 'outline-style', 'box-shadow']) el.style.removeProperty(k);
n++;
}
window.scrollTo(0, 0);
return n;
}
+201 -5
View File
@@ -2,17 +2,33 @@
* The render tier: what only a browser can measure. Drives the same engine
* gstack-render picks (Aside when it is running, otherwise gstack's own
* headless browser) through lib/aside-render, so nothing is installed and no
* puppeteer is needed.
* puppeteer is needed. Each page is opened once, the probes in
* render-probe*.ts are installed, and one eval runs per viewport; a second
* pass takes full-page screenshots only when a contrast claim needs the
* painted pixels behind it (hallmark's pixelBehind and pixelsAroundRing).
*
* Ported from Nutlope/hallmark's sloplint-render.mjs (MIT, see NOTICE.md).
* Ported from Nutlope/hallmark's sloplint-render.mjs renderCheck (MIT, see
* NOTICE.md). Gates covered here and in the probe files: 23, 25, 26a, 34, 40,
* 41, 44a, 44b, 44c, 44d, 49a, 49b, 58, 71, 72, 73, 75, 77, 78, 79, 80, 81,
* 82, 83, F17, F18, F19, F20, plus the page that did not render.
*/
import type { Finding } from './report';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { pickEngine, render, renderWithBrowse, type EngineChoice, type RenderResult as EngineResult, type RenderSpec, type RenderStep } from '../aside-render';
import { contrastRatio } from './color';
import { plainText } from './html';
import { decodePng, medianRgb, type SampleRect } from './render-png';
import { installScript, probeScript, type PixelItem, type ProbeAt, type ProbeFinding, type RingProbe, type SetupReport } from './render-probe';
import { Reporter, type Finding } from './report';
export interface RenderOptions {
cwd?: string;
job?: string | null;
scope?: 'page' | 'component';
timeoutMs?: number;
/** Replaces the engine choice (tests inject one); defaults to lib/aside-render's pickEngine. */
engine?: () => EngineChoice;
}
export interface RenderResult {
@@ -21,7 +37,187 @@ export interface RenderResult {
status: string;
}
export async function lintRender(targets: string[], _opts: RenderOptions = {}): Promise<RenderResult> {
const WIDTHS = [320, 375, 414, 768, 1024, 1280, 1920];
const LAPTOP = { width: 1440, height: 900 };
const RING_FLOOR = 2.95;
type Runner = (spec: RenderSpec) => Promise<EngineResult>;
export async function lintRender(targets: string[], opts: RenderOptions = {}): Promise<RenderResult> {
if (!targets.length) return { findings: [], status: 'skipped: no HTML page to render' };
return { findings: [], status: 'skipped: render tier not built yet' };
const choice = (opts.engine ?? pickEngine)();
if (!choice.engine) return { findings: [], status: `skipped: ${choice.error}` };
const run: Runner = (spec) => (choice.engine === 'browse' ? renderWithBrowse(spec, choice.bin) : render(spec));
const r = new Reporter(opts.scope ?? 'page', opts.cwd ?? process.cwd());
for (const target of targets) await lintPage(target, r, run, opts.timeoutMs ?? 120_000);
return { findings: r.findings, status: 'ran' };
}
const isUrl = (t: string): boolean => /^https?:\/\//.test(t);
async function unreachable(url: string): Promise<string | null> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000), redirect: 'follow' });
return res.ok || res.status === 304 ? null : `HTTP ${res.status}`;
} catch (e) {
return (e as Error).message;
}
}
interface Measure { label: string; width: number; height: number; call: string }
/** The first pass: one eval per viewport, the 1280 one carrying the fold, contrast and accent walks. */
function measures(): Measure[] {
const m: Measure[] = WIDTHS.map((w) => ({ label: `${w}px`, width: w, height: 800, call: w === 1280 ? 'P.layout(P, true).concat(P.contrast(P), P.accent(P))' : 'P.layout(P, false)' }));
m.push({ label: '1440px', ...LAPTOP, call: 'P.space(P, true)' });
m.push({ label: '1920px', width: 1920, height: 1080, call: 'P.space(P, false)' });
for (const call of ['P.quality(P)', 'P.occlusion(P)', 'P.focus(P)']) m.push({ label: '1440px', ...LAPTOP, call });
return m;
}
const evalStep = (call: string, width: number, height: number): RenderStep =>
({ kind: 'eval', expression: probeScript(call), width, height, deviceScaleFactor: 1, mobile: false, maxInline: 200_000 });
/** The probes are installed by a step of their own: its value is not read, so whatever wrapper the engine puts around 70KB of source does not matter. */
const openingSteps = (): RenderStep[] => [{ kind: 'eval', expression: installScript(), width: LAPTOP.width, height: LAPTOP.height, deviceScaleFactor: 1, mobile: false, maxInline: 10 }, evalStep('P.setup(P)', LAPTOP.width, LAPTOP.height)];
type Parsed<T> = { value: T } | { error: string };
function parsed<T>(text: string | undefined): Parsed<T> {
if (text === undefined) return { error: 'no result came back' };
try {
const v = JSON.parse(text) as T | { error: string } | null;
if (v === null) return { error: 'empty result' };
if (typeof v === 'object' && 'error' in v && typeof v.error === 'string') return { error: v.error };
return { value: v as T };
} catch (e) {
return { error: (e as Error).message };
}
}
/** The source line of the element a finding names: its tag, first class, a distinguishing attribute and its opening text. Best effort; 1 when nothing matches. */
export function lineOf(src: string | null, at?: ProbeAt): number {
if (!src || !at) return 1;
const cls = at.cls ? new RegExp(`class=["'](?:[^"']*\\s)?${at.cls.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s[^"']*)?["']`, 'i') : null;
const want = at.text.toLowerCase();
let fallback = -1;
for (const m of src.matchAll(new RegExp(`<${at.tag}\\b[^>]*>`, 'gi'))) {
const pos = m.index ?? 0;
if (cls && !cls.test(m[0])) continue;
if (at.attr && !m[0].includes(at.attr)) continue;
if (!want) { fallback = pos; break; }
if (plainText(src.slice(pos + m[0].length, pos + m[0].length + 600)).toLowerCase().startsWith(want)) { fallback = pos; break; }
if (fallback < 0) fallback = pos;
}
return fallback < 0 ? 1 : src.slice(0, fallback).split('\n').length;
}
interface Found extends ProbeFinding { label: string }
async function lintPage(target: string, r: Reporter, run: Runner, timeoutMs: number): Promise<void> {
const file = isUrl(target) ? target : path.resolve(target);
const report = (f: Found | ProbeFinding, src: string | null) => r.report(f.rule, file, lineOf(src, f.at), ('label' in f ? `[${f.label}] ` : '') + f.evidence, { fix: f.fix, suspected: f.grade === 'warn', source: 'render' });
if (isUrl(target)) {
const err = await unreachable(target);
if (err) { report({ rule: 'render', grade: 'fail', evidence: `could not reach ${target}: ${err}` }, null); return; }
}
let src: string | null = null;
if (!isUrl(target)) { try { src = fs.readFileSync(file, 'utf8'); } catch { src = null; } }
const plan = measures();
const steps: RenderStep[] = [...openingSteps(), ...plan.map((m) => evalStep(m.call, m.width, m.height))];
const res = await run({ file, steps, timeoutMs });
if (!res.ok) { report({ rule: 'render', grade: 'fail', evidence: `the page did not render: ${res.error ?? 'unknown error'}` }, null); return; }
const setup = parsed<SetupReport>(res.evals[1]);
if ('error' in setup) report({ rule: 'render', grade: 'fail', evidence: `the page could not be prepared for measurement: ${setup.error}` }, null);
else if (setup.value.lost.length) report({ rule: 'render', grade: 'warn', evidence: `fonts or stylesheets did not load (${setup.value.lost.slice(0, 2).join(', ')}): the measurements show a fallback face`, fix: 'fix the path or wait for the host, then measure again; never sign off a fallback face' }, null);
const errs = parsed<string[]>(res.stdout.match(/^PAGE_ERRORS=(.+)$/m)?.[1]);
const uncaught = 'value' in errs && Array.isArray(errs.value) ? errs.value.find((e) => e.startsWith('uncaught: ')) : undefined;
if (uncaught) report({ rule: 'render', grade: 'fail', evidence: `a script threw on load: ${uncaught.slice(10, 100)}`, fix: 'fix the script; a page that throws ships half-working' }, null);
const found: Found[] = [];
let rings: RingProbe[] = [];
const seen = new Set<string>();
plan.forEach((m, i) => {
const p = parsed<ProbeFinding[] | RingProbe[]>(res.evals[i + 2]);
if ('error' in p) { report({ rule: 'render', grade: 'fail', evidence: `the ${m.call.replace(/^P\.(\w+).*/, '$1')} measurement at ${m.label} failed: ${p.error}` }, null); return; }
if (m.call === 'P.focus(P)') { rings = p.value as RingProbe[]; return; }
for (const f of p.value as ProbeFinding[]) {
const key = f.rule + '|' + f.evidence;
if (seen.has(key)) continue;
seen.add(key);
found.push({ ...f, label: m.label });
}
});
const scrolls = found.filter((f) => f.rule === 'horizontal-scroll' && !f.at);
if (scrolls.length > 1) {
scrolls[0].evidence += ` (also at ${scrolls.slice(1).map((f) => f.label.replace(/px$/, '')).join(', ')}px)`;
for (const f of scrolls.slice(1)) found.splice(found.indexOf(f), 1);
}
const bare = rings.filter((x) => x.bare);
if (bare.length) report({ rule: 'missing-focus-visible', grade: 'fail', evidence: `[1440px] nothing shows keyboard focus on ${bare.slice(0, 4).map((x) => x.key).join(', ')}`, fix: 'give every control a :focus-visible ring; never remove an outline without putting one back', at: bare[0].at }, src);
const textProbes = found.filter((f) => f.probe);
const weakRings = rings.filter((x) => x.ratio != null && x.ratio < RING_FLOOR);
const confirmed = textProbes.length || weakRings.length ? await confirmPixels(file, run, timeoutMs, textProbes, weakRings) : null;
const dropped = new Set<string>();
for (const f of textProbes) {
const real = confirmed?.text.get(f.probe!.path);
if (real == null) continue;
if (real >= f.probe!.threshold - 0.05) { dropped.add(f.probe!.path); continue; }
f.evidence = f.evidence.replace(/computed contrast [\d.]+:1 under/, `contrast ${real.toFixed(2)}:1 against the pixels behind, under`).replace(/\([\d.]+:1\)$/, `(${real.toFixed(2)}:1)`);
}
for (const f of found) if (!(f.probe && dropped.has(f.probe.path))) report(f, src);
for (const ring of weakRings) {
const real = confirmed?.ring.get(ring.path) ?? ring.ratio!;
if (real < RING_FLOOR) report({ rule: 'missing-focus-visible', grade: 'fail', evidence: `[1440px] focus ring ${real.toFixed(2)}:1 against what is behind it on ${ring.key}`, fix: 'a ring at 3:1 or better against the surface it is drawn over', at: ring.at }, src);
}
}
interface Confirmed { text: Map<string, number>; ring: Map<string, number> }
/** hallmark's pixelBehind and pixelsAroundRing: hide the ink, screenshot the whole page, sample the median colour behind each claim. */
async function confirmPixels(file: string, run: Runner, timeoutMs: number, textProbes: Found[], weakRings: RingProbe[]): Promise<Confirmed | null> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-lint-'));
try {
const textItems: PixelItem[] = [...new Map(textProbes.map((f) => [f.probe!.path, { path: f.probe!.path, kind: 'text' as const }])).values()];
const ringItems: PixelItem[] = weakRings.map((x) => ({ path: x.path, kind: 'ring' as const, pad: x.pad }));
const groups: Array<{ items: PixelItem[]; width: number; height: number; shot: string }> = [];
if (textItems.length) groups.push({ items: textItems, width: 1280, height: 800, shot: path.join(dir, 'text.png') });
if (ringItems.length) groups.push({ items: ringItems, width: LAPTOP.width, height: LAPTOP.height, shot: path.join(dir, 'ring.png') });
const steps: RenderStep[] = openingSteps();
for (const g of groups) {
steps.push(evalStep(`P.hide(P, ${JSON.stringify(g.items)})`, g.width, g.height));
steps.push({ kind: 'screenshot', out: g.shot, width: g.width, height: g.height, fullPage: true, type: 'png', mobile: false });
steps.push(evalStep(`P.restore(P, ${JSON.stringify(g.items)})`, g.width, g.height));
}
const res = await run({ file, steps, timeoutMs });
if (!res.ok) return null;
const out: Confirmed = { text: new Map(), ring: new Map() };
groups.forEach((g, gi) => {
const rects = parsed<Array<SampleRect | null>>(res.evals[2 + gi * 3]);
if ('error' in rects || !fs.existsSync(g.shot)) return;
const png = decodePng(fs.readFileSync(g.shot));
if (!png) return;
const scale = png.width / g.width;
g.items.forEach((item, i) => {
const rect = rects.value[i];
if (!rect || rect.w < 2 || rect.h < 2) return;
const scaled: SampleRect = { x: rect.x * scale, y: rect.y * scale, w: rect.w * scale, h: rect.h * scale, inner: rect.inner && { l: rect.inner.l * scale, t: rect.inner.t * scale, r: rect.inner.r * scale, b: rect.inner.b * scale } };
const bg = medianRgb(png, scaled);
if (!bg) return;
const under = { r: bg[0], g: bg[1], b: bg[2] };
if (item.kind === 'text') {
const fg = textProbes.find((f) => f.probe!.path === item.path)!.probe!.fg;
out.text.set(item.path, contrastRatio({ r: fg[0], g: fg[1], b: fg[2] }, under));
} else {
const ring = weakRings.find((x) => x.path === item.path)!.ring!;
out.ring.set(item.path, contrastRatio({ r: ring[0], g: ring[1], b: ring[2] }, under));
}
});
});
return out;
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
+241
View File
@@ -0,0 +1,241 @@
/**
* The design lint's render tier: every render rule id gets a page that passes
* it (the clean fixture) and a page with the tell planted, measured through
* whichever browser lib/aside-render picks (gstack's own browse daemon on the
* free lane). Skipped with a named reason when neither browser is available;
* the skipped and render-failure paths run everywhere.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { lintRender, lineOf } from '../lib/design-lint/render';
import { installScript } from '../lib/design-lint/render-probe';
import { decodePng, medianRgb } from '../lib/design-lint/render-png';
import { formatFinding, type Finding } from '../lib/design-lint/report';
import { RULES } from '../lib/design-lint/rules/ids';
import { NO_BROWSER, buildRenderScript, pickEngine, renderWithBrowse, resolveBrowseBin, type EngineChoice } from '../lib/aside-render';
import { asideAvailable } from './helpers/aside-available';
import { browserAvailable, NO_BROWSER_REASON } from './helpers/browser-available';
const ROOT = path.resolve(import.meta.dir, '..');
const CLEAN = fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'design-lint', 'clean', 'index.html'), 'utf8');
const hasBrowser = browserAvailable();
if (!hasBrowser) process.stderr.write(`design-lint-render: SKIPPED — ${NO_BROWSER_REASON}\n`);
const RENDER_RULES = Object.entries(RULES).filter(([, m]) => m.render).map(([id]) => id);
/** The engine for these pages, chosen here rather than read from lib/aside-render's per-process cache, which other test files prime with fakes. */
const engine = (): EngineChoice => {
if (asideAvailable()) return pickEngine(true);
const bin = resolveBrowseBin();
return bin ? { engine: 'browse', bin } : { engine: null, probe: { ok: false, reason: 'NEEDS_ASIDE', detail: NO_BROWSER_REASON }, error: NO_BROWSER_REASON };
};
function tmpPage(html: string): { file: string; dir: string } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-render-'));
const file = path.join(dir, 'index.html');
fs.writeFileSync(file, html);
return { file, dir };
}
/** The clean fixture with extra CSS in its head and extra markup before </main>. */
const pageWith = (css: string, body: string, swap: Array<[string, string]> = []): string => {
let html = CLEAN.replace('</style>', css + '\n</style>').replace('</main>', body + '\n</main>');
for (const [from, to] of swap) { if (!html.includes(from)) throw new Error('swap target missing: ' + from); html = html.replace(from, to); }
return html;
};
const lineIn = (html: string, needle: string): number => { const i = html.indexOf(needle); if (i < 0) throw new Error('needle missing: ' + needle); return html.slice(0, i).split('\n').length; };
async function measure(html: string): Promise<{ html: string; findings: Finding[]; status: string; of: (rule: string) => Finding[]; lines: string }> {
const { file, dir } = tmpPage(html);
try {
const r = await lintRender([file], { cwd: dir, engine });
return { html, findings: r.findings, status: r.status, of: (rule) => r.findings.filter((f) => f.rule === rule), lines: r.findings.map(formatFinding).join('\n') };
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
const SVG_IMG = (w: number, h: number, fill: string) => `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${w}' height='${h}'%3E%3Crect width='${w}' height='${h}' fill='%23${fill}'/%3E%3C/svg%3E`;
const WORDS = (n: number) => Array.from({ length: n }, (_, i) => ['ledger', 'invoice', 'carrier', 'receipt', 'review', 'close', 'depot', 'month'][i % 8]).join(' ');
describe('design-lint render: the pure parts', () => {
test('lineOf finds the element by tag, class and opening text; falls back to the tag, then line 1', () => {
const src = '<main>\n<p class="lede">First words here</p>\n<p class="lede">Second words here</p>\n<button class="button">Go</button>\n</main>';
expect(lineOf(src, { tag: 'p', cls: 'lede', text: 'Second words here' })).toBe(3);
expect(lineOf(src, { tag: 'p', cls: 'lede', text: 'no such text' })).toBe(2);
expect(lineOf(src, { tag: 'button', cls: '', text: '' })).toBe(4);
expect(lineOf(src, { tag: 'h1', cls: '', text: '' })).toBe(1);
expect(lineOf(src + '\n<div data-slot="a"></div>\n<div data-slot="b"></div>', { tag: 'div', cls: '', text: '', attr: 'data-slot="b"' })).toBe(7);
expect(lineOf(null, { tag: 'p', cls: '', text: '' })).toBe(1);
});
test('the install script carries every probe and no module-scope reference', () => {
const js = installScript();
for (const name of ['setup', 'layout', 'contrast', 'accent', 'focus', 'occlusion', 'space', 'quality', 'hide', 'restore']) expect(js).toContain(name + ': (');
expect(js).not.toMatch(/\bimport\b|\bexport\b|require\(/);
});
test('decodePng reads an RGB PNG and medianRgb samples a rectangle, leaving out an inner one', () => {
const png = decodePng(fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'design-lint', 'render', 'swatch.png')))!;
expect([png.width, png.height, png.channels]).toEqual([8, 8, 3]);
expect(medianRgb(png, { x: 0, y: 0, w: 4, h: 8 })!.map((v) => Math.round(v * 255))).toEqual([200, 30, 30]);
expect(medianRgb(png, { x: 0, y: 0, w: 8, h: 8, inner: { l: 0, t: 0, r: 4, b: 8 } })!.map((v) => Math.round(v * 255))).toEqual([30, 30, 200]);
expect(decodePng(Buffer.from('not a png'))).toBeNull();
});
test('no browser: findings are empty and the status names the reason', async () => {
const r = await lintRender(['/nowhere/index.html'], { engine: () => ({ engine: null, probe: { ok: false, reason: 'NEEDS_ASIDE', detail: 'test' }, error: `${NO_BROWSER}: open Aside or run ./setup (NEEDS_ASIDE: test)` }) });
expect(r.findings).toEqual([]);
expect(r.status).toBe(`skipped: ${NO_BROWSER}: open Aside or run ./setup (NEEDS_ASIDE: test)`);
expect((await lintRender([])).status).toBe('skipped: no HTML page to render');
});
});
describe.skipIf(process.platform === 'win32')('design-lint render: the eval viewport and URL targets on both engines', () => {
test('the Aside script sets the device metrics before a sized eval and clears them after', () => {
const script = buildRenderScript('http://127.0.0.1:1/x.html', { file: '/x.html', steps: [{ kind: 'eval', expression: 'innerWidth', width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, { kind: 'eval', expression: 'document.title' }] });
const set = script.indexOf('Emulation.setDeviceMetricsOverride", {"width":1440,"height":900,"deviceScaleFactor":1,"mobile":false}');
const run = script.indexOf('pg.evaluate((src) => (0, eval)(src), "innerWidth")');
const clear = script.indexOf('Emulation.clearDeviceMetricsOverride');
expect(set).toBeGreaterThan(-1);
expect(set).toBeLessThan(run);
expect(run).toBeLessThan(clear);
expect(script.indexOf('"document.title"')).toBeGreaterThan(clear);
expect(script.match(/setDeviceMetricsOverride/g)).toHaveLength(1);
});
test('the browse path sets the viewport before a sized eval, restores the default after, and opens an http target without serving anything', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-fake-browse-'));
try {
const log = path.join(tmp, 'argv.log');
const bin = path.join(tmp, 'browse');
fs.writeFileSync(bin, `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(log)}\ncase "$1" in newtab) echo '{"tabId":7}' ;; js) echo 42 ;; *) : ;; esac\n`, { mode: 0o755 });
const doc = path.join(tmp, 'doc.html');
fs.writeFileSync(doc, '<!doctype html><title>Doc</title>');
const r = await renderWithBrowse({ file: doc, steps: [{ kind: 'eval', expression: 'innerWidth', width: 1440, height: 900 }, { kind: 'eval', expression: '1' }] }, bin);
expect(r.error).toBeUndefined();
expect(r.evals).toEqual({ 0: '42', 1: '42' });
const lines = fs.readFileSync(log, 'utf8').split('\n').filter(Boolean).filter((l) => !/^js \(\(\) => \{ window\.__gstackErrs|^js JSON\.stringify\(window\.__gstackErrs/.test(l));
expect(lines.slice(0, 6)).toEqual(['newtab --json', expect.stringMatching(/^goto http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{32}\/doc\.html --tab-id 7$/), 'viewport 1440x900 --tab-id 7', 'js innerWidth --tab-id 7', 'viewport 1280x720 --tab-id 7', 'js 1 --tab-id 7']);
fs.rmSync(log);
const u = await renderWithBrowse({ file: 'http://example.test/page', steps: [{ kind: 'eval', expression: '1' }] }, bin);
expect(u.error).toBeUndefined();
expect(fs.readFileSync(log, 'utf8')).toContain('goto http://example.test/page --tab-id 7');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
describe.skipIf(!hasBrowser)('design-lint render: the browser tier', () => {
test('the clean fixture passes every render rule; status is ran', async () => {
const r = await measure(CLEAN);
expect(r.status).toBe('ran');
expect(r.findings.filter((f) => RENDER_RULES.includes(f.rule) || f.rule === 'render').map(formatFinding)).toEqual([]);
}, 60_000);
test('a URL nobody answers and a file that is not there each report render; the status still says ran', async () => {
const r = await lintRender(['http://127.0.0.1:9/', '/nowhere/missing.html'], { engine });
expect(r.status).toBe('ran');
const render = r.findings.filter((f) => f.rule === 'render');
expect(render).toHaveLength(2);
expect(render[0].grade).toBe('blocks');
expect(render[0].evidence).toMatch(/^could not reach http:\/\/127\.0\.0\.1:9\//);
expect(render[1].evidence).toMatch(/^the page did not render: HTML file not found/);
}, 60_000);
test('layout tells: horizontal scroll, a wrapping button and link, the headline past the fold, a stylesheet that did not load', async () => {
const html = pageWith(
'.hero { padding-top: 760px; } .narrow { width: 120px; } .wide { width: 2400px; height: 8px; background: var(--color-line); }',
'<div class="wide"></div>\n<div class="narrow"><a class="button" href="/book">Book the complete walkthrough today</a></div>\n<footer><div class="narrow"><a class="quiet" href="/method">Read how the close works for you</a></div></footer>',
[['</head>', '<link rel="stylesheet" href="missing.css">\n</head>']],
);
const r = await measure(html);
expect(r.of('render').map((f) => [f.grade, f.evidence])).toEqual([['ask', 'fonts or stylesheets did not load (missing.css): the measurements show a fallback face']]);
expect(r.findings.every((f) => f.source === 'render')).toBe(true);
expect(r.of('horizontal-scroll').map((f) => [f.grade, f.line, f.evidence])).toEqual([['blocks', lineIn(html, 'class="wide"'), "[375px] div.wide runs 2057px past the 375px screen, hidden only by the page's clip"]]);
const unclipped = await measure(html.replace('html, body { overflow-x: clip; }', ''));
expect(unclipped.of('horizontal-scroll').map((f) => [f.grade, f.evidence])).toEqual([['blocks', '[320px] the page scrolls sideways: it is 2432px wide in a 320px viewport (also at 375, 414, 768, 1024, 1280, 1920px)']]);
const wraps = r.of('button-label-wraps');
expect(wraps.map((f) => [f.grade, f.line])).toEqual([['blocks', lineIn(html, 'Book the complete walkthrough today')]]);
expect(wraps[0].evidence).toMatch(/^\[320px\] control "Book the complete walkthrough to" wraps to \d lines \(a\.button\)$/);
expect(r.of('link-wraps').map((f) => [f.grade, f.line])).toEqual([['ask', lineIn(html, 'Read how the close works for you')]]);
expect(r.of('hero-fold').map((f) => [f.grade, f.line, f.evidence])).toEqual([['blocks', lineIn(html, '<h1>'), expect.stringMatching(/^\[1280px\] the headline crosses the 1280x800 fold/)]]);
}, 60_000);
test('contrast and focus: low contrast confirmed against the pixels, button text on its fill, a weak field edge, a bare focus state and a faint ring; white text over a dark picture is not a finding', async () => {
const html = pageWith(
'.faint { color: #b8b8b8; } .flat { background: #777; color: #808080; border-color: #777; } .edge { border: 1px solid #f2f2f2; background: #fff; height: 44px; } .bare:focus-visible { outline: none; } .weak:focus-visible { outline: 3px solid #e4e4e4; outline-offset: 2px; } .over { position: relative; width: 600px; } .over p { position: absolute; left: 24px; top: 24px; color: #fff; margin: 0; }',
'<section class="extra">\n<p class="faint">The faint paragraph nobody can read on the paper.</p>\n<a class="button flat" href="/buy">Buy the ledger</a>\n<label>Email <input class="edge" type="email" name="email"></label>\n<button class="button bare">Bare of focus</button>\n<button class="button weak">Weak ring</button>\n<div class="over"><img src="' + SVG_IMG(600, 200, '222222') + '" width="600" height="200" alt="A dark depot at night"><p>White on the night photograph</p></div>\n</section>',
);
const r = await measure(html);
const low = r.of('low-contrast');
expect(low.find((f) => f.line === lineIn(html, 'class="faint"'))?.evidence).toMatch(/^\[1280px\] contrast 1\.\d\d:1 against the pixels behind, under 4\.5:1 on p\.faint "The faint paragraph nobo"$/);
expect(low.find((f) => /field's edge/.test(f.evidence))?.evidence).toMatch(/^\[1440px\] a field's edge is under 3:1 against what surrounds it: input\.edge 1\.\d\d:1$/);
expect(low.some((f) => /White on the night/.test(f.evidence))).toBe(false);
expect(r.of('gray-on-color').map((f) => [f.grade, f.line, f.evidence])).toEqual([['blocks', lineIn(html, 'class="button flat"'), expect.stringMatching(/^\[1280px\] button text within a step of its fill on a\.button\.flat \(1\.\d\d:1\)$/)]]);
const focus = r.of('missing-focus-visible');
expect(focus.map((f) => f.grade)).toEqual(['blocks', 'blocks']);
expect(focus.find((f) => /nothing shows/.test(f.evidence))).toMatchObject({ line: lineIn(html, 'class="button bare"'), evidence: '[1440px] nothing shows keyboard focus on button.button "Bare of focus"' });
expect(focus.find((f) => /focus ring/.test(f.evidence))).toMatchObject({ line: lineIn(html, 'class="button weak"'), evidence: expect.stringMatching(/^\[1440px\] focus ring 1\.\d\d:1 against what is behind it on button\.button "Weak ring"$/) });
}, 90_000);
test('type and space tells at 1440: the word budget, a drawn SVG, an empty slot, counting marks, a tiny slot, a sliver, a lonely grid item, five weights, tiny text, long lines, crowded heads and a cramped box', async () => {
const html = pageWith(
'.marks { display: flex; gap: 4px; } .marks i { display: block; width: 8px; height: 8px; background: var(--color-ink); border-radius: 50%; } .tiles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } .tiles div { min-height: 120px; background: var(--color-line); } .sliver { width: 60px; } .w3 { font-weight: 300; } .w6 { font-weight: 600; } .w9 { font-weight: 900; } .tiny { font-size: 10px; } .long { max-width: none; font-size: 15px; } .crowd h3 { margin: 2px 0 48px; } .box { background: var(--color-line); padding: 1px; width: 320px; }',
'<section class="extra">\n<h2>Everything the close touches</h2>\n<p class="budget">' + WORDS(120) + '</p>\n<svg class="plant" width="200" height="200" viewBox="0 0 200 200" aria-hidden="true"><path d="M20 180 C 60 20, 140 20, 180 180" fill="#8a6"/><path d="M40 160 Q 100 40 160 160" fill="#6a8"/><path d="M60 140 S 100 60 140 140" fill="#a86"/></svg>\n<div data-slot="product-shot" style="width: 420px; height: 300px; background: var(--color-line);"></div>\n<div class="marks" aria-hidden="true">' + '<i></i>'.repeat(30) + '</div>\n<div data-slot="mark" style="width: 20px; height: 20px; background: var(--color-line);"></div>\n<p class="sliver">A paragraph squeezed into a sliver of a column that runs for many lines.</p>\n<div class="tiles"><div></div><div></div><div></div><div></div></div>\n<p class="w3">Light weight runs across thirty characters.</p><p class="w6">Semibold weight runs across thirty characters.</p><p class="w9">Black weight runs across thirty characters here.</p>\n<p class="tiny">Ten pixel text that strains the eye across a whole paragraph of running prose.</p>\n<p class="long">' + WORDS(160) + '</p>\n<div class="crowd"><p>A paragraph above.</p><h3>Crowded head one</h3><p>The text it heads.</p><p>Another paragraph above.</p><h3>Crowded head two</h3><p>The text it heads.</p></div>\n<div class="box">Text pressed against the edge of its filled box.</div>\n</section>',
);
const r = await measure(html);
const one = (rule: string) => { const f = r.of(rule); expect(f.length, rule + '\n' + r.lines).toBeGreaterThan(0); return f[0]; };
expect(one('screen-word-budget')).toMatchObject({ grade: 'ask', evidence: expect.stringMatching(/^\[1440px\] \d+ words on one 900px screen/) });
expect(one('hand-drawn-svg')).toMatchObject({ grade: 'ask', line: lineIn(html, '<svg class="plant"'), evidence: '[1440px] drawn illustration: section.extra (200x200, 3 shapes)' });
expect(one('empty-picture')).toMatchObject({ grade: 'ask', line: lineIn(html, 'data-slot="product-shot"'), evidence: '[1440px] 1 empty picture: div[data-slot=product-shot] 420x300' });
expect(one('counting-marks')).toMatchObject({ grade: 'ask', line: lineIn(html, 'class="marks"'), evidence: '[1440px] counting device: div.marks holds 30 identical marks' });
expect(r.of('tiny-slot').map((f) => [f.grade, f.line, f.evidence])).toEqual([
['polish', lineIn(html, 'data-slot="mark"'), '[375px] slot "mark" renders at 20 x 20px on a phone'],
['polish', lineIn(html, 'data-slot="mark"'), '[1440px] slot "mark" renders at 20 x 20px'],
]);
expect(one('text-sliver')).toMatchObject({ grade: 'polish', line: lineIn(html, 'class="sliver"'), evidence: expect.stringMatching(/^\[1440px\] "A paragraph squeezed into a " squeezed to 60px wide over \d+ lines \(p\.sliver\)$/) });
expect(one('lonely-grid-item')).toMatchObject({ grade: 'polish', line: lineIn(html, 'class="tiles"'), evidence: '[1440px] div.tiles leaves one item alone on its last row (3 + 1)' });
expect(one('font-weight-count')).toMatchObject({ grade: 'polish', evidence: '[1440px] 5 font weights in running use (300, 400, 600, 700, 900)' });
expect(one('tiny-text')).toMatchObject({ grade: 'ask', line: lineIn(html, 'class="tiny"'), evidence: '[1440px] text that strains the eye: p.tiny at 10px' });
expect(one('line-length')).toMatchObject({ grade: 'ask', line: lineIn(html, 'class="long"'), evidence: expect.stringMatching(/^\[1440px\] running prose set too wide: p\.long about \d{2,3} characters a line$/) });
expect(one('heading-rhythm')).toMatchObject({ grade: 'ask', line: lineIn(html, 'Crowded head one'), evidence: expect.stringMatching(/^\[1440px\] heads nearer what precedes them than what they head: h3 "Crowded head one" 24px over, 48px under; h3 "Crowded/) });
expect(one('cramped-padding')).toMatchObject({ grade: 'ask', line: lineIn(html, 'class="box"'), evidence: expect.stringMatching(/^\[1440px\] text crowds the edge of its box: div\.box left \dpx/) });
}, 90_000);
test('the hero and its sections: a dead band, small section heads, thin sections, text under a cover, and the 1920 container', async () => {
const html = pageWith(
'.hero { align-items: start; } .hero figure { display: none; } .wrap { max-width: none; padding: 0; } h2 { font-size: 24px; } p { max-width: none; } .cover { position: absolute; left: 0; width: 300px; height: 80px; margin-top: -80px; background: var(--color-ink); }',
'<section class="thin"><h2>A thin section</h2><p style="max-width: 30ch">A short body on a wide canvas with nothing else in it.</p></section>\n<section class="under"><p class="hidden-run" style="max-width: 20ch">This paragraph is painted under the cover.</p><div class="cover" aria-hidden="true"></div><p class="wide-run">' + WORDS(60) + '.</p></section>',
[['<section class="story">', '<section class="story" style="display: none">'], ['<section class="ledger">', '<section class="ledger" style="display: none">'], ['<section class="close">', '<section class="close" style="display: none">']],
);
const r = await measure(html);
expect(r.of('dead-band').map((f) => [f.grade, f.line, f.evidence])).toEqual([['ask', lineIn(html, '<section class="hero">'), expect.stringMatching(/^\[1440px\] \d{3}px of nothing between the hero's last content \(y \d+\) and the fold$/)]]);
expect(r.of('small-section-heads').map((f) => [f.grade, f.evidence])).toEqual([['ask', expect.stringMatching(/^\[1440px\] section heads render at 24px under a \d+px hero head$/)]]);
expect(r.of('thin-section').map((f) => [f.grade, f.line])).toEqual([['ask', lineIn(html, '<section class="thin">')]]);
expect(r.of('thin-section')[0].evidence).toMatch(/^\[1440px\] \d thin sections?: "A thin section" \(\d+% wide, no mass\)/);
expect(r.of('text-occlusion').map((f) => [f.grade, f.line, f.evidence])).toEqual([['blocks', lineIn(html, 'class="hidden-run"'), expect.stringMatching(/^\[1440px\] text painted under another element: div\.cover over "This paragraph i"/)]]);
const wide = r.of('wide-container');
expect(wide.map((f) => f.grade)).toEqual(wide.map(() => 'blocks'));
expect(wide.map((f) => f.evidence)).toEqual(expect.arrayContaining([expect.stringMatching(/^\[1920px\] the hero text starts \d+px from the edge at 1920$/), expect.stringMatching(/^\[1920px\] \d text blocks? runs? wider than 1600px at 1920/)]));
}, 90_000);
test('the photo wall, the accent footprint and hero content sitting low', async () => {
const photo = (fill: string) => '<section class="shot"><img src="' + SVG_IMG(1200, 520, fill) + '" width="1200" height="520" alt="A depot"></section>';
const html = pageWith(
'.band { background: var(--color-accent); height: 220px; } .shot img { width: 100%; height: auto; display: block; }',
'<div class="band"></div>\n' + photo('778866') + '\n' + photo('667788') + '\n' + photo('886677'),
[['<section class="hero">', '<div class="band"></div>\n <section class="hero">'], ['<section class="story">', '<section class="story" style="display: none">'], ['<section class="ledger">', '<section class="ledger" style="display: none">'], ['align-items: center;', 'align-items: end;'], ['<figure>', '<figure style="display: none">']],
);
const r = await measure(html);
const balance = r.of('hero-balance');
expect(balance.map((f) => f.grade)).toEqual(balance.map(() => 'ask'));
expect(balance.map((f) => f.evidence)).toContainEqual(expect.stringMatching(/^\[1440px\] the hero content sits low: \d+px above it, \d+px below$/));
expect(r.of('photo-wall').map((f) => [f.grade, f.evidence])).toEqual([['ask', expect.stringMatching(/^\[1440px\] photographs cover \d\d% of the page below the hero \(3 pictures\)$/)]]);
expect(r.of('accent-footprint').map((f) => [f.grade, f.evidence])).toEqual([['ask', expect.stringMatching(/^\[1280px\] the accent paints about \d+\.\d% of the 1280x800 viewport \(over 8%\)$/)]]);
}, 60_000);
});
+6 -5
View File
@@ -34,12 +34,12 @@ body { margin: 0; background: var(--color-paper); color: var(--color-ink); font-
::selection { background: var(--color-accent); color: var(--color-accent-ink); }
.skip { position: absolute; left: var(--space-2); top: var(--space-2); }
.wrap { max-width: 1360px; margin: 0 auto; padding: 0 var(--space-4); }
.hero { min-height: 100svh; display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); gap: var(--space-6); align-items: end; padding: var(--space-8) 0 var(--space-6); }
.hero { box-sizing: border-box; min-height: 100svh; display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); gap: var(--space-6); align-items: center; padding: var(--space-8) 0 var(--space-6); }
h1 { font-family: var(--font-display); font-size: clamp(2.5rem, 6vw, 5rem); line-height: 1.02; margin: 0 0 var(--space-3); overflow-wrap: break-word; min-width: 0; text-wrap: balance; }
h2 { font-family: var(--font-display); font-size: clamp(1.75rem, 3vw, 2.75rem); line-height: 1.1; margin: 0 0 var(--space-3); overflow-wrap: break-word; min-width: 0; text-wrap: balance; }
p { max-width: 62ch; margin: 0 0 var(--space-3); }
.lede { font-size: 1.375rem; color: var(--color-muted); }
.actions { display: flex; gap: var(--space-2); align-items: center; }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
.button { display: inline-flex; align-items: center; min-height: 48px; padding: 0 var(--space-3); border: 1px solid var(--color-accent); border-radius: var(--radius); background: var(--color-accent); color: var(--color-accent-ink); text-decoration: none; font-family: var(--font-body); transition: background 160ms var(--ease-out), transform 160ms var(--ease-out); }
.button:hover { background: var(--color-accent-deep); }
.button:active { transform: translateY(1px); }
@@ -53,9 +53,10 @@ p { max-width: 62ch; margin: 0 0 var(--space-3); }
.ledger th { font-weight: 600; color: var(--color-muted); }
.story { margin: var(--space-10) 0; display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: var(--space-6); }
.story figure { margin: 0; }
.story img { width: 100%; height: auto; display: block; border-radius: var(--radius); }
.hero img, .story img { width: 100%; height: auto; display: block; border-radius: var(--radius); }
.story figcaption { color: var(--color-muted); margin-top: var(--space-1); }
.close { margin: var(--space-10) 0 var(--space-8); padding-top: var(--space-6); border-top: 1px solid var(--color-line); }
.close h2 { font-size: clamp(2.75rem, 5.5vw, 5rem); max-width: 18ch; }
footer { padding: var(--space-4) 0 var(--space-6); color: var(--color-muted); }
@media (max-width: 60em) { .hero, .story { grid-template-columns: minmax(0, 1fr); } }
@media (prefers-reduced-motion: reduce) { .button { transition: none; } }
@@ -74,7 +75,7 @@ footer { padding: var(--space-4) 0 var(--space-6); color: var(--color-muted); }
</div>
</div>
<figure>
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='420'%3E%3Crect width='600' height='420' fill='%23e8e1d3'/%3E%3C/svg%3E" width="600" height="420" alt="The October close screen, 1,140 invoices reconciled">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='500' height='620'%3E%3Crect width='500' height='620' fill='%23e8e1d3'/%3E%3C/svg%3E" width="500" height="620" alt="The October close screen, 1,140 invoices reconciled">
<figcaption>The close screen on 2 November 2026.</figcaption>
</figure>
</section>
@@ -98,7 +99,7 @@ footer { padding: var(--space-4) 0 var(--space-6); color: var(--color-muted); }
</figure>
<div>
<h2>Where the seven days went</h2>
<p>Three days were matching freight receipts to invoices by hand. Two were chasing the four carriers whose statements arrive as scanned paper. The last two were the review itself, which nobody could start until the first five were done.</p>
<p>Three days were matching freight receipts to invoices by hand. Two were chasing the four carriers whose statements arrive as scanned paper. The last two were the review itself.</p>
<p>Ledgerline matches receipts as they land and reads the scanned statements the same afternoon, so the review starts on the first of the month.</p>
</div>
</section>
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 B