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 상자 안의 넓은 표를 오버플로로 잡던 것. 재는 도구가 틀리면 통과도 실패도 의미가 없다.
241 lines
10 KiB
JavaScript
241 lines
10 KiB
JavaScript
// 쇼케이스 실렌더 감사 — 전 폭 × 전 페이지에서 하드 게이트를 기계로 판정한다.
|
||
//
|
||
// 검사 항목
|
||
// 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);
|