// 쇼케이스 실렌더 감사 — 전 폭 × 전 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; }