Skills › Visual Explainer › visual-explainer › look.mjs
look.mjs
426 lines · plugins/visual-explainer/kit/look.mjs at 9d5f880
1// Opens a rendered kit page in headless Chrome and looks at it the way a reader would, so the agent does not script its own browser:
2// script errors, sideways scrolling, labels under 12 px, text that overlaps or runs out of its drawing, headings that sit on what
3// follows, every stepper step, and a contact sheet of the first screen, the figures, and the steps. playwright-core is an optional peer.
4import { createRequire } from "node:module";
5import { readdirSync, rmSync, writeFileSync } from "node:fs";
6import { basename, dirname, join, resolve } from "node:path";
7import { fileURLToPath, pathToFileURL } from "node:url";
9const WIDTHS = [390, 1280];
10const MIN_LABEL = 12;
11const MIN_HEADING_GAP = 8;
12// ≈1.15 megapixels, the most a model takes in before it scales an image down
13const SHEET_W = 1200, SHEET_H = 960, PAD = 16, GAP = 12, LABEL = 22, MAX_SHEETS = 3;
14// a font still downloading after load must not hold the render; a late font only shifts glyphs slightly
15const fontsReady = () => Promise.race([document.fonts.ready, new Promise((r) => setTimeout(r, 5000))]);
17async function launch() {
18 let chromium;
19 try {
20 ({ chromium } = await import("playwright-core"));
21 } catch (e) {
22 if (e.code !== "ERR_MODULE_NOT_FOUND") throw e;
23 // import() never looks in global installs, so it goes beside the plugin
24 const plugin = dirname(dirname(fileURLToPath(import.meta.url)));
25 return { skipped: `playwright-core is not installed. Run: npm install --no-save --prefix "${plugin}" playwright-core (in a repository checkout, npm install is enough)` };
26 }
27 try {
28 return { browser: await chromium.launch() };
29 } catch (e) {
30 try {
31 return { browser: await chromium.launch({ channel: "chrome" }) };
32 } catch {
33 const { version } = createRequire(import.meta.url)("playwright-core/package.json");
34 return { skipped: `no browser started (${e.message.split("\n")[0]}). Install Google Chrome, or run: npx playwright-core@${version} install chromium-headless-shell` };
35 }
36 }
37}
39// Runs in the page: the smallest rendered SVG label, overflow, figure and prose counts, and whether the kit's script finished.
40function measure(minLabel) {
41 let smallest = null;
42 for (const t of document.querySelectorAll("svg text")) {
43 if (!t.textContent.trim() || !t.checkVisibility({ visibilityProperty: true })) continue;
44 const m = t.getScreenCTM();
45 if (!m) continue;
46 const px = parseFloat(getComputedStyle(t).fontSize) * Math.hypot(m.a, m.b);
47 if (!smallest || px < smallest.px) smallest = { px: Math.round(px * 100) / 100, text: t.textContent.trim().slice(0, 40) };
48 }
49 // SVG text with no fill gets the default black, which no kit token is; it vanishes on a dark scheme. A page whose own text is black is exempt.
50 const black = "rgb(0, 0, 0)";
51 const unfilled = getComputedStyle(document.body).color === black ? [] : [...document.querySelectorAll("svg text")].filter((t) => t.textContent.trim() && !t.closest("defs, marker, symbol, clipPath, mask, pattern") && t.checkVisibility() && getComputedStyle(t).fill === black);
52 let prose = 0;
53 for (const p of document.querySelectorAll("p, li")) if (!p.closest("figure") && !p.parentElement.closest("p, li")) prose += p.textContent.trim().split(/\s+/).filter(Boolean).length;
54 return {
55 overflow: document.documentElement.scrollWidth > window.innerWidth,
56 smallest,
57 small: smallest !== null && smallest.px < minLabel,
58 unfilled: unfilled.length ? { n: unfilled.length, text: unfilled[0].textContent.trim().slice(0, 40) } : null,
59 figures: document.querySelectorAll("figure").length,
60 prose,
61 kit: document.documentElement.dataset.veKit === "ready",
62 };
63}
65// Runs in the page: visible text that overlaps other text or runs past its drawing, in one stepper (r) or in every drawing.
66function inspect(r) {
67 const figs = [...document.querySelectorAll("main figure")];
68 const scope = r === null ? document : document.querySelectorAll(".ve-steps")[r];
69 const short = (s) => s.trim().replace(/\s+/g, " ").slice(0, 32);
70 const name = (svg) => {
71 const f = svg.closest("figure"), i = figs.indexOf(f);
72 const capEl = f?.querySelector("figcaption");
73 const capText = capEl ? [...capEl.childNodes].filter((n) => !n.classList?.contains("fig-n")).map((n) => n.textContent).join("") : "";
74 const cap = short(capText || svg.getAttribute("aria-label") || "");
75 return `${i >= 0 ? `Fig ${i + 1}` : "A drawing"}${cap ? ` ("${cap}…")` : ""}`;
76 };
77 const found = [];
78 // A text's box is its em box; the letters sit in about its middle 56%, so two stacked labels can touch boxes without touching letters.
79 const ink = (t) => { const r = t.getBoundingClientRect(), d = r.height * 0.22; return { left: r.left + 1, right: r.right - 1, top: r.top + d, bottom: r.bottom - d }; };
80 const area = (r) => (r.right - r.left) * (r.bottom - r.top);
81 // two texts that overlap are fine when an opaque shape painted between them covers the lower one (a job card over an idle slot)
82 const covered = (ta, tb) => {
83 const mid = (a, b) => [(Math.max(a.left, b.left) + Math.min(a.right, b.right)) / 2, (Math.max(a.top, b.top) + Math.min(a.bottom, b.bottom)) / 2];
84 scrollBy(0, mid(ink(ta), ink(tb))[1] - innerHeight / 2);
85 const stack = document.elementsFromPoint(...mid(ink(ta), ink(tb)));
86 const ia = stack.findIndex((e) => ta.contains(e)), ib = stack.findIndex((e) => tb.contains(e));
87 if (ia < 0 || ib < 0) return true;
88 return stack.slice(Math.min(ia, ib) + 1, Math.max(ia, ib)).some((e) => {
89 if (!(e instanceof SVGGeometryElement) || e instanceof SVGTextContentElement) return false;
90 const cs = getComputedStyle(e);
91 return cs.fill !== "none" && parseFloat(cs.fillOpacity) >= 0.9 && parseFloat(cs.opacity) >= 0.9;
92 });
93 };
94 // A label a border or line runs through or touches: a visible point of a shape's outline lies in the label's ink box, or within
95 // TOUCH px of its ends. A label fully inside a shape (a node's name) or clear of it never has one there. A point is not visible
96 // when an opaque shape painted later covers it (an edge under a callout's disc). A label with a --bg halo (paint-order: stroke,
97 // like .ve-el) is drawn to sit on what was painted before it, and a label wholly under an opaque shape painted after it is hidden.
98 // A line under 1.6:1 against the page (a --border rule, a ledger, a grid) is decoration, not a crossing.
99 const crossed = (svg, texts) => {
100 const TOUCH = 4;
101 const seen = (e) => { let o = 1; for (let n = e; n && n !== svg.parentElement; n = n.parentElement) o *= parseFloat(getComputedStyle(n).opacity); return o; };
102 const later = (a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING;
103 const lum = (c) => {
104 const n = (c.match(/rgba?\(([^)]*)\)/)?.[1] ?? c.match(/color\(srgb ([^)]*)\)/)?.[1] ?? "").split(/[\s,/]+/).filter(Boolean).map(Number);
105 if (n.length < 3 || n.some(Number.isNaN)) return null;
106 const [r, g, b] = (/^rgb/.test(c) ? n.slice(0, 3).map((v) => v / 255) : n.slice(0, 3)).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
107 return 0.2126 * r + 0.7152 * g + 0.0722 * b;
108 };
109 const probeBg = document.createElement("i"); probeBg.style.color = "var(--bg, #fff)"; document.body.append(probeBg);
110 const bg = lum(getComputedStyle(probeBg).color) ?? 1; probeBg.remove();
111 const faint = (c) => { const l = lum(c); return l !== null && (Math.max(l, bg) + 0.05) / (Math.min(l, bg) + 0.05) < 1.6; };
112 const shapes = [];
113 for (const e of svg.querySelectorAll("rect, circle, ellipse, line, polyline, polygon, path")) {
114 if (e.closest("defs, marker, symbol, clipPath, mask, pattern, text") || !e.checkVisibility({ opacityProperty: true, visibilityProperty: true }) || seen(e) < 0.3) continue;
115 // a filled path is an area even without a closing z (a datastore's body); a line, a polyline, or an unfilled open path is a line
116 const cs = getComputedStyle(e), filled = cs.fill !== "none" && !(e instanceof SVGLineElement) && !(e instanceof SVGPolylineElement);
117 const open = !filled && !(e instanceof SVGPathElement && /z\s*$/i.test(e.getAttribute("d") ?? "")) && !(e instanceof SVGRectElement || e instanceof SVGCircleElement || e instanceof SVGEllipseElement || e instanceof SVGPolygonElement);
118 const stroked = cs.stroke !== "none" && parseFloat(cs.strokeWidth) > 0 && parseFloat(cs.strokeOpacity) >= 0.3 && !faint(cs.stroke);
119 const opaque = filled && parseFloat(cs.fillOpacity) >= 0.9 && seen(e) >= 0.9;
120 const edged = stroked || (filled && parseFloat(cs.fillOpacity) >= 0.5 && !faint(cs.fill));
121 if (!edged && !opaque) continue;
122 const m = e.getScreenCTM(), inv = m.inverse(), len = e.getTotalLength(), step = 1.5 / (Math.hypot(m.a, m.b) || 1), pts = [];
123 if (edged) for (let s = 0; s <= len; s += step) { const p = e.getPointAtLength(s); pts.push(new DOMPoint(p.x, p.y).matrixTransform(m)); }
124 shapes.push({ e, pts, opaque, open, inside: (p) => e.isPointInFill(new DOMPoint(p.x, p.y).matrixTransform(inv)) });
125 }
126 const covers = (o, p) => o.opaque && o.inside(p);
127 const out = [];
128 for (const { t, s } of texts) {
129 // measured again: the overlap check above may have scrolled the page since texts were measured
130 const b = ink(t);
131 if (seen(t) < 0.3) continue;
132 const cs = getComputedStyle(t), halo = cs.paintOrder.startsWith("stroke") && cs.stroke !== "none" && parseFloat(cs.strokeWidth) >= 3;
133 const probe = [[b.left, b.top], [b.right, b.top], [b.left, b.bottom], [b.right, b.bottom], [(b.left + b.right) / 2, (b.top + b.bottom) / 2]].map(([x, y]) => ({ x, y }));
134 if (shapes.some((o) => later(t, o.e) && probe.every((p) => covers(o, p)))) continue;
135 let worst = null;
136 for (const sh of shapes) {
137 if (halo && !later(t, sh.e)) continue;
138 for (const p of sh.pts) {
139 if (p.x < b.left - TOUCH || p.x > b.right + TOUCH || p.y < b.top + 1 || p.y > b.bottom - 1) continue;
140 if (shapes.some((o) => o !== sh && later(sh.e, o.e) && covers(o, p))) continue;
141 const through = p.x > b.left && p.x < b.right;
142 if (!worst || (through && !worst.through)) worst = { through, open: sh.open };
143 if (through) break;
144 }
145 if (worst?.through) break;
146 }
147 if (worst) out.push(`"${s}": label ${worst.through ? "crossed by" : "touches"} a ${worst.open ? "line" : "border"}`);
148 }
149 return out;
150 };
151 for (const svg of scope.querySelectorAll("svg")) {
152 if (svg.parentElement.closest("svg") || !svg.closest("main")) continue;
153 const box = svg.getBoundingClientRect();
154 if (!box.width || !box.height) continue;
155 const texts = [...svg.querySelectorAll("text")]
156 .filter((t) => t.textContent.trim() && t.checkVisibility({ opacityProperty: true, visibilityProperty: true }))
157 .map((t) => ({ t, s: short(t.textContent), b: ink(t) }))
158 .filter(({ b }) => b.right > b.left && b.bottom > b.top);
159 // a drawing with overflow: visible (the data tags) paints past its box on purpose
160 if (getComputedStyle(svg).overflow !== "visible") {
161 for (const { s, b } of texts) {
162 if (b.left < box.left - 1 || b.right > box.right + 1 || b.top < box.top - 1 || b.bottom > box.bottom + 1) found.push({ where: name(svg), what: `"${s}" runs past the edge of its drawing` });
163 }
164 }
165 for (let i = 0; i < texts.length; i++) for (let j = i + 1; j < texts.length; j++) {
166 const a = texts[i].b, b = texts[j].b;
167 const x = Math.min(a.right, b.right) - Math.max(a.left, b.left), y = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
168 if (x > 1 && y > 1 && x * y > 0.1 * Math.min(area(a), area(b)) && !covered(texts[i].t, texts[j].t)) found.push({ where: name(svg), what: `"${texts[i].s}" overlaps "${texts[j].s}"` });
169 }
170 // charts and Pele's Mermaid put labels on their shapes by design, and the agent cannot move them
171 if (!svg.matches(".ve-bars, .ve-waffle, .ve-stack, .pele")) found.push(...crossed(svg, texts).map((what) => ({ where: name(svg), what })));
172 }
173 scrollTo(0, 0);
174 return found;
175}
177// Runs in the page: an h2 closer than minGap to what follows it.
178function headings(minGap) {
179 const found = [];
180 for (const h of document.querySelectorAll("main h2")) {
181 const block = h.parentElement.matches(".sec-head") ? h.parentElement : h;
182 const next = block.nextElementSibling;
183 if (!next || !next.checkVisibility()) continue;
184 const gap = next.getBoundingClientRect().top - h.getBoundingClientRect().bottom;
185 if (gap < minGap) found.push(`the heading "${h.textContent.trim().replace(/\s+/g, " ").slice(0, 40)}" sits ${Math.max(0, Math.round(gap))} px from the ${next.tagName.toLowerCase()} below it`);
186 }
187 return found;
188}
190// Runs in the page: moves stepper r to step k (0-based) with its own controls. False when it has none.
191function goStep([r, k]) {
192 const root = document.querySelectorAll(".ve-steps")[r];
193 const n = root.querySelectorAll(".ve-cap li").length;
194 const range = root.querySelector("input[type=range]");
195 if (range) { range.value = k; range.dispatchEvent(new Event("input", { bubbles: true })); return true; }
196 const go = [...root.querySelectorAll("[data-go]")];
197 const prev = go.find((b) => +b.dataset.go < 0), next = go.find((b) => +b.dataset.go > 0);
198 if (!prev || !next) return false;
199 for (let i = 0; i < n; i++) prev.click();
200 for (let i = 0; i < k; i++) next.click();
201 return true;
202}
204const pngSize = (png) => ({ w: png.readUInt32BE(16), h: png.readUInt32BE(20) });
206// Lays the crops out in rows on sheets of at most SHEET_W × SHEET_H: the first screens, then each figure, then each stepper's steps
207// as thumbnails. A page with more than fits on MAX_SHEETS is drawn smaller before anything is left out.
208function layout(items) {
209 const inner = SHEET_W - 2 * PAD, tallest = SHEET_H - 2 * PAD - LABEL;
210 const pack = (figScale, stepCols) => {
211 const rows = [];
212 for (const it of items) {
213 const scaled = it.cells.map((c) => {
214 const s = it.kind === "overview" ? 0.62 : it.kind === "steps" ? (inner - GAP * (stepCols - 1)) / stepCols / c.w : figScale;
215 return { ...c, s: Math.min(1, s, inner / c.w, tallest / c.h) };
216 });
217 // each group starts a row; its cells flow left to right
218 let row = [], width = -GAP;
219 for (const c of scaled) {
220 const w = Math.round(c.w * c.s) + GAP;
221 if (row.length && width + w > inner) { rows.push(row); row = []; width = -GAP; }
222 row.push(c); width += w;
223 }
224 if (row.length) rows.push(row);
225 }
226 return rows;
227 };
228 let rows = [];
229 for (const [figScale, stepCols] of [[0.8, 3], [0.65, 4], [0.55, 5]]) {
230 rows = pack(figScale, stepCols);
231 if (toSheets(rows).length <= MAX_SHEETS) break;
232 }
233 const sheets = toSheets(rows);
234 return { sheets: sheets.slice(0, MAX_SHEETS), omitted: sheets.slice(MAX_SHEETS).flat().flat().length };
235}
237function toSheets(rows) {
238 const sheets = [];
239 let cur = [], height = 2 * PAD - GAP;
240 const rowH = (row) => LABEL + Math.max(...row.map((c) => Math.round(c.h * c.s)));
241 for (const row of rows) {
242 const h = rowH(row) + GAP;
243 if (cur.length && height + h > SHEET_H) { sheets.push(cur); cur = []; height = 2 * PAD - GAP; }
244 cur.push(row); height += h;
245 }
246 if (cur.length) sheets.push(cur);
247 return sheets;
248}
250async function drawSheets(browser, sheets, outBase) {
251 const page = await browser.newPage({ viewport: { width: SHEET_W, height: SHEET_H } });
252 const paths = [];
253 try {
254 for (const [n, rows] of sheets.entries()) {
255 // the sheet is only as wide as its widest row: blank pixels cost the model as much as drawn ones
256 const html = `<!doctype html><body style="margin:0;background:#fff"><div id="s" style="width:max-content;max-width:${SHEET_W}px;box-sizing:border-box;padding:${PAD}px;display:flex;flex-direction:column;gap:${GAP}px;font:600 13px/18px system-ui,sans-serif;color:#333">${rows.map((row) => `<div style="display:flex;gap:${GAP}px;align-items:flex-start">${row.map((c) => `<div><div style="height:${LABEL - 4}px;margin-bottom:4px">${c.label}</div><img src="data:image/png;base64,${c.png.toString("base64")}" width="${Math.round(c.w * c.s)}" height="${Math.round(c.h * c.s)}" style="display:block;outline:1px solid #ccc"></div>`).join("")}</div>`).join("")}</div></body>`;
257 await page.setContent(html, { waitUntil: "load" });
258 const path = `${outBase}.sheet${n ? `-${n + 1}` : ""}.png`;
259 writeFileSync(path, await page.locator("#s").screenshot());
260 paths.push(path);
261 }
262 } finally {
263 await page.close();
264 }
265 return paths;
266}
268/**
269 * Looks at a rendered page. Returns { problems, sheets, widths, errors } or { skipped } when there is no browser.
270 * With `sheet`, writes <name>.sheet.png (and -2, -3 … when one would be too small to read) beside the page, replacing older ones.
271 */
272export async function lookAt(file, { sheet = true } = {}) {
273 const got = await launch();
274 if (got.skipped) return { skipped: got.skipped, problems: [], sheets: [] };
275 const { browser } = got;
276 const path = resolve(file), url = pathToFileURL(path).href;
277 const result = { file: path, errors: [], widths: {}, problems: [], sheets: [], omitted: 0 };
278 const geometry = new Map();
279 const note = (list, step) => {
280 for (const { where, what } of list) {
281 const key = `${where}: ${what}`;
282 if (!geometry.has(key)) geometry.set(key, []);
283 if (step && !geometry.get(key).includes(step)) geometry.get(key).push(step);
284 }
285 };
286 try {
287 // as a reader opens it, at each width
288 for (const width of WIDTHS) {
289 const page = await browser.newPage({ viewport: { width, height: 900 } });
290 page.on("console", (m) => {
291 if (m.type() !== "error") return;
292 // an offline or blocked fetch from another origin (the kit's web font) is the host's situation, not the page's
293 if (/^Failed to load resource: net::/.test(m.text()) && !m.location().url.startsWith("file:")) return;
294 result.errors.push(`console at ${width}px: ${m.text()}`);
295 });
296 page.on("pageerror", (e) => result.errors.push(`page error at ${width}px: ${e.message}`));
297 await page.goto(url, { waitUntil: "load", timeout: 15000 });
298 await page.evaluate(fontsReady);
299 await page.waitForTimeout(300);
300 result.widths[width] = await page.evaluate(measure, MIN_LABEL);
301 await page.close();
302 }
304 // the final state without motion: geometry, every step, and the crops for the sheet
305 const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, reducedMotion: "reduce", colorScheme: "light" });
306 const items = [];
307 try {
308 const page = await context.newPage();
309 await page.goto(url, { waitUntil: "load", timeout: 15000 });
310 await page.evaluate(fontsReady);
311 await page.waitForTimeout(200);
312 note(await page.evaluate(inspect, null));
313 const gaps = await page.evaluate(headings, MIN_HEADING_GAP);
314 const desktop = sheet ? await page.screenshot() : null;
315 await page.setViewportSize({ width: 390, height: 900 });
316 await page.waitForTimeout(100);
317 gaps.push(...await page.evaluate(headings, MIN_HEADING_GAP));
318 const phone = sheet ? await page.screenshot() : null;
319 await page.setViewportSize({ width: 1280, height: 900 });
320 await page.evaluate(() => scrollTo(0, 0));
321 await page.waitForTimeout(100);
322 result.problems.push(...new Set(gaps));
323 if (sheet) items.push({ kind: "overview", cells: [{ png: desktop, ...pngSize(desktop), label: "First screen · 1280" }, { png: phone, ...pngSize(phone), label: "390" }] });
325 // figures below the first screen, steppers aside (their steps follow)
326 const figs = await page.$$("main figure");
327 // a drawing wider than its frame scrolls sideways on the page; its crop shows all of it, then the page is put back
328 const shoot = async (el) => {
329 const clip = await el.evaluate((e) => {
330 const frames = [...(e.matches(".frame") ? [e] : []), ...e.querySelectorAll(".frame")].filter((f) => f.scrollWidth > f.clientWidth + 1);
331 e.__veOld = frames.map((f) => [f, f.getAttribute("style")]);
332 for (const f of frames) f.style.cssText += `;overflow:visible;width:${f.scrollWidth}px;max-width:none`;
333 const rs = [e, ...frames].map((x) => x.getBoundingClientRect());
334 const x = Math.min(...rs.map((r) => r.left)), y = Math.min(...rs.map((r) => r.top));
335 return { x: x + scrollX, y: y + scrollY, width: Math.max(...rs.map((r) => r.right)) - x, height: Math.max(...rs.map((r) => r.bottom)) - y };
336 });
337 try {
338 return await page.screenshot({ fullPage: true, clip });
339 } finally {
340 await el.evaluate((e) => { for (const [f, s] of e.__veOld) s === null ? f.removeAttribute("style") : f.setAttribute("style", s); delete e.__veOld; });
341 }
342 };
343 for (const [i, fig] of figs.entries()) {
344 const { top, stepped } = await fig.evaluate((f) => ({ top: f.getBoundingClientRect().bottom + scrollY, stepped: f.matches(".ve-steps") || !!f.querySelector(".ve-steps") }));
345 if (!sheet || stepped || top <= 900) continue;
346 const png = await shoot(fig);
347 items.push({ kind: "figure", cells: [{ png, ...pngSize(png), label: `Fig ${i + 1}` }] });
348 }
350 // each stepper, step by step
351 const steppers = await page.$$(".ve-steps");
352 for (const [r, root] of steppers.entries()) {
353 const n = await root.evaluate((e) => e.querySelectorAll(".ve-cap li").length);
354 const fig = (await root.evaluateHandle((e) => e.closest("figure") ?? e)).asElement();
355 // a step's thumbnail is its drawing; the caption is text the agent wrote
356 const drawing = (await root.evaluateHandle((e) => e.querySelector(".frame") ?? e.querySelector("svg") ?? e)).asElement();
357 const figN = await fig.evaluate((f) => [...document.querySelectorAll("main figure")].indexOf(f) + 1);
358 const cells = [];
359 for (let k = 0; k < n; k++) {
360 if (!(await page.evaluate(goStep, [r, k]))) break;
361 await page.waitForTimeout(60);
362 note(await page.evaluate(inspect, r), `${k + 1}`);
363 if (sheet) { const png = await shoot(drawing); cells.push({ png, ...pngSize(png), label: `${figN ? `Fig ${figN}` : "Stepper"} · step ${k + 1}/${n}` }); }
364 }
365 if (n) await page.evaluate(goStep, [r, n - 1]);
366 if (cells.length) items.push({ kind: "steps", cells });
367 }
369 // a page that sets its own colors gets its first screen in dark too
370 const custom = await page.evaluate(() => [...document.querySelectorAll("style:not([data-ve-kit])")].some((s) => /--(?:accent|bg|surface|text|border|ok|warn|risk|info)[\w-]*\s*:/.test(s.textContent)));
371 if (sheet && custom) {
372 const dark = await browser.newContext({ viewport: { width: 1280, height: 900 }, reducedMotion: "reduce", colorScheme: "dark" });
373 try {
374 const d = await dark.newPage();
375 await d.goto(url, { waitUntil: "load" });
376 await d.evaluate(fontsReady);
377 await d.waitForTimeout(200);
378 const png = await d.screenshot();
379 items.push({ kind: "figure", cells: [{ png, ...pngSize(png), label: "First screen · dark" }] });
380 } finally {
381 await dark.close();
382 }
383 }
384 } finally {
385 await context.close();
386 }
388 if (sheet) {
389 const outBase = path.replace(/\.html?$/i, "");
390 const own = new RegExp(`^${basename(outBase).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.sheet(?:-\\d+)?\\.png$`);
391 for (const f of readdirSync(dirname(path))) if (own.test(f)) rmSync(join(dirname(path), f));
392 const { sheets, omitted } = layout(items);
393 result.sheets = await drawSheets(browser, sheets, outBase);
394 result.omitted = omitted;
395 }
396 } catch (e) {
397 // the page is already written; a browser failure is a skipped check, not a failed render
398 return { skipped: `the visual check could not finish: ${String(e.message).split("\n")[0]}`, problems: [], sheets: [] };
399 } finally {
400 await browser.close();
401 }
403 const at = result.widths;
404 const shapes = [...geometry].map(([key, steps]) => (steps.length ? `${key} (step${steps.length > 1 ? "s" : ""} ${steps.join(", ")})` : key));
405 result.problems = [
406 ...result.errors,
407 ...WIDTHS.filter((w) => at[w].overflow).map((w) => `the page scrolls sideways at ${w}px`),
408 ...WIDTHS.filter((w) => at[w].small).map((w) => `a diagram label renders at ${at[w].smallest.px}px at ${w}px ("${at[w].smallest.text}"); keep labels at ${MIN_LABEL}px or more`),
409 ...(at[1280].kit ? [] : ["the kit's script did not run to its end"]),
410 ...(at[1280].unfilled ? [`${at[1280].unfilled.n} SVG label(s) have no fill, so they are black on a dark scheme ("${at[1280].unfilled.text}"); use a kit class or a token`] : []),
411 ...result.problems,
412 ...shapes,
413 ];
414 return result;
415}
417/** The lines every host prints after a render: problems as ✗, or the smallest label; then the sheet to look at. */
418export function lookLines(r) {
419 if (r.skipped) return [`Visual check skipped: ${r.skipped}`];
420 const shown = r.problems.slice(0, 12);
421 const lines = shown.length
422 ? [...shown.map((p) => `✗ ${p}`), ...(r.problems.length > shown.length ? [`✗ … and ${r.problems.length - shown.length} more`] : [])]
423 : [`✓ Visual check: no problems · smallest label ${WIDTHS.map((w) => `${r.widths[w].smallest?.px ?? "—"}px at ${w}`).join(", ")}`];
424 if (r.sheets.length) lines.push(`Look at: ${r.sheets.join(", ")}${r.omitted ? ` (${r.omitted} more crops left out)` : ""}`);
425 return lines;
426}