// 쇼케이스 실렌더 감사 — 전 폭 × 전 페이지에서 하드 게이트를 기계로 판정한다. // // 검사 항목 // 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);