designpaca/apps/site/tools/showcase-audit.mjs
Yun Chan e09d4efb1c
Some checks failed
ci / build (push) Has been cancelled
feat: strengthen design skill and refresh all showcases
2026-09-12 18:16:33 +09:00

431 lines
20 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.

// 쇼케이스 실렌더 감사 — 전 폭 × 전 HTML 경로에서 규범·기능 하드 게이트를 판정한다.
//
// 검사 항목
// 1. 가로 스크롤 (하드 게이트 5)
// 2. 뷰포트를 넘는 요소 (오버플로)
// 3. 버튼·내비 라벨 2줄 접힘 (시각 관찰)
// 4. 텍스트 대비 AA (하드 게이트 7) — 조상 배경을 걷어 올려 실제 배경으로 잰다
// 5. 왼쪽 인셋 최소값 (시각 관찰)
// 6. 프로젝트 oneH1 계약 · 히어로 헤드라인 줄 수(시각 관찰)
// 7. 콘솔 에러 · 깨진 이미지
//
// 사용: node tools/showcase-audit.mjs [slug ...]
// 인자가 없으면 public 아래 라이선스 자산을 제외한 모든 HTML을 돈다.
import puppeteer from "puppeteer-core";
import fs from "node:fs";
import path from "node:path";
import url from "node:url";
import { startPublicServer, routeForFile } from "./capture-stable.mjs";
const here = path.dirname(url.fileURLToPath(import.meta.url));
const root = path.resolve(here, "..");
const publicRoot = path.resolve(process.env.SHOWCASE_AUDIT_PUBLIC_ROOT || path.join(root, "public"));
const reportPath = path.resolve(process.env.SHOWCASE_AUDIT_REPORT || path.join(root, "showcase-audit-report.json"));
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);
}
// 넓은 모니터에서 최대 폭이 풀려 카드·카피가 늘어지는 결함은 1440px 검사만으로
// 발견할 수 없다. 기본 감사에는 1920/2560까지 포함하고, 긴급 진단은 환경변수로
// 필요한 폭만 좁혀 실행할 수 있게 둔다.
const DEFAULT_WIDTHS = [320, 390, 768, 1440, 1920, 2560];
const WIDTHS = process.env.SHOWCASE_AUDIT_WIDTHS
? process.env.SHOWCASE_AUDIT_WIDTHS.split(",")
.map((value) => Number.parseInt(value.trim(), 10))
.filter(Number.isFinite)
: DEFAULT_WIDTHS;
if (!WIDTHS.length || WIDTHS.some((width) => width < 1)) {
console.error("SHOWCASE_AUDIT_WIDTHS는 쉼표로 구분한 양의 정수 폭이어야 한다.");
process.exit(2);
}
const args = process.argv.slice(2);
/** assets 아래 라이선스 HTML은 쇼케이스 경로가 아니므로 제외하고, legacy 상세까지 모두 수집한다. */
function htmlPagesOf(directory) {
const out = [];
const walk = (dir) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name !== "assets") walk(p);
} else if (e.name.endsWith(".html")) out.push(p);
}
};
walk(directory);
return out;
}
function selectedPages() {
if (!args.length) return htmlPagesOf(publicRoot);
return args.flatMap((slug) => {
const workCandidate = path.join(publicRoot, "work", slug);
const directCandidate = path.resolve(publicRoot, slug);
const selected = fs.existsSync(workCandidate) ? workCandidate : directCandidate;
if (!fs.existsSync(selected) || !fs.statSync(selected).isDirectory()) throw new Error(`감사 경로를 찾지 못했다: ${slug}`);
return htmlPagesOf(selected);
});
}
/* 페이지 안에서 도는 판정기. 여기서 나온 수치가 판정의 근거다. */
const PROBE = `() => {
// getComputedStyle는 color-mix()를 rgb()가 아닌 CSS Color 4 color(srgb …)
// 문자열로 돌려줄 수 있다. 그 0~1 채널을 0~255 rgb로 오독하면 대비 게이트가
// 거짓 RED를 낸다. 모든 색 합성·휘도 계산이 같은 파서를 공유해야 한다.
const parseColor = (c) => {
const srgb = String(c || '').match(/^color\\(srgb\\s+([\\d.]+)\\s+([\\d.]+)\\s+([\\d.]+)(?:\\s*\\/\\s*([\\d.]+))?\\)$/i);
if (srgb) {
return { rgb: srgb.slice(1, 4).map((v) => Number(v) * 255), a: srgb[4] === undefined ? 1 : Number(srgb[4]) };
}
const m = String(c || '').match(/[\\d.]+/g);
if (!m) return null;
return { rgb: m.slice(0, 3).map(Number), a: m.length > 3 ? Number(m[3]) : 1 };
};
const lum = (c) => {
const parsed = parseColor(c);
if (!parsed) return 1;
const [r, g, b] = parsed.rgb.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 parsed = parseColor(c);
if (parsed) {
const { rgb, a } = parsed;
if (a > 0.001) {
if (acc === null) {
acc = { rgb, a };
} else {
// 현재 acc(자식)를 새 부모 배경 위에 합성한다. 이전 구현은 부모 alpha를
// (1-a)에 써서 반투명 color-mix 층의 실제 색을 왜곡했다.
const outA = acc.a + a * (1 - acc.a);
acc = {
rgb: acc.rgb.map((v, i) => (v * acc.a + rgb[i] * a * (1 - acc.a)) / outA),
a: outA,
};
}
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) });
});
// 기능은 정상이어도 글자가 테두리에 닿으면 조작감이 거칠어 보인다. 실제 텍스트
// 렌더 범위를 컨트롤의 테두리 상자와 비교한다. 아이콘만 있는 컨트롤은 텍스트
// 여백 검사의 대상이 아니며, 입력·선택 상자는 계산 스타일의 inline padding을 잰다.
const textRect = (el) => {
// selectNodeContents는 sr-only 안내, aria-hidden 장식, 빈 행 오버레이의
// element box까지 모두 묶는다. 실제 보이는 text node만 합쳐야 셀과 아이콘
// 컨트롤의 시각 인셋을 측정할 수 있다.
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const rects = [];
let textNodes = 0;
let node;
while ((node = walker.nextNode())) {
if (!node.nodeValue.trim()) continue;
const owner = node.parentElement;
if (!owner || owner.closest('.sr-only, [aria-hidden="true"], [hidden]')) continue;
const ownerStyle = getComputedStyle(owner);
if (ownerStyle.display === 'none' || ownerStyle.visibility === 'hidden') continue;
const range = document.createRange();
range.selectNode(node);
const nodeRects = [...range.getClientRects()].filter((r) => r.width > 0.5 && r.height > 0.5);
if (nodeRects.length) textNodes += 1;
rects.push(...nodeRects);
}
if (!rects.length) return null;
return {
left: Math.min(...rects.map((r) => r.left)),
right: Math.max(...rects.map((r) => r.right)),
top: Math.min(...rects.map((r) => r.top)),
bottom: Math.max(...rects.map((r) => r.bottom)),
nodes: textNodes,
};
};
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 content = textRect(el);
// 이름·기한·상태처럼 여러 정보를 수직으로 쌓은 행은 두 줄 버튼이 아니라
// 작은 정보 카드다. 한 개의 라벨 텍스트가 의도치 않게 접힌 경우만 실패로
// 잡아 내비와 일반 CTA의 좁은 폭 결함은 유지한다.
if (content && content.nodes === 1 && content.bottom - content.top > lh * 1.6) {
wrapped.push(el.textContent.trim().slice(0, 26));
}
});
const controlInsets = [];
const controls = new Set(document.querySelectorAll('button, a.btn, .btn, [role=button], input:not([type=hidden]), select, textarea'));
controls.forEach((el) => {
if (!visible(el) || el.disabled || el.getBoundingClientRect().width < 28) return;
// range/checkbox/radio는 텍스트 입력 상자가 아니라 고유 트랙·표식이라
// padding 0이 정상이다. 해당 값은 인접 label에서 별도로 읽힌다.
if (el.matches('input[type=range], input[type=checkbox], input[type=radio], input[type=color], input[type=file]')) return;
const rect = el.getBoundingClientRect();
const cs = getComputedStyle(el);
const isField = el.matches('input, select, textarea');
const left = isField ? parseFloat(cs.paddingInlineStart) || 0 : textRect(el)?.left - rect.left;
const right = isField ? parseFloat(cs.paddingInlineEnd) || 0 : rect.right - textRect(el)?.right;
if (!Number.isFinite(left) || !Number.isFinite(right)) return;
const inset = Math.min(left, right);
if (inset < 8) {
controlInsets.push({
tag: el.tagName,
cls: String(el.className || '').slice(0, 30),
inset: +inset.toFixed(1),
text: (el.getAttribute('aria-label') || el.textContent || el.value || '').trim().slice(0, 30),
});
}
});
// 입력 상자는 서로 포개지면 클릭 대상과 값의 관계가 사라진다. 다른 뷰에 숨은
// 요소는 visible()에서 제외하고, 실제로 면적을 공유할 때만 실패로 낸다.
const inputOverlaps = [];
const fields = [...document.querySelectorAll('input:not([type=hidden]), select, textarea')].filter(visible);
for (let i = 0; i < fields.length; i += 1) {
const a = fields[i].getBoundingClientRect();
for (let j = i + 1; j < fields.length; j += 1) {
const b = fields[j].getBoundingClientRect();
const width = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const height = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (width > 4 && height > 4) {
inputOverlaps.push({
first: fields[i].name || fields[i].id || fields[i].type,
second: fields[j].name || fields[j].id || fields[j].type,
});
}
}
}
// 표의 셀은 flex/grid로 그렸는지와 무관하게 읽히는 텍스트가 양 옆 경계에
// 붙지 않아야 한다. 역할을 알 수 있는 native/ARIA table 셀만 검사해 그리드
// 레이아웃 자체를 테이블로 오인하는 오탐을 피한다.
const crowdedCells = [];
document.querySelectorAll('th, td, [role=cell], [role=gridcell]').forEach((el) => {
if (!visible(el) || !el.textContent.trim()) return;
const rect = el.getBoundingClientRect();
const content = textRect(el);
if (!content || rect.width < 32) return;
const inset = Math.min(content.left - rect.left, rect.right - content.right);
if (inset < 8) {
crowdedCells.push({
tag: el.tagName,
cls: String(el.className || '').slice(0, 30),
inset: +inset.toFixed(1),
text: el.textContent.trim().slice(0, 30),
});
}
});
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, controlInsets, inputOverlaps, crowdedCells,
minInset: insets.length ? Math.min(...insets) : null,
h1Count: h1.length,
h1Lines,
brokenImages,
};
}`;
function hardFailuresFor(rawProbe, browserFailures) {
const failures = [];
// 이 목록만 WCAG·기능·프로젝트 계약의 하드 실패다. 줄 수·여백 등은 아래 observations에 남겨
// 실제 렌더의 과업·언어·브리프와 함께 판정한다.
if (rawProbe.horizScroll) failures.push(`document horizontal overflow ${rawProbe.docWidth}>${rawProbe.winWidth}`);
if (rawProbe.overflow.length) failures.push(`visible element overflow ${rawProbe.overflow.length}`);
if (rawProbe.contrast.length) failures.push(`contrast below required ratio ${rawProbe.contrast.length}`);
if (rawProbe.inputOverlaps.length) failures.push(`input overlap ${rawProbe.inputOverlaps.length}`);
if (rawProbe.brokenImages.length) failures.push(`broken images ${rawProbe.brokenImages.length}`);
if (rawProbe.h1Count !== 1) failures.push(`project oneH1 contract: ${rawProbe.h1Count}`);
if (browserFailures.consoleErrors.length) failures.push(`browser console errors ${browserFailures.consoleErrors.length}`);
if (browserFailures.pageErrors.length) failures.push(`browser page errors ${browserFailures.pageErrors.length}`);
if (browserFailures.requestFailures.length) failures.push(`network failures ${browserFailures.requestFailures.length}`);
if (browserFailures.httpErrors.length) failures.push(`HTTP responses >=400 ${browserFailures.httpErrors.length}`);
return failures;
}
function observationsFor(rawProbe) {
// 스타일 휴리스틱은 기계의 합격/불합격이 아니라 시각 검토자가 확인할 관찰값이다.
return {
h1Lines: rawProbe.h1Lines,
wrappedControls: rawProbe.wrapped,
minInset: rawProbe.minInset,
controlInsets: rawProbe.controlInsets,
crowdedCells: rawProbe.crowdedCells,
};
}
function writePartialReport(report) {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
}
function attachBrowserFailureCollection(page) {
const result = { consoleErrors: [], pageErrors: [], requestFailures: [], httpErrors: [] };
page.on("console", (message) => {
if (message.type() === "error") result.consoleErrors.push(message.text());
});
page.on("pageerror", (error) => result.pageErrors.push(error.message));
page.on("requestfailed", (request) => result.requestFailures.push(`${request.url()} ${request.failure()?.errorText || ""}`));
page.on("response", (response) => {
if (response.status() >= 400) result.httpErrors.push(`${response.status()} ${response.url()}`);
});
return result;
}
const report = {
generatedAt: new Date().toISOString(),
publicRoot,
widths: WIDTHS,
rows: [],
runnerErrors: [],
};
let browser;
let server;
try {
const pages = selectedPages().sort();
if (!pages.length) throw new Error(`감사할 HTML이 없다: ${publicRoot}`);
server = await startPublicServer(publicRoot);
browser = await puppeteer.launch({
executablePath: chrome,
headless: "new",
// WebGL 페이지도 HTTP 환경에서 실제 렌더한다. capture-stable의 freeze helper는 사용하지 않는다.
args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"],
});
for (const file of pages) {
const route = routeForFile(publicRoot, file);
for (const width of WIDTHS) {
console.log(`RUN ${route} @${width}`);
const row = { route, width, rawProbe: null, hardFailures: [], observations: null, consoleErrors: [], pageErrors: [], networkErrors: [] };
const page = await browser.newPage();
const browserFailures = attachBrowserFailureCollection(page);
try {
await page.setViewport({ width, height: 900, isMobile: width < 500, hasTouch: width < 500 });
await page.goto(`${server.origin}${route}`, { waitUntil: "networkidle0", timeout: 30000 });
await page.evaluate(() => document.fonts.ready);
await new Promise((resolve) => setTimeout(resolve, 260));
// PROBE는 기존 수집 범위를 보존한다. 분류는 hardFailuresFor/observationsFor에서만 바꾼다.
row.rawProbe = await page.evaluate(`(${PROBE})()`);
} catch (error) {
browserFailures.pageErrors.push(`audit runner: ${error.stack || error.message}`);
} finally {
await page.close();
}
row.consoleErrors = browserFailures.consoleErrors;
row.pageErrors = browserFailures.pageErrors;
row.networkErrors = [...browserFailures.requestFailures, ...browserFailures.httpErrors];
if (row.rawProbe) {
row.hardFailures = hardFailuresFor(row.rawProbe, browserFailures);
row.observations = observationsFor(row.rawProbe);
} else {
row.hardFailures = ["no probe result"];
row.observations = {};
}
report.rows.push(row);
writePartialReport(report);
console.log(`${row.hardFailures.length ? "FAIL" : "PASS"} ${route} @${width}${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();
writePartialReport(report);
const failedRows = report.rows.filter((row) => row.hardFailures.length);
console.log(`\n${failedRows.length || report.runnerErrors.length ? "✗" : "✓"} 실렌더 감사 — PASS ${report.rows.length - failedRows.length} / FAIL ${failedRows.length}`);
process.exitCode = failedRows.length || report.runnerErrors.length ? 1 : 0;
}