feat: strengthen design skill and refresh all showcases
Some checks failed
ci / build (push) Has been cancelled
Some checks failed
ci / build (push) Has been cancelled
This commit is contained in:
parent
69e5cc1efd
commit
e09d4efb1c
257 changed files with 8196 additions and 2374 deletions
248
apps/site/tools/showcase-stress.mjs
Normal file
248
apps/site/tools/showcase-stress.mjs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
// 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue