designpaca/apps/site/tools/showcase-audit.mjs
Yun Chan 9529e3747c
All checks were successful
ci / build (push) Successful in 33s
feat(showcase): 트웬티온스 사이트를 전면 개편한다
2026-08-25 03:50:28 +09:00

250 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 쇼케이스 실렌더 감사 — 전 폭 × 전 페이지에서 하드 게이트를 기계로 판정한다.
//
// 검사 항목
// 1. 가로 스크롤 (하드 게이트 5)
// 2. 뷰포트를 넘는 요소 (오버플로)
// 3. 버튼·내비 라벨 2줄 접힘 (하드 게이트 6)
// 4. 텍스트 대비 AA (하드 게이트 7) — 조상 배경을 걷어 올려 실제 배경으로 잰다
// 5. 왼쪽 인셋 최소값 (가장자리에 붙는 제목)
// 6. h1 개수 · 히어로 헤드라인 줄 수
// 7. 콘솔 에러 · 깨진 이미지
//
// 사용: node tools/showcase-audit.mjs [slug ...]
// 인자가 없으면 public/work 아래 전부를 돈다.
import puppeteer from "puppeteer-core";
import fs from "node:fs";
import path from "node:path";
import url from "node:url";
const here = path.dirname(url.fileURLToPath(import.meta.url));
const root = path.resolve(here, "..");
const workRoot = path.join(root, "public", "work");
const CHROME_CANDIDATES = [
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
process.env.CHROME_PATH,
].filter(Boolean);
const chrome = CHROME_CANDIDATES.find((p) => fs.existsSync(p));
if (!chrome) {
console.error("크롬을 찾지 못했다. CHROME_PATH 환경변수로 지정해라.");
process.exit(2);
}
const WIDTHS = [320, 390, 768, 1440];
const args = process.argv.slice(2);
const slugs = args.length
? args
: fs
.readdirSync(workRoot, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.sort();
/** 한 쇼케이스가 가진 모든 index.html (하위 상세 페이지 포함) */
function pagesOf(slug) {
const out = [];
const walk = (dir) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (e.name === "index.html") out.push(p);
}
};
walk(path.join(workRoot, slug));
return out;
}
/* 페이지 안에서 도는 판정기. 여기서 나온 수치가 판정의 근거다. */
const PROBE = `() => {
const lum = (c) => {
const m = (c || '').match(/[\\d.]+/g);
if (!m) return 1;
const [r, g, b] = m.slice(0, 3).map(Number).map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
// 반투명 배경을 만나면 뒤 배경과 합성한다. 불투명으로 취급하면
// 대비가 실제보다 나쁘게(혹은 좋게) 나온다 — 측정기가 틀리면 판정이 무의미하다
const bgOf = (el) => {
let n = el, acc = null;
while (n && n.nodeType === 1) {
const c = getComputedStyle(n).backgroundColor;
const m = (c || '').match(/[\\d.]+/g);
if (m) {
const a = m.length > 3 ? Number(m[3]) : 1;
if (a > 0.001) {
const rgb = m.slice(0, 3).map(Number);
acc = acc === null ? { rgb, a } : { rgb: acc.rgb.map((v, i) => v * acc.a + rgb[i] * (1 - acc.a)), a: 1 };
if (acc.a >= 0.999) return 'rgb(' + acc.rgb.map(Math.round).join(',') + ')';
}
}
n = n.parentElement;
}
return acc ? 'rgb(' + acc.rgb.map(Math.round).join(',') + ')' : 'rgb(255,255,255)';
};
// 스크린리더 전용 텍스트는 눈에 보이지 않으므로 대비 판정 대상이 아니다.
// 1px 상자 + clip 은 sr-only 의 표준 구현이고, 실제 본문이 이 모양일 수는 없다.
const srOnly = (el) => {
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
const clipped = (cs.clipPath && cs.clipPath !== 'none') || (cs.clip && cs.clip !== 'auto');
return clipped && r.width <= 2 && r.height <= 2;
};
const visible = (el) => {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) return false;
if (srOnly(el)) return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
// 가로로 스크롤되는 상자 안의 넓은 표는 결함이 아니라 처방이다.
// 그 상자가 실제로 스크롤될 때만 면제한다 — 그냥 넘치는 것과 구별해야 한다
const inScroller = (el) => {
let n = el.parentElement;
while (n && n !== document.body) {
const ox = getComputedStyle(n).overflowX;
if ((ox === 'auto' || ox === 'scroll') && n.scrollWidth > n.clientWidth + 1) return true;
n = n.parentElement;
}
return false;
};
const contrast = [];
document.querySelectorAll('p,span,a,li,dt,dd,h1,h2,h3,h4,td,th,label,output,figcaption,kbd,caption,button,strong,em,summary').forEach((el) => {
const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
if (!own || !visible(el)) return;
const cs = getComputedStyle(el);
const size = parseFloat(cs.fontSize);
const bold = Number(cs.fontWeight) >= 700;
const large = size >= 24 || (size >= 18.66 && bold);
const a = lum(cs.color), b = lum(bgOf(el));
const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
const need = large ? 3 : 4.5;
if (ratio < need - 0.005) {
contrast.push({ tag: el.tagName, cls: String(el.className || '').slice(0, 30), size: +size.toFixed(1), ratio: +ratio.toFixed(2), need, text: el.textContent.trim().slice(0, 30) });
}
});
const overflow = [];
document.querySelectorAll('body *').forEach((el) => {
if (!visible(el) || inScroller(el)) return;
const r = el.getBoundingClientRect();
if (r.right > window.innerWidth + 1.5) overflow.push({ tag: el.tagName, cls: String(el.className || '').slice(0, 30), right: Math.round(r.right) });
});
const wrapped = [];
document.querySelectorAll('nav a, .btn, button, [role=button]').forEach((el) => {
if (!visible(el) || !el.textContent.trim()) return;
const cs = getComputedStyle(el);
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.25;
const textRange = document.createRange();
textRange.selectNodeContents(el);
const textHeight = textRange.getBoundingClientRect().height;
if (textHeight > lh * 1.6) wrapped.push(el.textContent.trim().slice(0, 26));
});
const insets = [...document.querySelectorAll('h1,h2,h3,p,li')]
.filter((e) => e.textContent.trim() && visible(e))
.map((e) => Math.round(e.getBoundingClientRect().left));
const h1 = document.querySelectorAll('h1');
const firstH1 = h1[0];
let h1Lines = 0;
if (firstH1) {
const cs = getComputedStyle(firstH1);
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.2;
h1Lines = Math.round(firstH1.getBoundingClientRect().height / lh);
}
const brokenImages = [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.currentSrc || i.src);
return {
docWidth: document.documentElement.scrollWidth,
winWidth: window.innerWidth,
horizScroll: document.documentElement.scrollWidth > window.innerWidth + 1,
contrast, overflow, wrapped,
minInset: insets.length ? Math.min(...insets) : null,
h1Count: h1.length,
h1Lines,
brokenImages,
};
}`;
const browser = await puppeteer.launch({
executablePath: chrome,
headless: "new",
// WebGL 을 쓰는 페이지가 있으므로 --disable-gpu 를 쓰지 않는다.
// swiftshader 로 소프트웨어 렌더를 강제해 헤드리스에서도 캔버스가 뜬다
args: [
"--no-sandbox",
"--allow-file-access-from-files",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
"--force-device-scale-factor=1",
],
});
let totalFail = 0;
const lines = [];
for (const slug of slugs) {
for (const file of pagesOf(slug)) {
const rel = path.relative(workRoot, file).replace(/\\/g, "/");
for (const width of WIDTHS) {
console.log(`RUN ${rel} @${width}`);
const page = await browser.newPage();
const consoleErrors = [];
page.on("pageerror", (e) => consoleErrors.push(String(e).slice(0, 120)));
page.on("console", (m) => {
if (m.type() === "error") consoleErrors.push(m.text().slice(0, 120));
});
await page.setViewport({ width, height: 900 });
await page.goto(url.pathToFileURL(file).href, { waitUntil: "networkidle0" });
await page.evaluate(() => document.fonts.ready);
await new Promise((r) => setTimeout(r, 260));
// 문자열을 넘기면 puppeteer 는 그것을 '식'으로 평가한다.
// 화살표 함수만 넘기면 함수 객체가 그대로 직렬화돼 빈 객체가 돌아온다 — 즉시호출로 감싼다
const r = await page.evaluate(`(${PROBE})()`);
await page.close();
const problems = [];
if (r.horizScroll) problems.push(`가로스크롤 ${r.docWidth}>${r.winWidth}`);
if (r.overflow.length) problems.push(`오버플로 ${r.overflow.length} (${r.overflow.slice(0, 2).map((o) => o.tag + "." + o.cls).join(", ")})`);
if (r.wrapped.length) problems.push(`컨트롤 줄바꿈 ${r.wrapped.length} (${r.wrapped.slice(0, 2).join(", ")})`);
if (r.contrast.length) problems.push(`대비미달 ${r.contrast.length} (최악 ${Math.min(...r.contrast.map((c) => c.ratio))}:1 — ${r.contrast[0].cls || r.contrast[0].tag})`);
if (r.minInset !== null && r.minInset < 12) problems.push(`왼쪽 인셋 ${r.minInset}px`);
if (r.h1Count !== 1) problems.push(`h1 ${r.h1Count}개`);
if (r.h1Lines > 2) problems.push(`히어로 헤드라인 ${r.h1Lines}줄`);
if (r.brokenImages.length) problems.push(`깨진 이미지 ${r.brokenImages.length}`);
if (consoleErrors.length) problems.push(`콘솔에러 ${consoleErrors.length} (${consoleErrors[0]})`);
if (problems.length) {
totalFail++;
lines.push(`FAIL ${rel} @${width} — ${problems.join(" · ")}`);
if (r.contrast.length) {
for (const c of r.contrast.slice(0, 4)) {
lines.push(` ↳ ${c.ratio}:1 (${c.need} 필요) ${c.size}px ${c.tag}.${c.cls} "${c.text}"`);
}
}
} else {
lines.push(`PASS ${rel} @${width}`);
}
}
}
}
await browser.close();
for (const l of lines) console.log(l);
const passCount = lines.filter((l) => l.startsWith("PASS")).length;
console.log(`\n${totalFail ? "✗" : "✓"} 실렌더 감사 — PASS ${passCount} / FAIL ${totalFail}`);
process.exit(totalFail ? 1 : 0);