designpaca/packages/skill/tools/design-gate.mjs
Yun Chan 6805fb2be7 feat(skill): absorb external design skills, restore interview gate, add review route
- Restore the step-0 interview as a mechanical gate the skill explicitly
  depends on; add harness.md (per-harness question tools, limits,
  fallbacks) and brief-interview.md (slots, question cards, rounds).
- Add 10 reference docs absorbed from external design skills
  (accessibility, interaction-feel, elevation, color, icons, product-copy,
  component-systems, critique, change-review, print-email) and extend
  existing references.
- Add a review-only route and two hard-gate clauses (truncated content
  reachability, three-flashes limit).
- design-gate: split tap targets into WCAG 2.5.8 and 44px contract layers,
  run axe-core when available, and fix false positives found on a real
  site (decorative alt="", stacked wordmark line count, url-only pages).
- lint-skill: fail if the interview gate section or its links disappear.
- Ship agents/openai.yaml and THIRD_PARTY_NOTICES.md.
2026-09-24 13:26:03 +09:00

768 lines
43 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.

#!/usr/bin/env node
// design-gate — 범용 디자인 QA 게이트 (designpaca 스킬 동반 도구)
//
// 조사 기반 7계층 중 이 파일 하나로 돌리는 것: L0(정적·옵션 + 죽은 선택자·토큰 위생) + L2(불변식·SEO/meta·theme-color 정합·터치타깃) + L3(시각회귀·옵션) + L4(WebKit·옵션).
// 프로젝트 고유의 L1(단위)·L5(탐색)·L6(시나리오 E2E)는 각 프로젝트의 calc/테스트로 보강한다.
//
// 사용:
// node design-gate.mjs --init # 설정파일 게이트 초안 생성
// node design-gate.mjs # gate.config.json 기준 실행
// node design-gate.mjs --update-baseline # 시각 기준화면 갱신(검증된 배포 후에만)
//
// 철학: 없는 도구는 SKIP(실패 아님), 검사 가능한 것은 전부 검사(실패면 exit 1).
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import url from "node:url";
const argv = process.argv.slice(2);
const cfgFile = path.resolve(process.cwd(), argv.includes("--init") ? "gate.config.json" : (argv.find((a) => a.startsWith("--config=")) || "gate.config.json").replace("--config=", ""));
const DEFAULT_CFG = {
pages: [
{
name: "app",
path: "index.html",
viewAttribute: "data-view",
views: ["main"],
h1: "h1",
brand: ".brand",
},
],
widths: [320, 375, 390, 768, 1024, 1440],
fontScales: [1, 1.3],
thresholds: { contrastNormal: 4.5, contrastLarge: 3, visualDiffPct: 0.1, stackLines: 3, radiusLiterals: 3, tapTargetMin: 44, inlineTargetMin: 24, leftInsetMin: 12 },
checks: { meta: true, contrast: true, stack: true, wrap: true, rhythm: true, scaleMatrix: true, tapTargets: true, leftInset: true, static: false, visual: false, webkit: false, deadCss: true, cssHygiene: true, axe: "auto" },
// "contract"(기본, 현재 동작 유지 — 컨트롤 <44px·인라인 <24px 실패) | "wcag"(WCAG 2.5.8 간격 예외 실패만 하드 게이트로 본다, 계약 미달은 참고로만 남긴다)
tapTargetPolicy: "contract",
deadCssIgnore: ["is-"], // 동적으로 조립되는 클래스 접두사(is-${type} 등). 오탐 방지 — 프로젝트마다 추가한다
chromePath: null,
};
if (argv.includes("--init")) {
fs.writeFileSync(cfgFile, JSON.stringify(DEFAULT_CFG, null, 2) + "\n");
console.log("gate.config.json 초안 생성 — pages/views 를 프로젝트에 맞게 고치세요.");
console.log("package.json: \"scripts\": { \"verify\": \"node <이 스크립트 경로>\" } 등록을 권장합니다.");
console.log("tapTargetPolicy: \"contract\"(44/24px 프로젝트 계약, 기본) 또는 \"wcag\"(WCAG 2.5.8 간격 예외만 하드 게이트) · checks.axe: \"auto\"(axe-core 있으면 실행, 없으면 미검증) 또는 false.");
process.exit(0);
}
const _user = JSON.parse(fs.readFileSync(cfgFile, "utf8"));
// checks·thresholds 는 깊은 병합 — 새 검사·임계값이 기본 켜지도록(실측: 프로젝트 구형 설정이 새 기본값을 지우고 "≥undefinedpx"를 찍었다)
const CFG = { ...DEFAULT_CFG, ..._user, checks: { ...DEFAULT_CFG.checks, ...(_user.checks || {}) }, thresholds: { ...DEFAULT_CFG.thresholds, ...(_user.thresholds || {}) } };
// path·url 둘 다 없는 페이지는 렌더도 파일 검사도 대상이 없다 — 실행 전에 명확한 설정 오류로 끊는다
for (const p of CFG.pages || []) {
if (!p.path && !p.url) {
console.error(`gate.config 오류: pages 항목(name=${p.name || "?"}) 에 path 또는 url 이 필요합니다.`);
process.exit(2);
}
}
const UPDATE = argv.includes("--update-baseline");
const results = [];
const pass = (name, detail = "") => results.push({ ok: true, name, detail });
const fail = (name, detail = "") => results.push({ ok: false, name, detail });
const skip = (name, detail) => results.push({ ok: true, name, detail: "SKIP — " + detail });
// ── 브라우저 확보 (puppeteer-core + 크롬 경로 자동 탐색) ──
const CHROME_CANDIDATES = [
process.env.CHROME_PATH,
CFG.chromePath,
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/usr/bin/google-chrome",
"/usr/bin/chromium-browser",
].filter(Boolean);
const CHROME = CHROME_CANDIDATES.find((p) => fs.existsSync(p));
const pageUrl = (p) => (p.url ? p.url : url.pathToFileURL(path.resolve(process.cwd(), p.path)).href);
const hasPath = (p) => typeof p.path === "string" && p.path.length > 0;
// ── L0 정적 (옵션) ──
if (CFG.checks.static) {
const css = spawnSync("npx", ["stylelint", "**/*.css"], { shell: true, encoding: "utf8" });
css.status === 0 ? pass("L0 stylelint") : fail("L0 stylelint", (css.stdout || "").slice(0, 200));
const staticPages = CFG.pages.filter(hasPath);
if (staticPages.length) {
const html = spawnSync("npx", ["html-validate", ...staticPages.map((p) => p.path)], { shell: true, encoding: "utf8" });
html.status === 0 ? pass("L0 html-validate") : fail("L0 html-validate", (html.stdout || "").slice(0, 200));
} else skip("L0 html-validate", "path 없음(url 전용 페이지)");
} else skip("L0 정적", "checks.static=false");
// ── L0 정적: 죽은 선택자·토큰 위생 (브라우저 불필요. 실측 사고:
// 컴포넌트 제거 시 CSS 만 남아 12종의 미사용 선택자가 쌓였고, 토큰 밖 상수색 8건이 눈 검사를 통과했다) ──
const pageDir = (p) => path.dirname(path.resolve(process.cwd(), p.path));
const cssFilesOf = (p) => {
const out = [];
if (!hasPath(p)) return out;
let html = "";
try { html = fs.readFileSync(path.resolve(process.cwd(), p.path), "utf8"); } catch { return out; }
for (const tag of html.matchAll(/<link[^>]+rel=["']stylesheet["'][^>]*>/g)) {
const href = /href=["']([^"']+)["']/.exec(tag[0])?.[1];
if (href && !/^https?:/.test(href)) {
const f = path.resolve(pageDir(p), href.split("?")[0]);
if (fs.existsSync(f)) out.push(f);
}
}
// @import 로 딸려오는 로컬 파일(토큰 등)까지 따라간다
for (const f of [...out]) {
try {
for (const im of fs.readFileSync(f, "utf8").matchAll(/@import\s+(?:url\()?["']?([^"')]+)["']?\)?/g)) {
if (!/^https?:/.test(im[1])) {
const g = path.resolve(path.dirname(f), im[1]);
if (fs.existsSync(g) && !out.includes(g)) out.push(g);
}
}
} catch { /* 읽기 실패는 아래 검사가 각자 보고 */ }
}
return out;
};
// 페이지가 공유 CSS·동적 JS·지역 토큰을 함께 쓰면 HTML 한 장과 파일명 규칙만으로
// 소유권을 추측할 수 없다. staticContract 는 검사 자체를 끄지 않고, 어떤 CSS를
// 어떤 문서 코퍼스로 대조하며 어느 파일이 토큰 권위인지 명시한다.
const contractFilesOf = (p, key, fallback) => {
const specs = p.staticContract?.[key];
if (!Array.isArray(specs)) return fallback;
return specs.map((file) => path.resolve(process.cwd(), file));
};
const scriptFilesOf = (p) => {
const out = [];
if (!hasPath(p)) return out;
let html = "";
try { html = fs.readFileSync(path.resolve(process.cwd(), p.path), "utf8"); } catch { return out; }
for (const tag of html.matchAll(/<script[^>]+src=["']([^"']+)["'][^>]*>/g)) {
const src = tag[1];
if (/^(?:https?:|data:)/.test(src)) continue;
const file = path.resolve(pageDir(p), src.split("?")[0]);
if (fs.existsSync(file) && !out.includes(file)) out.push(file);
}
return out;
};
const selectorDocumentsOf = (p) => contractFilesOf(
p,
"selectorDocuments",
hasPath(p) ? [path.resolve(process.cwd(), p.path), ...scriptFilesOf(p)] : [],
);
const missingFiles = (files) => files.filter((file) => !fs.existsSync(file));
if (CFG.checks.deadCss) {
const ignore = CFG.deadCssIgnore || [];
for (const p of CFG.pages) {
if (!hasPath(p)) { skip(`${p.name} 죽은 선택자`, "path 없음(url 전용 페이지)"); continue; }
const files = contractFilesOf(p, "selectorFiles", cssFilesOf(p));
const documents = selectorDocumentsOf(p);
const missing = missingFiles([...files, ...documents]);
if (missing.length) {
fail(`${p.name} 죽은 선택자 계약`, `없는 파일 ${missing.map((file) => path.relative(process.cwd(), file)).join(", ")}`);
continue;
}
if (!files.length) { skip(`${p.name} 죽은 선택자`, "로컬 CSS 없음"); continue; }
const corpus = documents.map((file) => fs.readFileSync(file, "utf8")).join("\n");
const dead = new Set();
for (const f of files) {
const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const m of src.matchAll(/\.([A-Za-z_][A-Za-z0-9_-]*)/g)) {
const cls = m[1];
if (ignore.some((pfx) => cls.startsWith(pfx))) continue;
if (!corpus.includes(cls)) dead.add(cls);
}
}
dead.size
? fail(`${p.name} 죽은 선택자 ${dead.size}종`, [...dead].slice(0, 8).map((c) => "." + c).join(" ") + (dead.size > 8 ? " …" : "") + " — 제거는 HTML·CSS·JS 삼위일체")
: pass(`${p.name} 죽은 선택자`, `${files.length}개 CSS × ${documents.length}개 HTML·JS 문서(무시 ${ignore.length}접두사)`);
}
} else skip("죽은 선택자", "checks.deadCss=false");
if (CFG.checks.cssHygiene) {
for (const p of CFG.pages) {
if (!hasPath(p)) { skip(`${p.name} 토큰 위생`, "path 없음(url 전용 페이지)"); continue; }
const files = contractFilesOf(p, "hygieneFiles", cssFilesOf(p));
if (!files.length) { skip(`${p.name} 토큰 위생`, "로컬 CSS 없음"); continue; }
const inferredTokens = cssFilesOf(p).filter((f) => /tokens/i.test(path.basename(f)));
const tokenFiles = contractFilesOf(p, "tokenFiles", inferredTokens);
const missing = missingFiles([...files, ...tokenFiles]);
if (missing.length) {
fail(`${p.name} 토큰 위생 계약`, `없는 파일 ${missing.map((file) => path.relative(process.cwd(), file)).join(", ")}`);
continue;
}
const tokenNames = new Set();
for (const f of new Set([...tokenFiles, ...files])) {
const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const m of src.matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)) tokenNames.add(m[1]);
}
const colors = []; const colorKinds = new Set(); const radii = new Set();
for (const f of files) {
// 어느 파일에 있든 custom property 선언은 지역 토큰이다. 선언값을 다시
// 하드코딩으로 세면 :root 토큰을 만든 페이지가 오히려 실패한다.
const src = fs.readFileSync(f, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/--[A-Za-z0-9_-]+\s*:\s*([^;{}]+);/g, "");
for (const m of src.matchAll(/#[0-9a-fA-F]{3,8}\b|(?:rgb|rgba|hsl|hsla|oklch|lab|lch)\([^)]*\)/g)) {
colors.push(`${path.basename(f)}:${m[0]}`);
colorKinds.add(m[0].toLowerCase().replace(/\s+/g, " "));
}
for (const m of src.matchAll(/border-radius:\s*([^;]+);/g)) if (!m[1].includes("var(")) radii.add(m[1].trim());
}
const budgets = p.staticContract?.budgets || {};
const hasColorBudget = Number.isFinite(budgets.colorLiterals);
const hasRadiusBudget = Number.isFinite(budgets.radiusLiterals);
const colorLimit = hasColorBudget ? budgets.colorLiterals : (tokenFiles.length ? 0 : null);
const radiusLimit = hasRadiusBudget ? budgets.radiusLiterals : null;
const bad = [];
if (colorLimit !== null && colors.length > colorLimit) bad.push(`색 리터럴 ${colors.length}건 > 계약 ${colorLimit}건`);
if (radiusLimit !== null ? radii.size > radiusLimit : radii.size >= CFG.thresholds.radiusLiterals) {
bad.push(`radius 리터럴 ${radii.size}종 > 계약 ${radiusLimit ?? `<${CFG.thresholds.radiusLiterals}`}종([${[...radii].join(", ")}])`);
}
bad.length
? fail(`${p.name} 토큰 위생`, bad.join(" · ") + (colors.length ? " — 예: " + colors.slice(0, 4).join(" ") : ""))
: pass(`${p.name} 토큰 위생`, `색 ${colors.length}${colorLimit === null ? "" : `/${colorLimit}`}건(${colorKinds.size}종)·radius ${radii.size}${radiusLimit === null ? "" : `/${radiusLimit}`}종·토큰 ${tokenFiles.length}파일/${tokenNames.size}개`);
}
} else skip("토큰 위생", "checks.cssHygiene=false");
// ── 브라우저 계층 ──
if (!CHROME) {
console.log("chrome/puppeteer-core 없음 — CHROME_PATH 를 설정하세요.");
process.exit(1);
}
let puppeteer;
try { puppeteer = await import("puppeteer-core"); }
catch { console.log("puppeteer-core 미설치 — npm i -D puppeteer-core 필요"); process.exit(1); }
// ── axe-core 선택 해석 (checks.axe: "auto" 기본). 프로젝트 cwd 기준으로 해석되면
// 각 page/view 에 소스를 주입해 실행하고, 안 되면 "미검증"으로 보고한다 — 통과로 추정하지 않는다.
// audit-gate.md 가 axe 를 해법으로 적어놓고 도구엔 구현이 없던 괴리를 없앤다. ──
let axeSource = null;
if (CFG.checks.axe !== false) {
try {
const req = createRequire(path.join(process.cwd(), "noop.js"));
axeSource = fs.readFileSync(req.resolve("axe-core"), "utf8");
} catch { /* 미설치 — 아래에서 SKIP 으로 보고 */ }
}
const UTILS = `
window.__lines = (sel) => {
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
if (!el) return -1;
const range = document.createRange();
range.selectNodeContents(el);
const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4);
const tops = rects.map((r) => r.top).sort((a, b) => a - b);
const ls = [];
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
return ls.length;
};
window.__textLines = (sel) => {
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
if (!el) return -1;
const text = [...el.childNodes].find((node) => node.nodeType === 3 && node.textContent.trim());
return window.__lines(text || el);
};
// 요소 전체를 하나의 Range 로 재면(예: display:inline-grid 로 두 텍스트 블록을 세로로
// 쌓은 의도된 로크업) 자식 인라인 박스 rect 가 섞여 줄 수가 과다 계산된다(실측: 조각달
// ".wordmark" <span>+<small> 2블록이 "3줄"로 오카운트). 비어 있지 않은 텍스트 노드
// 하나하나를 각자의 Range 로 재면 이 섞임이 없다 — 블록마다 실제 줄 수만 남는다.
window.__brandTextBlocks = (el) => {
if (!el) return [];
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const out = [];
let n;
while ((n = walker.nextNode())) {
const text = n.textContent.trim();
if (text) out.push({ text, lines: window.__lines(n) });
}
return out;
};
window.__heading = (sel) => {
let el;
try { el = document.querySelector(sel); }
catch { return { state: "invalid" }; }
if (!el) return { state: "missing" };
if (!el.textContent.trim()) return { state: "empty" };
const box = el.getBoundingClientRect();
const hiddenAncestor = (() => {
for (let node = el; node && node !== document.documentElement; node = node.parentElement) {
const style = getComputedStyle(node);
if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || style.contentVisibility === "hidden" || parseFloat(style.opacity) <= 0) return true;
}
return false;
})();
if (hiddenAncestor || box.width <= 0 || box.height <= 0) {
return { state: "hidden" };
}
const range = document.createRange();
range.selectNodeContents(el);
const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4);
const tops = rects.map((r) => r.top).sort((a, b) => a - b);
const lines = [];
for (const top of tops) if (!lines.length || top - lines[lines.length - 1] > 5) lines.push(top);
return { state: "visible", lines: lines.length, rects: rects.map((r) => ({ left: r.left, right: r.right, top: r.top, bottom: r.bottom })) };
};
window.__headingScaleAudit = (sel) => {
const heading = window.__heading(sel);
if (heading.state !== "visible") return heading;
const el = document.querySelector(sel);
const clipping = [];
const viewportWidth = document.documentElement.clientWidth;
for (const rect of heading.rects) {
if (rect.left < -1 || rect.right > viewportWidth + 1) clipping.push("viewport");
}
for (let ancestor = el; ancestor && ancestor !== document.documentElement; ancestor = ancestor.parentElement) {
const cs = getComputedStyle(ancestor);
const box = ancestor.getBoundingClientRect();
const name = ancestor.className ? "." + String(ancestor.className).trim().split(/\\s+/)[0] : ancestor.tagName.toLowerCase();
if (/^(hidden|clip|auto|scroll)$/.test(cs.overflowX) && heading.rects.some((rect) => rect.left < box.left - 1 || rect.right > box.right + 1)) clipping.push(name + "(x)");
if (/^(hidden|clip|auto|scroll)$/.test(cs.overflowY) && heading.rects.some((rect) => rect.top < box.top - 1 || rect.bottom > box.bottom + 1)) clipping.push(name + "(y)");
}
return {
...heading,
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
clipping: [...new Set(clipping)],
};
};
`;
const browser = await puppeteer.default.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] });
const page = await browser.newPage();
await page.setCacheEnabled(false);
for (const p of CFG.pages) {
const href = pageUrl(p);
await page.setViewport({ width: 1440, height: 900 });
await page.goto(href, { waitUntil: "networkidle0" });
await page.evaluate(() => document.fonts.ready);
await page.evaluate(UTILS);
// ── SEO / meta (조사 기반 체크리스트) ──
if (CFG.checks.meta) {
const meta = await page.evaluate(() => {
const get = (sel, attr = "content") => { const el = document.querySelector(sel); return el ? (el.getAttribute(attr) || "") : null; };
const title = document.title.trim();
const desc = get('meta[name="description"]');
const og = (k) => get(`meta[property="og:${k}"]`);
const imgs = [...document.querySelectorAll("img")];
return {
title, titleLen: title.length,
desc, descLen: desc ? desc.length : 0,
viewport: get('meta[name="viewport"]'),
lang: document.documentElement.getAttribute("lang"),
charset: !!document.querySelector("meta[charset], meta[http-equiv='Content-Type']"),
canonical: get('link[rel="canonical"]', "href"),
ogTitle: og("title"), ogDesc: og("description"), ogImage: og("image"), ogUrl: og("url"),
twitter: get('meta[name="twitter:card"]'),
robots: get('meta[name="robots"]'),
favicon: !!document.querySelector('link[rel~="icon"]'),
themeColor: get('meta[name="theme-color"]'),
bodyBg: (() => {
// 프리셋 전환에서 브랜드 표면이 누락되면 theme-color 만 옛 값으로 남는다 — 실제 배경과 비교해 잡는다
const b = getComputedStyle(document.body).backgroundColor;
return b && b !== "rgba(0, 0, 0, 0)" ? b : getComputedStyle(document.documentElement).backgroundColor;
})(),
h1Count: document.querySelectorAll("h1").length,
// alt="" 는 장식 이미지 선언(의도)이다 — 실패는 alt 속성 자체가 없는 img 만
imgNoAlt: imgs.filter((i) => !i.hasAttribute("alt")).length,
imgDecorative: imgs.filter((i) => i.hasAttribute("alt") && i.getAttribute("alt") === "").length,
jsonld: [...document.querySelectorAll('script[type="application/ld+json"]')].every((s) => { try { JSON.parse(s.textContent); return true; } catch { return false; } }),
jsonldCount: document.querySelectorAll('script[type="application/ld+json"]').length,
};
});
const m = [];
if (!meta.title) m.push("title 없음");
else if (meta.titleLen < 10 || meta.titleLen > 65) m.push(`title ${meta.titleLen}자(10~65 권장)`);
if (!meta.desc) m.push("description 없음");
else if (meta.descLen < 60 || meta.descLen > 165) m.push(`description ${meta.descLen}자(60~165 권장)`);
if (!meta.viewport) m.push("viewport 없음");
if (!meta.lang) m.push("html lang 없음");
if (!meta.charset) m.push("charset 없음");
if (meta.ogTitle === null) m.push("og:title 없음");
if (meta.ogImage === null) m.push("og:image 없음");
if (meta.twitter === null) m.push("twitter:card 없음");
if (!meta.favicon) m.push("favicon 없음");
if (meta.themeColor && meta.bodyBg) {
const hexOf = (v) => {
const mm = /rgba?\((\d+),(\d+),(\d+)/.exec(v.replace(/\s+/g, ""));
return mm ? "#" + [1, 2, 3].map((i) => (+mm[i]).toString(16).padStart(2, "0")).join("") : v.replace(/\s+/g, "").toLowerCase();
};
if (meta.themeColor.replace(/\s+/g, "").toLowerCase() !== hexOf(meta.bodyBg))
m.push(`theme-color ${meta.themeColor} ≠ 실제 배경 ${meta.bodyBg}(낡은 토큰 의심)`);
}
if (meta.h1Count !== 1) m.push(`h1 ${meta.h1Count}개(정확히 하나여야)`);
if (meta.imgNoAlt) m.push(`img alt 없음 ${meta.imgNoAlt}개`);
if (meta.jsonldCount && !meta.jsonld) m.push("JSON-LD 파싱 실패");
// 데모·로컬 페이지는 canonical/robots 미명시 허용 — 있으면 오히려 검증
m.length ? fail(`${p.name} SEO/meta`, m.join(" · ")) : pass(`${p.name} SEO/meta`, `title ${meta.titleLen}자·desc ${meta.descLen}자·OG✓·alt 누락 ${meta.imgNoAlt} · 장식(alt="") ${meta.imgDecorative}`);
}
// ── 각 뷰 불변식 ──
const goView = async (v) => {
if (!p.viewAttribute || v === "single") return;
await page.evaluate((attr, name) => {
const btn = document.querySelector(`[${attr}="${name}"]`);
btn && btn.click();
}, p.viewAttribute, v);
await new Promise((r) => setTimeout(r, 120));
};
for (const v of p.views || ["single"]) {
await goView(v);
// 오버플로 — 전 폭
let overs = [];
for (const w of CFG.widths) {
await page.setViewport({ width: w, height: 900 });
await new Promise((r) => setTimeout(r, 60));
const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
if (over > 0) overs.push(`+${over}px@${w}`);
}
overs.length ? fail(`${p.name}/${v} 오버플로`, overs.join(" ")) : pass(`${p.name}/${v} 오버플로`, CFG.widths.join("/"));
// 제목은 항상 존재·가시성을 확인하고, 줄 수는 페이지가 명시한 계약일 때만 검사한다.
await page.setViewport({ width: 1440, height: 900 });
const h1Selector = p.h1 || "h1";
const h1 = await page.evaluate((sel) => window.__heading(sel), h1Selector);
const h1MaxLines = Number.isInteger(p.h1MaxLines) && p.h1MaxLines > 0 ? p.h1MaxLines : null;
if (h1.state === "missing") fail(`${p.name}/${v} h1`, `선택자 없음 (${h1Selector})`);
else if (h1.state === "empty") fail(`${p.name}/${v} h1`, "텍스트 없음");
else if (h1.state === "hidden") fail(`${p.name}/${v} h1`, `선택자가 숨김 (${h1Selector})`);
else if (h1.state === "invalid") fail(`${p.name}/${v} h1`, `잘못된 선택자 (${h1Selector})`);
else if (h1.lines < 1) fail(`${p.name}/${v} h1`, "텍스트 없음");
else if (h1MaxLines !== null && h1.lines > h1MaxLines) fail(`${p.name}/${v} h1`, `${h1.lines}줄(최대 ${h1MaxLines}줄)`);
else if (h1MaxLines !== null) pass(`${p.name}/${v} h1 최대 ${h1MaxLines}줄`, `${h1.lines}줄`);
else pass(`${p.name}/${v} h1 보임`, `${h1.lines}줄(줄수 계약 없음)`);
// 세로 쌓임(수축) — 짧은 라벨 3줄 이상
if (CFG.checks.stack) {
const stacked = await page.evaluate((limit) => {
const out = [];
document.querySelectorAll("body *").forEach((el) => {
if (!el.offsetParent || el.querySelector("*")) return;
const txt = el.textContent.trim();
if (txt.length && txt.length <= 14 && window.__lines(el) >= limit) out.push(txt.slice(0, 10));
});
return [...new Set(out)].slice(0, 5);
}, CFG.thresholds.stackLines);
stacked.length ? fail(`${p.name}/${v} 세로쌓임(수축)`, stacked.join(",")) : pass(`${p.name}/${v} 수축 없음`);
}
// 리듬·트랙 불변식 — 같은 부모·같은 클래스의 형제들이 한 축으로 정렬돼 있으면 폭·간격이 균일해야 한다
// (시간표 시간 라벨이 자동 배치로 흩어지던 사고: 같은 열인데 폭 40/78 혼재, Δ 22·194 불규칙)
if (CFG.checks.rhythm) {
const rhythm = await page.evaluate(() => {
const groups = {};
document.querySelectorAll("body *").forEach((el) => {
if (!el.offsetParent || !el.parentElement || !el.classList.length || el.children.length) return;
const cls = el.classList[0];
const key = (el.parentElement.className || el.parentElement.tagName) + "|" + cls;
const r = el.getBoundingClientRect();
(groups[key] = groups[key] || []).push({ x: r.left, y: r.top, w: r.width, h: r.height, t: el.textContent.trim().slice(0, 6), p: el.parentElement });
});
const issues = [];
for (const [key, items] of Object.entries(groups)) {
if (items.length < 4) continue;
const spread = (v) => Math.max(...v) - Math.min(...v);
const cls = key.split("|")[1];
const hUniform = spread(items.map((i) => i.h)) <= 4;
const txtUniform = spread(items.map((i) => i.t.length)) <= 1;
const isDigits = items.every((i) => /^[0-9: .]+$/.test(i.t) && i.t.length > 0); // 숫자/시간 라벨 — 등폭이 의도
const isEmpty = items.every((i) => !i.t); // 빈 격자 셀 — 트랙 균일이 의도
// 1) 트랙 폭: 숫자 라벨·빈 셀·한 축 정렬 라벨은 폭이 균일해야 한다 — 자동 배치 흩어짐(열 섞임)을 잡는다.
// 한국어 라벨은 글자수가 같아도 폭이 달라지므로, 한 축 정렬 또는 숫자 전용일 때만 검사한다(오탐 방지).
const wSpread = spread(items.map((i) => i.w));
const alignedCol = spread(items.map((i) => i.x)) < 3;
if (hUniform && wSpread > 6 && (isDigits || isEmpty)) { // 숫자 라벨·빈 셀만 — 한국어 라벨은 내용 폭차가 자연스럽다
issues.push(`${cls}: 폭 ${Math.round(Math.min(...items.map((i) => i.w)))}~${Math.round(Math.max(...items.map((i) => i.w)))}px 불균일 — 트랙/배치 의심`);
}
// 2) 리듬: 한 축으로 정렬된 균일 크기 형제는 등간격이어야 한다
if (spread(items.map((i) => i.x)) < 3 && hUniform) {
const ys = items.map((i) => i.y).sort((a, b) => a - b);
const deltas = ys.slice(1).map((y, i) => y - ys[i]);
if (deltas.length >= 3 && Math.max(...deltas) > Math.min(...deltas) * 1.6 + 4) issues.push(`${cls}: 세로 간격 Δ${Math.round(Math.min(...deltas))}~${Math.round(Math.max(...deltas))}px 불균일`);
} else if (spread(items.map((i) => i.y)) < 3 && hUniform && spread(items.map((i) => i.w)) <= 4) {
const xs2 = items.map((i) => i.x).sort((a, b) => a - b);
const d2 = xs2.slice(1).map((x, i) => x - xs2[i]);
if (d2.length >= 3 && Math.max(...d2) > Math.min(...d2) * 1.6 + 4) issues.push(`${cls}: 가로 간격 불균일 Δ${Math.round(Math.min(...d2))}~${Math.round(Math.max(...d2))}px`);
}
}
return issues.slice(0, 4);
});
rhythm.length ? fail(p.name + "/" + v + " 리듬·트랙", rhythm.join(" · ")) : pass(p.name + "/" + v + " 리듬·트랙");
}
// 대비
if (CFG.checks.contrast) {
const bad = await page.evaluate((cn, cl) => {
const lum = ({ r, g, b }) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
const rgb = (c) => { const m = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/); return m ? { r: +m[1], g: +m[2], b: +m[3], a: m[4] === undefined ? 1 : +m[4] } : null; };
const blend = (t, b) => ({ r: t.r * t.a + b.r * (1 - t.a), g: t.g * t.a + b.g * (1 - t.a), b: t.b * t.a + b.b * (1 - t.a), a: 1 });
const seen = new Set(); const out = [];
document.querySelectorAll("body *").forEach((el) => {
if (!el.offsetParent) return;
if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) return;
const cs = getComputedStyle(el);
const fg = rgb(cs.color); if (!fg) return;
let node = el; const stack = [];
while (node && node !== document.documentElement) {
const c = rgb(getComputedStyle(node).backgroundColor);
if (c && c.a >= 0.95) { stack.unshift(c); break; }
if (c && c.a > 0) stack.unshift(c);
node = node.parentElement;
}
let bg = { r: 255, g: 255, b: 255, a: 1 };
for (const s of stack) bg = blend(s, bg);
const fgb = blend(fg, bg);
const l1 = lum(fgb), l2 = lum(bg);
const ratio = +(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05))).toFixed(2);
const size = parseFloat(cs.fontSize); const w = +cs.fontWeight || 400;
const need = size >= 24 || (size >= 18.66 && w >= 600) ? cl : cn;
if (ratio < need) {
const k = cs.color + Math.round(size);
if (!seen.has(k)) { seen.add(k); out.push(`"${el.textContent.trim().slice(0, 8)}" ${ratio}:1`); }
}
});
return out.slice(0, 4);
}, CFG.thresholds.contrastNormal, CFG.thresholds.contrastLarge);
bad.length ? fail(`${p.name}/${v} 대비`, bad.join(",")) : pass(`${p.name}/${v} 대비 AA`);
}
}
// ── 스케일×폭 제목 행렬 (글꼴 확대에서 줄 수 증가와 잘림을 분리한다) ──
if (CFG.checks.scaleMatrix) {
const bad = [];
const measurements = [];
for (const w of [375, 390]) {
for (const s of CFG.fontScales) {
await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true });
await page.goto(href, { waitUntil: "networkidle0" });
await page.evaluate(UTILS);
const baseRootSize = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).fontSize));
if (!Number.isFinite(baseRootSize) || baseRootSize <= 0) {
bad.push(`${w}@${s}x:root font-size 측정 실패`);
continue;
}
await page.addStyleTag({ content: `html { font-size: ${baseRootSize * s}px !important; }` });
const h1 = await page.evaluate((sel) => window.__headingScaleAudit(sel), p.h1 || "h1");
const point = `${w}@${s}x`;
if (h1.state === "missing") { bad.push(`${point}:h1 선택자 없음 (${p.h1 || "h1"})`); continue; }
if (h1.state === "empty") { bad.push(`${point}:h1 텍스트 없음`); continue; }
if (h1.state === "hidden") { bad.push(`${point}:h1 선택자가 숨김 (${p.h1 || "h1"})`); continue; }
if (h1.state === "invalid") { bad.push(`${point}:h1 잘못된 선택자 (${p.h1 || "h1"})`); continue; }
if (h1.lines < 1) { bad.push(`${point}:h1 텍스트 없음`); continue; }
measurements.push(`${point}:${h1.lines}줄`);
if (s > 1 && h1.documentOverflow > 1) bad.push(`${point}:문서 가로오버플로 +${h1.documentOverflow}px`);
if (s > 1 && h1.clipping.length) bad.push(`${point}:제목 텍스트 클리핑 ${h1.clipping.join(",")}`);
}
}
bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `줄수 정보 ${measurements.join(", ")}`);
// 브랜드 줄바꿈 — 텍스트 블록별(예: 로고의 로크업 두 줄) 각자 1줄이어야 한다.
// 여러 텍스트 블록을 한 Range 로 합쳐 재면 자식 인라인 박스가 섞여 과다 계산된다.
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await page.goto(href, { waitUntil: "networkidle0" });
await page.evaluate(UTILS);
if (p.brand) {
// 동일 셀렉터가 여러 개(사이드바+탑바 등)면 보이는 것을 잰다 — display:none 의 rect 는 0
const blocks = await page.evaluate((sel) => {
const els = [...document.querySelectorAll(sel)];
const el = els.find((e) => e.offsetParent !== null || getComputedStyle(e).position === "fixed") || els[0];
return el ? window.__brandTextBlocks(el) : null;
}, p.brand);
if (blocks === null) {
fail(`${p.name} 브랜드 줄바꿈`, `선택자 매칭 없음 (${p.brand})`);
} else {
const wrapped = blocks.find((b) => b.lines >= 2);
wrapped
? fail(`${p.name} 브랜드 줄바꿈`, `"${wrapped.text.slice(0, 12)}" ${wrapped.lines}줄(수축 의심)`)
: pass(`${p.name} 브랜드 줄바꿈 없음`, `텍스트 블록 ${blocks.length}개, 각 1줄`);
}
}
}
// ── 터치 타깃 — 두 층으로 나눠 판정한다 (실측: coarse 포인터에서 28px 버튼이 정확히 못 눌렸다는
// 보고로 시작된 검사).
// 1) WCAG 2.5.8(하드 게이트, 규범): 인라인이 아닌 대상 중 min(width,height) < 24px 는
// 간격 예외(24px 지름 원이 다른 대상의 bbox·다른 undersized 대상의 원과 겹치지 않음)를
// 계산한다. 문장 속 인라인 링크는 WCAG 2.5.8 의 inline 예외라 이 층에서 아예 빼고,
// 계약 층에서만(24px) 본다.
// 2) 계약 층(프로젝트·플랫폼 목표): HIG 44pt·M3 48dp 기준 컨트롤 ≥44px, 인라인 ≥24px.
// tapTargetPolicy:"contract"(기본, 이전 동작과 호환)면 계약 미달도 실패,
// "wcag"면 WCAG 층 실패만 실패로 보고하고 계약 미달은 통과 detail 에 참고로만 남긴다.
if (CFG.checks.tapTargets) {
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await page.goto(href, { waitUntil: "networkidle0" });
await page.evaluate(UTILS);
for (const v of p.views || ["single"]) {
await goView(v);
const result = await page.evaluate((min, inlineMin, inlineSel, policy) => {
const targets = [];
document.querySelectorAll("button, a, input, select, textarea, [role='button'], [role='tab'], [role='link'], [role='switch'], [role='checkbox']").forEach((el) => {
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden") return;
const r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return;
const inline = cs.display === "inline" || (el.tagName === "A" && el.hasAttribute("href")) || (inlineSel || []).some((s) => el.matches(s));
const label = el.textContent.trim().slice(0, 8) || el.getAttribute("aria-label") || el.tagName.toLowerCase();
targets.push({ x: r.left, y: r.top, w: r.width, h: r.height, inline, label });
});
// 간격 예외: 반지름 12 원(중심=bbox 중심)이 (a) 다른 모든 대상의 bbox 와 교차하지 않고
// (b) 다른 undersized 대상의 원과 교차하지 않으면(중심 간 거리 ≥24) 충족.
const exceptionMet = (t) => {
const cx = t.x + t.w / 2, cy = t.y + t.h / 2;
for (const o of targets) {
if (o === t) continue;
const nx = Math.max(o.x, Math.min(cx, o.x + o.w));
const ny = Math.max(o.y, Math.min(cy, o.y + o.h));
if (Math.hypot(cx - nx, cy - ny) < 12) return false;
}
for (const o of targets) {
if (o === t || Math.min(o.w, o.h) >= 24) continue;
const ocx = o.x + o.w / 2, ocy = o.y + o.h / 2;
if (Math.hypot(cx - ocx, cy - ocy) < 24) return false;
}
return true;
};
const fails = []; const notes = [];
for (const t of targets) {
const size = Math.min(t.w, t.h);
const wcagFail = !t.inline && size < 24 && !exceptionMet(t);
const contractMin = t.inline ? inlineMin : min;
const contractFail = size < contractMin;
const sizeLabel = `"${t.label}" ${Math.round(t.w)}×${Math.round(t.h)}${t.inline ? "(인라인)" : ""}`;
if (wcagFail) {
fails.push(`WCAG 2.5.8: ${sizeLabel}`);
} else if (contractFail) {
if (policy === "contract") {
fails.push(`계약 ${contractMin}px: ${sizeLabel}`);
} else {
const note = !t.inline && size < 24 ? "(WCAG 간격 예외 충족)" : "";
notes.push(`계약 ${contractMin}px: ${sizeLabel}${note}`);
}
}
}
return { fails: [...new Set(fails)].slice(0, 5), notes: [...new Set(notes)].slice(0, 5) };
}, CFG.thresholds.tapTargetMin, CFG.thresholds.inlineTargetMin, CFG.tapTargetsInline || [], CFG.tapTargetPolicy);
const summary = `컨트롤 ≥${CFG.thresholds.tapTargetMin}px·인라인 ≥${CFG.thresholds.inlineTargetMin}px`;
if (result.fails.length) fail(`${p.name}/${v} 터치타깃`, result.fails.join(" · "));
else if (result.notes.length) pass(`${p.name}/${v} 터치타깃`, `${summary} — 계약 참고: ${result.notes.join(" · ")}`);
else pass(`${p.name}/${v} 터치타깃`, summary);
}
} else skip("터치타깃", "checks.tapTargets=false");
// ── axe-core 접근성 (선택 — checks.axe:"auto" 기본. 해석되면 각 view 에서 WCAG 2.x AA
// 태그로 실행하고, 위반은 규칙 id·영향·대상 수로 보고한다. 해석 안 되면 미검증으로 보고하고
// 통과로 추정하지 않는다) ──
if (CFG.checks.axe === false) {
skip(`${p.name} axe`, "checks.axe=false");
} else if (!axeSource) {
skip(`${p.name} axe`, "미검증 — axe-core 를 찾지 못했다(npm i -D axe-core)");
} else {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(href, { waitUntil: "networkidle0" });
for (const v of p.views || ["single"]) {
await goView(v);
await page.addScriptTag({ content: axeSource });
const report = await page.evaluate(async () => {
const r = await window.axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } });
return r.violations.map((v) => ({ id: v.id, impact: v.impact, count: v.nodes.length }));
});
report.length
? fail(`${p.name}/${v} axe`, report.map((r) => `${r.id}(${r.impact}) ${r.count}건`).join(" · "))
: pass(`${p.name}/${v} axe`, "WCAG 2.x AA 위반 0건");
}
}
// ── 왼쪽 인셋(모바일) — 오버플로 검사는 오른쪽만 본다.
// 실측 사고: 제목 16px vs 앱바·카드 24px 혼재가 모든 게이트를 통과했고
// 사용자에게 '가장자리에 붙어 깨졌다'고 보고됐다. 텍스트 노드 Range 로 글자 시작점을 잰다
// (전폭 컨테이너의 보더박스가 아니라. display:none 의 rect=0 은 보임 검사로 제외). ──
if (CFG.checks.leftInset) {
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await page.goto(href, { waitUntil: "networkidle0" });
for (const v of p.views || ["single"]) {
await goView(v);
const inset = await page.evaluate(() => {
let min = null;
const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let n;
while ((n = walk.nextNode())) {
if (!n.textContent.trim() || !n.parentElement) continue;
const cs = getComputedStyle(n.parentElement);
if (cs.display === "none" || cs.visibility === "hidden" || cs.position === "fixed") continue;
const range = document.createRange();
range.selectNodeContents(n);
const r = range.getBoundingClientRect();
if (r.width < 2 || r.height < 4 || r.left < 0) continue;
const l = Math.round(r.left);
if (min === null || l < min) min = l;
}
return min;
});
inset === null
? skip(`${p.name}/${v} 왼쪽 인셋`, "텍스트 없음")
: inset >= CFG.thresholds.leftInsetMin
? pass(`${p.name}/${v} 왼쪽 인셋`, `최소 텍스트 시작 ${inset}px`)
: fail(`${p.name}/${v} 왼쪽 인셋`, `텍스트가 ${inset}px에서 시작 — 기준 ≥${CFG.thresholds.leftInsetMin}px(가장자리에 붙어 보인다)`);
}
} else skip("왼쪽 인셋", "checks.leftInset=false");
}
// ── L3 시각 회귀 (옵션: pixelmatch·pngjs 설치 시) ──
if (CFG.checks.visual) {
try {
const { default: pixelmatch } = await import("pixelmatch");
const { PNG } = await import("pngjs");
const BASE = path.resolve(process.cwd(), "__gate__/baseline");
fs.mkdirSync(BASE, { recursive: true });
let diffs = 0, n = 0;
for (const p of CFG.pages) {
for (const w of [390, 1440]) {
await page.setViewport({ width: w, height: w === 390 ? 844 : 900 });
await page.goto(pageUrl(p), { waitUntil: "networkidle0" });
await new Promise((r) => setTimeout(r, 250));
const name = `${p.name}-${w}.png`;
const buf = await page.screenshot();
const cur = PNG.sync.read(buf);
const bf = path.join(BASE, name);
if (UPDATE || !fs.existsSync(bf)) { fs.writeFileSync(bf, buf); continue; }
const base = PNG.sync.read(fs.readFileSync(bf));
if (base.width !== cur.width || base.height !== cur.height) { diffs++; fail(`L3 시각 ${name}`, "크기 불일치"); continue; }
const cnt = pixelmatch(base.data, cur.data, null, base.width, base.height, { threshold: 0.1 });
const pct = (cnt / (base.width * base.height)) * 100;
n++;
if (pct >= CFG.thresholds.visualDiffPct) { diffs++; fail(`L3 시각 ${name}`, pct.toFixed(3) + "% diff"); }
}
}
!diffs && pass("L3 시각 회귀", `${n}화면 ${UPDATE ? "기준갱신" : "일치"}`);
} catch (e) { skip("L3 시각 회귀", "pixelmatch/pngjs 미설치"); }
} else skip("L3 시각 회귀", "checks.visual=false");
await browser.close();
// ── L4 WebKit (옵션: playwright 설치 시) ──
if (CFG.checks.webkit) {
try {
const { webkit } = await import("playwright");
const b2 = await webkit.launch();
const pg = await b2.newPage();
await pg.setViewportSize({ width: 390, height: 844 });
let bad = 0;
for (const p of CFG.pages) {
await pg.goto(pageUrl(p), { waitUntil: "networkidle" });
const over = await pg.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
if (over > 0) { fail(`L4 WebKit ${p.name} 오버플로`, `+${over}px`); bad++; }
}
await b2.close();
!bad && pass("L4 WebKit", `${CFG.pages.length}페이지 오버플로 0`);
} catch { skip("L4 WebKit", "playwright/webkit 미설치"); }
} else skip("L4 WebKit", "checks.webkit=false");
// ── 리포트 ──
const fails = results.filter((r) => !r.ok).length;
const lines = results.map((r) => `${r.ok ? "PASS" : "FAIL"} ${r.name}${r.detail ? " — " + r.detail : ""}`);
console.log(lines.join("\n"));
const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
fs.writeFileSync(path.resolve(process.cwd(), "gate-report.md"),
`# 게이트 리포트 — ${stamp}\n\n${fails ? `**${fails}건 실패 — 배포 금지**` : "**전 항목 통과 — 배포 가능**"}\n\n\`\`\`\n${lines.join("\n")}\n\`\`\`\n`);
console.log(`\n${fails ? `게이트 실패(${fails})` : "게이트 통과"} — gate-report.md 생성`);
process.exit(fails ? 1 : 0);