// Showcase typography stress diagnostic. This records browser geometry; it is not a WCAG conformance certification. import fs from "node:fs"; import path from "node:path"; import url from "node:url"; import puppeteer from "puppeteer-core"; import { startPublicServer, routeForFile } from "./capture-stable.mjs"; const here = path.dirname(url.fileURLToPath(import.meta.url)); const siteRoot = path.resolve(here, ".."); const publicRoot = path.resolve(process.env.SHOWCASE_STRESS_PUBLIC_ROOT || path.join(siteRoot, "public")); const showcaseRoot = path.join(publicRoot, "work"); const reportPath = path.resolve(process.env.SHOWCASE_STRESS_REPORT || path.join(siteRoot, "showcase-stress-report.json")); const outputDirectory = path.resolve(process.env.SHOWCASE_STRESS_OUTPUT || path.join(siteRoot, "showcase-stress-artifacts")); const expectedRouteCount = Number.parseInt(process.env.SHOWCASE_STRESS_EXPECTED_ROUTES || "28", 10); const modes = [ { name: "normal-390", width: 390, height: 844, stress: "normal" }, { name: "text200-390", width: 390, height: 844, stress: "text200" }, { name: "spacing-390", width: 390, height: 844, stress: "spacing" }, { name: "reduced-motion-1440", width: 1440, height: 1000, stress: "reduced-motion" }, ]; const chrome = [ process.env.CHROME_PATH, "C:/Program Files/Google/Chrome/Application/chrome.exe", "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", ].find((candidate) => candidate && fs.existsSync(candidate)); if (!chrome) throw new Error("Chrome/Chromium executable not found. Set CHROME_PATH."); if (!Number.isInteger(expectedRouteCount) || expectedRouteCount < 1) throw new Error("SHOWCASE_STRESS_EXPECTED_ROUTES must be a positive integer."); fs.mkdirSync(outputDirectory, { recursive: true }); function collectHtmlFiles(directory) { const files = []; const walk = (current) => { for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const candidate = path.join(current, entry.name); if (entry.isDirectory()) { if (entry.name !== "assets") walk(candidate); } else if (entry.name.endsWith(".html")) { files.push(candidate); } } }; walk(directory); return files.sort(); } function attachBrowserEvents(page) { const events = { consoleErrors: [], pageErrors: [], requestFailures: [], httpErrors: [] }; page.on("console", (message) => { if (message.type() === "error") events.consoleErrors.push(message.text()); }); page.on("pageerror", (error) => events.pageErrors.push(error.message)); page.on("requestfailed", (request) => events.requestFailures.push(`${request.url()} ${request.failure()?.errorText || ""}`)); page.on("response", (response) => { if (response.status() >= 400) events.httpErrors.push(`${response.status()} ${response.url()}`); }); return events; } async function settle(page) { const result = await page.evaluate(async () => { await document.fonts.ready; const images = [...document.images]; for (const image of images) image.loading = "eager"; const decodeFailures = []; for (const image of images) { try { await image.decode(); } catch (error) { decodeFailures.push(`${image.currentSrc || image.src}: ${error}`); } } return { imageFailures: [...images.filter((image) => image.complete && image.naturalWidth === 0).map((image) => image.currentSrc || image.src), ...decodeFailures], fontErrors: [...document.fonts].filter((face) => face.status === "error").map((face) => ({ family: face.family, weight: face.weight, status: face.status })), }; }); await new Promise((resolve) => setTimeout(resolve, 300)); return result; } async function applyStressMode(page, mode) { if (mode.stress === "reduced-motion") return; if (mode.stress === "normal") return; await page.evaluate((stress) => { if (stress === "text200") { // Snapshot every baseline computed size before changing the root. Applying those doubled values inline // makes rem/em and fixed-px text exactly 2x, rather than multiplying root-scaled text twice. const baseline = [...document.body.querySelectorAll("*")].map((element) => ({ element, fontSize: parseFloat(getComputedStyle(element).fontSize) })); document.documentElement.style.fontSize = "200%"; for (const { element, fontSize } of baseline) { if (Number.isFinite(fontSize) && fontSize > 0) element.style.setProperty("font-size", `${fontSize * 2}px`, "important"); } return; } const style = document.createElement("style"); style.id = "showcase-stress-spacing"; style.textContent = "body * { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; } p { margin-block: 2em !important; }"; document.head.append(style); }, mode.stress); } async function sampleTabFocus(page) { await page.evaluate(() => document.activeElement?.blur()); const sequence = []; for (let index = 0; index < 10; index += 1) { await page.keyboard.press("Tab"); sequence.push(await page.evaluate(() => { const active = document.activeElement; const rect = active?.getBoundingClientRect(); const style = active ? getComputedStyle(active) : null; return { tag: active?.tagName || null, id: active?.id || null, className: active?.className || "", rect: rect ? { left: Math.round(rect.left), top: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height) } : null, outlineStyle: style?.outlineStyle || null, outlineWidth: style?.outlineWidth || null, }; })); } return sequence; } async function collectGeometry(page) { return page.evaluate(() => { const isHiddenModal = (element) => { const dialog = element.closest("dialog"); return Boolean(dialog && !dialog.open) || Boolean(element.closest("[hidden], [aria-hidden=\"true\"]")); }; const visible = (element) => { const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0 && !isHiddenModal(element); }; const excluded = (element) => element.matches("input, textarea, canvas") || Boolean(element.closest("input, textarea, canvas")); const selector = (element) => element.id ? `#${element.id}` : `${element.tagName.toLowerCase()}${[...element.classList].slice(0, 2).map((name) => `.${name}`).join("")}`; const textElements = [...document.querySelectorAll("p,h1,h2,h3,h4,h5,h6,li,dt,dd,a,button,label,output,figcaption,td,th,span")] .filter((element) => visible(element) && !excluded(element) && element.childNodes.length && [...element.childNodes].some((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim())); const clipping = textElements.map((element) => { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); const clipped = (style.overflowX === "hidden" || style.overflowX === "clip" || style.textOverflow !== "clip") && element.scrollWidth > element.clientWidth + 1; return clipped ? { selector: selector(element), text: element.textContent.trim().slice(0, 80), rect: { left: Math.round(rect.left), right: Math.round(rect.right), top: Math.round(rect.top), bottom: Math.round(rect.bottom) }, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, overflowX: style.overflowX, whiteSpace: style.whiteSpace } : null; }).filter(Boolean).slice(0, 30); const overlaps = []; for (let first = 0; first < textElements.length; first += 1) { const a = textElements[first]; const aRect = a.getBoundingClientRect(); for (let second = first + 1; second < textElements.length; second += 1) { const b = textElements[second]; if (a.contains(b) || b.contains(a)) continue; const bRect = b.getBoundingClientRect(); const horizontal = Math.min(aRect.right, bRect.right) - Math.max(aRect.left, bRect.left); const vertical = Math.min(aRect.bottom, bRect.bottom) - Math.max(aRect.top, bRect.top); if (horizontal > 2 && vertical > 2) overlaps.push({ first: selector(a), second: selector(b), overlap: { width: Math.round(horizontal), height: Math.round(vertical) } }); if (overlaps.length >= 30) break; } if (overlaps.length >= 30) break; } return { wholePage: { scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1 }, textClippingCandidates: clipping, textOverlapCandidates: overlaps, brokenImages: [...document.images].filter((image) => image.complete && image.naturalWidth === 0).map((image) => image.currentSrc || image.src), }; }); } function hardFailures(row) { const failures = []; if (row.geometry.brokenImages.length) failures.push(`broken images ${row.geometry.brokenImages.length}`); if (row.events.consoleErrors.length) failures.push(`console errors ${row.events.consoleErrors.length}`); if (row.events.pageErrors.length) failures.push(`page errors ${row.events.pageErrors.length}`); if (row.events.requestFailures.length) failures.push(`network failures ${row.events.requestFailures.length}`); if (row.events.httpErrors.length) failures.push(`HTTP responses >=400 ${row.events.httpErrors.length}`); if (row.fontErrors.length) failures.push(`font status errors ${row.fontErrors.length}`); return failures; } function screenshotPath(route, mode) { return path.join(outputDirectory, `${route.replace(/^\//, "").replace(/[\\/]/g, "__").replace(/\.html$/, "")}-${mode}.png`); } function writePartial(report) { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); } const report = { generatedAt: new Date().toISOString(), publicRoot, expectedRouteCount, routes: [], modes: modes.map((mode) => mode.name), rows: [], runnerErrors: [] }; let server; let browser; try { if (!fs.existsSync(showcaseRoot) || !fs.statSync(showcaseRoot).isDirectory()) throw new Error(`showcase root not found: ${showcaseRoot}`); const files = collectHtmlFiles(showcaseRoot); report.routes = files.map((file) => routeForFile(publicRoot, file)); if (report.routes.length !== expectedRouteCount) throw new Error(`route count ${report.routes.length}; expected ${expectedRouteCount}`); server = await startPublicServer(publicRoot); browser = await puppeteer.launch({ executablePath: chrome, headless: "new", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"] }); for (const route of report.routes) { for (const mode of modes) { console.log(`RUN ${route} ${mode.name}`); const page = await browser.newPage(); const row = { route, mode: mode.name, width: mode.width, rawObservations: null, focusOrder: [], events: null, hardFailures: [] }; const events = attachBrowserEvents(page); try { await page.setViewport({ width: mode.width, height: mode.height, isMobile: mode.width < 500, hasTouch: mode.width < 500 }); await page.emulateMediaFeatures(mode.stress === "reduced-motion" ? [{ name: "prefers-reduced-motion", value: "reduce" }] : []); await page.goto(`${server.origin}${route}`, { waitUntil: "networkidle0", timeout: 30000 }); await applyStressMode(page, mode); const settled = await settle(page); row.geometry = await collectGeometry(page); row.geometry.brokenImages = [...new Set([...row.geometry.brokenImages, ...settled.imageFailures])]; row.fontErrors = settled.fontErrors; row.focusOrder = await sampleTabFocus(page); if (mode.stress === "text200" || mode.stress === "spacing") { const file = screenshotPath(route, mode.name); await page.screenshot({ path: file, fullPage: true }); row.screenshot = file; } } catch (error) { events.pageErrors.push(`stress runner: ${error.stack || error.message}`); row.geometry = { wholePage: null, textClippingCandidates: [], textOverlapCandidates: [], brokenImages: [] }; row.fontErrors = []; } finally { await page.close(); } row.events = events; row.fontErrors ||= []; row.rawObservations = { wholePage: row.geometry.wholePage, textClippingCandidates: row.geometry.textClippingCandidates, textOverlapCandidates: row.geometry.textOverlapCandidates }; row.hardFailures = hardFailures(row); report.rows.push(row); writePartial(report); console.log(`${row.hardFailures.length ? "FAIL" : "PASS"} ${route} ${mode.name}${row.hardFailures.length ? ` — ${row.hardFailures.join(" · ")}` : ""}`); } } } catch (error) { report.runnerErrors.push(error.stack || error.message); } finally { if (browser) await browser.close(); if (server) await server.close(); writePartial(report); const failedRows = report.rows.filter((row) => row.hardFailures.length); console.log(`\n${failedRows.length || report.runnerErrors.length ? "✗" : "✓"} typography stress diagnostic — rows ${report.rows.length}, hard failures ${failedRows.length}`); process.exitCode = failedRows.length || report.runnerErrors.length ? 1 : 0; }