feat: strengthen design skill and refresh all showcases
Some checks failed
ci / build (push) Has been cancelled

This commit is contained in:
Yun Chan 2026-09-12 18:16:33 +09:00
parent 69e5cc1efd
commit e09d4efb1c
257 changed files with 8196 additions and 2374 deletions

View file

@ -0,0 +1,337 @@
// Focused browser instrumentation for five interactive showcase routes.
// This records the implemented browser behaviour; it is not a visual-accuracy test.
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import url from "node:url";
import puppeteer from "puppeteer-core";
import { resolveChrome, startPublicServer } 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_INSTRUMENT_PUBLIC_ROOT || path.join(siteRoot, "public"));
const reportPath = path.resolve(process.env.SHOWCASE_INSTRUMENT_REPORT || path.join(siteRoot, "instrument-showcases-e2e.json"));
const showcases = [
{ id: "lumina", route: "/work/lumina-optics/" },
{ id: "orbit", route: "/work/orbit-aerospace/" },
{ id: "synapse", route: "/work/synapse-bci/" },
{ id: "overtone", route: "/work/overtone-sound/" },
{ id: "pulse", route: "/work/pulsegate/" },
];
const viewports = [
{ name: "desktop", width: 1440, height: 1000 },
{ name: "mobile", width: 390, height: 844 },
];
function writePartial(report) {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
}
function attachEvents(page) {
const events = { consoleErrors: [], pageErrors: [], httpErrors: [], requestFailures: [] };
page.on("console", (message) => {
if (message.type() === "error") events.consoleErrors.push(message.text());
});
page.on("pageerror", (error) => events.pageErrors.push(error.message));
page.on("response", (response) => {
if (response.status() >= 400) events.httpErrors.push(`${response.status()} ${response.url()}`);
});
page.on("requestfailed", (request) => {
events.requestFailures.push(`${request.url()} ${request.failure()?.errorText || "request failed"}`);
});
return events;
}
async function settle(page, milliseconds = 300) {
const assets = 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({ src: image.currentSrc || image.src, message: String(error) });
}
}
return {
brokenImages: images.filter((image) => image.complete && image.naturalWidth === 0).map((image) => image.currentSrc || image.src),
decodeFailures,
};
});
await new Promise((resolve) => setTimeout(resolve, milliseconds));
return assets;
}
async function waitForScrollSettled(page, timeout = 3000) {
await page.evaluate(async (limit) => {
const startedAt = performance.now();
let stableFrames = 0;
let previous = { x: scrollX, y: scrollY };
await new Promise((resolve, reject) => {
const tick = () => {
const current = { x: scrollX, y: scrollY };
stableFrames = current.x === previous.x && current.y === previous.y ? stableFrames + 1 : 0;
previous = current;
if (stableFrames >= 5) return resolve();
if (performance.now() - startedAt > limit) return reject(new Error("scroll did not settle"));
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
}, timeout);
}
function digest(data) {
return crypto.createHash("sha256").update(data).digest("hex");
}
async function captureCanvasSamples(page, selector, count) {
const dataUrls = [];
for (let index = 0; index < count; index += 1) {
const result = await page.$eval(selector, (canvas) => {
if (!(canvas instanceof HTMLCanvasElement)) throw new Error("target is not a canvas");
return canvas.toDataURL();
});
dataUrls.push(result);
if (index < count - 1) await new Promise((resolve) => setTimeout(resolve, 100));
}
return dataUrls.map((dataUrl) => ({ sha256: digest(dataUrl), bytes: Buffer.byteLength(dataUrl) }));
}
async function prepareCanvasObservation(page, selector) {
await page.$eval(selector, (canvas) => canvas.scrollIntoView({ block: "center", behavior: "instant" }));
await waitForScrollSettled(page);
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await new Promise((resolve) => setTimeout(resolve, 100));
}
async function sampleCanvasMode(page, selector) {
await prepareCanvasObservation(page, selector);
const visible = await captureCanvasSamples(page, selector, 3);
const offscreen = await page.$eval(selector, (canvas) => {
const rect = canvas.getBoundingClientRect();
window.scrollTo({ top: scrollY + rect.bottom + 80, behavior: "instant" });
return { beforeTop: rect.top, beforeBottom: rect.bottom };
});
await waitForScrollSettled(page);
await new Promise((resolve) => setTimeout(resolve, 100));
const offscreenState = await page.$eval(selector, (canvas) => {
const rect = canvas.getBoundingClientRect();
return { top: Math.round(rect.top), bottom: Math.round(rect.bottom), isOffscreen: rect.bottom <= 0 || rect.top >= innerHeight };
});
const stationary = await captureCanvasSamples(page, selector, 2);
return {
visibleDataUrlSamples: visible,
visibleDistinctFrames: new Set(visible.map((sample) => sample.sha256)).size,
offscreenStart: offscreen,
offscreen: offscreenState,
offscreenDataUrlSamples: stationary,
offscreenDistinctFrames: new Set(stationary.map((sample) => sample.sha256)).size,
};
}
async function runLumina(page, reduced) {
const state = await page.evaluate(() => ({
reduced: matchMedia("(prefers-reduced-motion: reduce)").matches,
canvasPresent: Boolean(document.querySelector("#gl")),
fallbackPresent: Boolean(document.querySelector(".bench-fallback")),
note: document.querySelector("#hud-note")?.textContent.trim() || null,
}));
if (reduced) {
return {
state,
staticFallbackPresent: state.reduced && !state.canvasPresent && state.fallbackPresent && /Motion reduced/i.test(state.note || ""),
};
}
const controls = [];
for (const [input, output] of [["#ior", "#ior-out"], ["#disp", "#disp-out"]]) {
const before = await page.evaluate(([inputSelector, outputSelector]) => ({
input: document.querySelector(inputSelector)?.value || null,
output: document.querySelector(outputSelector)?.textContent.trim() || null,
}), [input, output]);
let after = null;
if (state.canvasPresent) {
await page.focus(input);
await page.keyboard.press("ArrowRight");
after = await page.evaluate(([inputSelector, outputSelector]) => ({
input: document.querySelector(inputSelector)?.value || null,
output: document.querySelector(outputSelector)?.textContent.trim() || null,
}), [input, output]);
}
controls.push({ input, output, before, after, outputChanged: after ? after.output !== before.output : null });
}
return {
state,
webglAvailable: state.canvasPresent,
controls,
controlsTested: state.canvasPresent,
};
}
async function runCanvasContract(page, selector, reduced) {
const canvasPresent = await page.$(selector);
if (!canvasPresent) return { canvasPresent: false, mode: reduced ? "reduced" : "normal" };
const samples = await sampleCanvasMode(page, selector);
return { canvasPresent: true, mode: reduced ? "reduced" : "normal", ...samples };
}
async function readAnchorAndTableContract(page) {
const anchorData = await page.evaluate(() => [...document.querySelectorAll('a[href^="#"]')]
.map((anchor, index) => ({ index, href: anchor.getAttribute("href"), text: anchor.textContent.trim() }))
.filter((anchor) => anchor.href && anchor.href.length > 1));
const tabReach = [];
await page.evaluate(() => document.activeElement?.blur());
for (let index = 0; index < 40; index += 1) {
await page.keyboard.press("Tab");
const focus = await page.evaluate(() => {
const active = document.activeElement;
return active instanceof HTMLAnchorElement && active.getAttribute("href")?.startsWith("#")
? { href: active.getAttribute("href"), text: active.textContent.trim() }
: null;
});
if (focus) tabReach.push(focus);
}
const anchors = [];
for (const anchor of anchorData) {
const target = await page.evaluate((hash) => {
const target = document.getElementById(hash.slice(1));
return target ? { exists: true, tag: target.tagName, text: target.textContent.trim().slice(0, 120) } : { exists: false };
}, anchor.href);
await page.evaluate((anchorIndex) => document.querySelectorAll('a[href^="#"]')[anchorIndex]?.focus(), anchor.index);
await page.keyboard.press("Enter");
await waitForScrollSettled(page);
const navigation = await page.evaluate((hash) => ({
hash: location.hash,
scrollY: Math.round(scrollY),
targetTop: document.getElementById(hash.slice(1))?.getBoundingClientRect().top ?? null,
}), anchor.href);
anchors.push({ ...anchor, target, reachedByTab: tabReach.some((entry) => entry.href === anchor.href), navigation });
}
const tables = await page.evaluate(() => [...document.querySelectorAll("table, [role=table]")].map((table) => {
const headers = [...table.querySelectorAll("thead th, [role=columnheader]")].map((cell) => cell.textContent.trim()).filter(Boolean);
const rows = [...table.querySelectorAll("tbody tr, [role=row]")].map((row) => [...row.querySelectorAll("th, td, [role=cell], [role=rowheader]")]
.map((cell) => cell.textContent.trim()).filter(Boolean)).filter((row) => row.length);
return {
caption: table.querySelector("caption")?.textContent.trim() || null,
headers,
firstRows: rows.slice(0, 3),
readable: headers.length > 0 && rows.length > 0,
};
}));
return { anchors, tabReach, tables };
}
function hardFailures(row) {
const failures = [];
for (const [mode, events] of Object.entries(row.events)) {
if (events.consoleErrors.length) failures.push(`${mode}: console errors ${events.consoleErrors.length}`);
if (events.pageErrors.length) failures.push(`${mode}: page errors ${events.pageErrors.length}`);
if (events.httpErrors.length) failures.push(`${mode}: HTTP responses >=400 ${events.httpErrors.length}`);
if (events.requestFailures.length) failures.push(`${mode}: request failures ${events.requestFailures.length}`);
}
if (row.assets.brokenImages.length) failures.push(`broken images ${row.assets.brokenImages.length}`);
if (row.observations.executionError) return [...failures, "runner: interaction observation did not complete"];
if (row.id === "lumina") {
if (!row.observations.reduced.staticFallbackPresent) failures.push("lumina: reduced-motion static fallback missing");
if (row.observations.normal.controlsTested && row.observations.normal.controls.some((control) => !control.outputChanged)) failures.push("lumina: range input did not update its output");
}
if (row.id === "synapse" || row.id === "overtone") {
const normal = row.observations.normal.canvas;
const reduced = row.observations.reduced.canvas;
if (!normal.canvasPresent) failures.push(`${row.id}: normal canvas missing`);
else {
if (normal.visibleDistinctFrames < 2) failures.push(`${row.id}: normal canvas did not change across visible samples`);
if (normal.offscreen.isOffscreen && normal.offscreenDistinctFrames > 1) failures.push(`${row.id}: offscreen canvas continued changing`);
}
if (!reduced.canvasPresent) failures.push(`${row.id}: reduced-motion canvas missing`);
else if (reduced.visibleDistinctFrames > 1 || (reduced.offscreen.isOffscreen && reduced.offscreenDistinctFrames > 1)) failures.push(`${row.id}: reduced-motion canvas continued changing`);
}
if (row.id === "orbit" || row.id === "pulse") {
const { anchors, tables } = row.observations.normal.content;
if (anchors.some((anchor) => !anchor.target.exists)) failures.push(`${row.id}: anchor target missing`);
if (anchors.some((anchor) => !anchor.reachedByTab)) failures.push(`${row.id}: internal anchor not reached by Tab`);
if (!tables.length || tables.some((table) => !table.readable)) failures.push(`${row.id}: table is not readable`);
}
return failures;
}
async function visit(browser, origin, showcase, viewport, reduced) {
const page = await browser.newPage();
const events = attachEvents(page);
try {
await page.setViewport({ width: viewport.width, height: viewport.height, isMobile: viewport.width < 500, hasTouch: viewport.width < 500 });
if (reduced) await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]);
await page.goto(`${origin}${showcase.route}`, { waitUntil: "networkidle0", timeout: 30000 });
const assets = await settle(page);
let observation;
if (showcase.id === "lumina") observation = await runLumina(page, reduced);
else if (showcase.id === "synapse") observation = { canvas: await runCanvasContract(page, "#raster", reduced) };
else if (showcase.id === "overtone") observation = { canvas: await runCanvasContract(page, "#wave", reduced) };
else observation = { content: await readAnchorAndTableContract(page) };
return { events, assets, observation };
} finally {
await page.close();
}
}
const report = {
generatedAt: new Date().toISOString(),
publicRoot,
routes: showcases,
viewports,
rows: [],
runnerErrors: [],
};
let server;
let browser;
try {
if (!fs.existsSync(publicRoot)) throw new Error(`public root not found: ${publicRoot}`);
server = await startPublicServer(publicRoot);
browser = await puppeteer.launch({
executablePath: resolveChrome(),
headless: "new",
args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"],
});
for (const showcase of showcases) {
for (const viewport of viewports) {
console.log(`RUN ${showcase.id} ${viewport.name}`);
const row = { id: showcase.id, route: showcase.route, viewport, events: {}, assets: { brokenImages: [], decodeFailures: [] }, observations: {}, hardFailures: [] };
try {
const normal = await visit(browser, server.origin, showcase, viewport, false);
const reduced = await visit(browser, server.origin, showcase, viewport, true);
row.events = { normal: normal.events, reduced: reduced.events };
row.assets = {
brokenImages: [...new Set([...normal.assets.brokenImages, ...reduced.assets.brokenImages])],
decodeFailures: [...normal.assets.decodeFailures, ...reduced.assets.decodeFailures],
};
row.observations = { normal: normal.observation, reduced: reduced.observation };
} catch (error) {
row.events = { runner: { consoleErrors: [], pageErrors: [error.stack || error.message], httpErrors: [], requestFailures: [] } };
row.observations = { executionError: error.stack || error.message };
}
row.hardFailures = hardFailures(row);
report.rows.push(row);
writePartial(report);
console.log(`${row.hardFailures.length ? "FAIL" : "PASS"} ${showcase.id} ${viewport.name}${row.hardFailures.length ? ` — ${row.hardFailures.join(" · ")}` : ""}`);
}
}
} catch (error) {
report.runnerErrors.push(error.stack || error.message);
writePartial(report);
throw error;
} finally {
if (browser) await browser.close();
if (server) await server.close();
}
const failureCount = report.rows.reduce((count, row) => count + row.hardFailures.length, 0);
console.log(`SUMMARY rows=${report.rows.length} hardFailures=${failureCount}`);
if (failureCount || report.runnerErrors.length) process.exitCode = 1;