designpaca/apps/site/tools/showcase-audit.mjs
Yun Chan 123d9fc5a8 fix(showcase): 미정의 CSS 변수·한글 조판·접근성 결함 수리 및 무결성 게이트 두 겹 신설
stylelint 도 html-validate 도 "var(--x) 를 썼는데 --x 가 어디에도 없다"를 잡지
못한다. 미정의 변수는 선언 전체를 무효로 만드는데 콘솔 에러가 없어 조용히 남는다.
검사기를 심자마자 v0.9.1 의 실제 결함이 나왔다.

게이트 신설
- tools/integrity.mjs — L0 무결성. CSS 커스텀 프로퍼티 참조, 상대경로,
  한글 조판 필수 규칙(keep-all·한글 폰트 지정), 슬롭 지문, 스크롤 리스너를 검사.
  verify.mjs 체인에 편입해 실패 시 배포를 막는다.
  폴백이 있는 var(--x, 기본값) 은 선택적 주입이므로 미정의로 세지 않는다.
- tools/showcase-audit.mjs — 320/390/768/1440 실렌더 감사. 가로 스크롤·오버플로·
  컨트롤 줄바꿈·대비 AA·왼쪽 인셋·h1 개수·히어로 줄 수·깨진 이미지·콘솔 에러.
  반투명 배경을 합성해 대비를 재고, WebGL 페이지를 위해 swiftshader 로 렌더한다.
- html-validate 대상을 두 페이지에서 public/work 전체로 넓혔다.

수리한 결함
- 미정의 변수 64건: --dur-fast·--radius-xs 가 정의된 적이 없어 cask-archive,
  cheongon-care, materia-interior 의 transition 선언이 통째로 무효였다.
  새 토큰을 만들지 않고 기존 --dur-quick·--radius 로 매핑했다 — 토큰을 늘리면
  radius 어휘가 4종이 되어 하드 게이트 3에 걸린다.
- 한글 조판: lumina-optics·pulsegate·synapse-bci 가 word-break: keep-all 없이
  IBM Plex Sans 만 지정해 한글이 시스템 기본으로 떨어지고 있었다.
  같은 계열의 IBM Plex Sans KR 을 스택에 넣고 keep-all 을 추가했다.
- 슬롭 지문 19건: text-transform: uppercase 로 만든 영문 대문자 eyebrow 를
  한글 문장으로 바꾸고, 대문자용으로 잡아둔 넓은 자간과 모노 서체도 되돌렸다.
- 하드 게이트 9: materia-interior/hannam-duplex.html 의 100vh → 100dvh.
  이 파일은 어디서도 참조되지 않으면서 인라인 hex 까지 갖고 있어 토큰 기반으로 다시 썼다.
- 접근성 11건: label 하나가 output 과 input 둘에 걸리던 것 5건(font-foundry,
  lumina-optics), dl > div > div > dt 4건(muwol-coffee), div 에 붙은 aria-label
  2건(overtone-sound, pulsegate).

측정기 자체의 오류 둘도 고쳤다 — 눈에 보이지 않는 .sr-only 를 대비 실패로 잡던 것과,
overflow-x: auto 상자 안의 넓은 표를 오버플로로 잡던 것. 재는 도구가 틀리면
통과도 실패도 의미가 없다.
2026-08-24 12:50:57 +09:00

241 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 pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) + parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
if (el.getBoundingClientRect().height > lh * 1.6 + pad) 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", "--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) {
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);