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
195
apps/site/tools/capture-stable.mjs
Normal file
195
apps/site/tools/capture-stable.mjs
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue