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,195 @@
// Stable, reproducible showcase captures.
//
// A thumbnail must show the composed page, not a font fallback, an unloaded image,
// a transition frame, or the next frame of a canvas loop. Keep the local HTTP
// server and settling contract shared by every capture entrypoint.
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
export const MIME = new Map([
[".avif", "image/avif"],
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".ico", "image/x-icon"],
[".jpeg", "image/jpeg"],
[".jpg", "image/jpeg"],
[".js", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".webmanifest", "application/manifest+json"],
[".webp", "image/webp"],
[".woff", "font/woff"],
[".woff2", "font/woff2"],
]);
export function resolveChrome() {
const candidates = [
process.env.CHROME_PATH,
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
];
const chrome = candidates.find((candidate) => candidate && fs.existsSync(candidate));
if (!chrome) throw new Error("Chrome/Chromium executable not found. Set CHROME_PATH or install Chrome/Chromium.");
return chrome;
}
function isInside(root, candidate) {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== ".." && !path.isAbsolute(relative));
}
export async function startPublicServer(publicRoot) {
const server = http.createServer((request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname);
const candidate = path.resolve(publicRoot, `.${pathname}`);
if (!isInside(publicRoot, candidate)) {
response.writeHead(403).end("forbidden");
return;
}
const file = fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()
? path.join(candidate, "index.html")
: candidate;
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
response.writeHead(404).end("not found");
return;
}
response.setHeader("Content-Type", MIME.get(path.extname(file).toLowerCase()) || "application/octet-stream");
response.setHeader("Cache-Control", "no-store");
fs.createReadStream(file).pipe(response);
} catch {
response.writeHead(400).end("bad request");
}
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
return {
origin: `http://127.0.0.1:${address.port}`,
close: () => new Promise((resolve) => server.close(resolve)),
};
}
export function routeForFile(publicRoot, file) {
const relative = path.relative(publicRoot, file).split(path.sep).join("/");
if (relative === "index.html") return "/";
return `/${relative.replace(/(?:^|\/)index\.html$/, "/")}`.replace(/\/+/g, "/");
}
export async function prepareStillPage(page) {
await page.setCacheEnabled(false);
await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]);
}
export function collectPageFailures(page, origin) {
const failures = [];
page.on("pageerror", (error) => failures.push(`pageerror ${error.message}`));
page.on("response", (response) => {
if (response.url().startsWith(origin) && response.status() >= 400) {
failures.push(`${response.status()} ${response.url()}`);
}
});
return failures;
}
async function waitForFonts(page) {
await Promise.race([
page.evaluate(() => document.fonts?.ready || Promise.resolve()),
new Promise((_, reject) => setTimeout(() => reject(new Error("Font readiness timed out")), 10000)),
]);
}
async function waitForImages(page) {
await page.evaluate(async () => {
const images = [...document.images];
for (const image of images) image.loading = "eager";
await Promise.all(images.map(async (image) => {
if (!image.complete) {
await new Promise((resolve) => {
const timer = window.setTimeout(resolve, 10000);
image.addEventListener("load", () => {
window.clearTimeout(timer);
resolve();
}, { once: true });
image.addEventListener("error", () => {
window.clearTimeout(timer);
resolve();
}, { once: true });
});
}
await image.decode().catch(() => undefined);
}));
});
const broken = await page.evaluate(() => [...document.images]
.filter((image) => image.complete && image.naturalWidth === 0)
.map((image) => image.currentSrc || image.src));
if (broken.length) throw new Error(`Broken images: ${broken.join(", ")}`);
}
async function stopTimeBasedMedia(page) {
await page.evaluate(async () => {
const media = [...document.querySelectorAll("video, audio")];
for (const item of media) {
item.pause();
if (item instanceof HTMLVideoElement && Number.isFinite(item.duration) && item.duration > 0) {
await new Promise((resolve) => {
const timer = window.setTimeout(resolve, 500);
item.addEventListener("seeked", () => {
window.clearTimeout(timer);
resolve();
}, { once: true });
item.currentTime = 0;
});
}
}
});
}
async function freezeAnimations(page) {
await page.evaluate(() => {
for (const animation of document.getAnimations({ subtree: true })) {
const timing = animation.effect?.getComputedTiming();
try {
if (timing?.iterations === Infinity) animation.pause();
else animation.finish();
} catch {
animation.pause();
}
}
// Existing rAF loops schedule their next frame after this callback completes.
// Replacing it here preserves the visually settled canvas while preventing a
// different frame from being captured on each run.
window.requestAnimationFrame = () => 0;
document.documentElement.dataset.thumbnailCapture = "settled";
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
});
await page.addStyleTag({
content: `
html[data-thumbnail-capture="settled"] *,
html[data-thumbnail-capture="settled"] *::before,
html[data-thumbnail-capture="settled"] *::after {
animation-play-state: paused !important;
transition-property: none !important;
caret-color: transparent !important;
}
html[data-thumbnail-capture="settled"] { scroll-behavior: auto !important; }
html[data-thumbnail-capture="settled"] ::-webkit-scrollbar { display: none !important; }
`,
});
}
export async function waitForStableVisual(page, { settleMs = 900 } = {}) {
await waitForFonts(page);
await waitForImages(page);
// Let the page apply its data and initial layout before freezing its final frame.
await new Promise((resolve) => setTimeout(resolve, settleMs));
await stopTimeBasedMedia(page);
await freezeAnimations(page);
await new Promise((resolve) => setTimeout(resolve, 80));
}