585 lines
32 KiB
JavaScript
585 lines
32 KiB
JavaScript
#!/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 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",
|
||
h1MaxLines: 1,
|
||
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 },
|
||
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 <이 스크립트 경로>\" } 등록을 권장합니다.");
|
||
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 || {}) } };
|
||
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);
|
||
|
||
// ── 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 html = spawnSync("npx", ["html-validate", ...CFG.pages.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 정적", "checks.static=false");
|
||
|
||
// ── L0 정적: 죽은 선택자·토큰 위생 (브라우저 불필요. 실측 사고:
|
||
// 컴포넌트 제거 시 CSS 만 남아 12종의 미사용 선택자가 쌓였고, 토큰 밖 상수색 8건이 눈 검사를 통과했다) ──
|
||
const pageDir = (p) => path.dirname(path.resolve(process.cwd(), p.path));
|
||
const cssFilesOf = (p) => {
|
||
const 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 = [];
|
||
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",
|
||
[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) {
|
||
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) {
|
||
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); }
|
||
|
||
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);
|
||
};
|
||
`;
|
||
|
||
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,
|
||
imgNoAlt: imgs.filter((i) => !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}결`);
|
||
}
|
||
|
||
// ── 각 뷰 불변식 ──
|
||
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("/"));
|
||
|
||
// h1 줄 수 — 앱 화면은 기본 1줄, 마케팅 헤드라인은 페이지별로 최대 2줄까지 명시할 수 있다.
|
||
await page.setViewport({ width: 1440, height: 900 });
|
||
const h1l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
|
||
const h1MaxLines = Number.isInteger(p.h1MaxLines) && p.h1MaxLines > 0 ? p.h1MaxLines : 1;
|
||
const h1Rule = h1MaxLines === 1 ? "h1 1줄" : `h1 최대 ${h1MaxLines}줄`;
|
||
h1l >= 1 && h1l <= h1MaxLines
|
||
? pass(`${p.name}/${v} ${h1Rule}`)
|
||
: (h1l > h1MaxLines ? fail(`${p.name}/${v} h1 ${h1l}줄(최대 ${h1MaxLines}줄)`) : skip(`${p.name}/${v} h1`, "요소 없음"));
|
||
|
||
// 세로 쌓임(수축) — 짧은 라벨 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`);
|
||
}
|
||
}
|
||
|
||
// ── 스케일×폭 h1 행렬 (안드로이드 글꼴 확대 재현) ──
|
||
if (CFG.checks.scaleMatrix) {
|
||
const bad = [];
|
||
const h1MaxLines = Number.isInteger(p.h1MaxLines) && p.h1MaxLines > 0 ? p.h1MaxLines : 1;
|
||
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);
|
||
await page.addStyleTag({ content: `html { font-size: ${16 * s}px !important; }` });
|
||
const l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
|
||
if (l > h1MaxLines) bad.push(`${w}@${s}x:${l}줄(최대 ${h1MaxLines}줄)`);
|
||
}
|
||
}
|
||
bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `최대 ${h1MaxLines}줄 · scales ${CFG.fontScales.join("/")}`);
|
||
// 브랜드 1줄
|
||
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 bl = await page.evaluate((sel) => {
|
||
const els = [...document.querySelectorAll(sel)];
|
||
const el = els.find((e) => e.offsetParent !== null || getComputedStyle(e).position === "fixed") || els[0];
|
||
return window.__textLines(el);
|
||
}, p.brand);
|
||
bl === 1 ? pass(`${p.name} 브랜드 1줄`) : fail(`${p.name} 브랜드 ${bl}줄(수축 의심)`);
|
||
}
|
||
}
|
||
|
||
// ── 터치 타깃 (모바일 — HIG 44pt·M3 48dp, WCAG 2.5.8 인라인 최소 24px.
|
||
// 실측: coarse 포인터에서 28px 버튼이 정확히 못 눌렸다는 보고로 시작된 검사.
|
||
// 기준: 컨트롤(button·input·role) 44px — 본문 흐름 속 텍스트 링크(a)·예외 선택자는 24px) ──
|
||
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 bad = await page.evaluate((min, inlineMin, inlineSel) => {
|
||
const out = [];
|
||
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 need = inline ? inlineMin : min;
|
||
if (Math.min(r.width, r.height) < need) {
|
||
const label = el.textContent.trim().slice(0, 8) || el.getAttribute("aria-label") || el.tagName.toLowerCase();
|
||
out.push(`"${label}" ${Math.round(r.width)}×${Math.round(r.height)}${inline ? "(인라인)" : ""}`);
|
||
}
|
||
});
|
||
return [...new Set(out)].slice(0, 5);
|
||
}, CFG.thresholds.tapTargetMin, CFG.thresholds.inlineTargetMin, CFG.tapTargetsInline || []);
|
||
bad.length ? fail(`${p.name}/${v} 터치타깃`, bad.join(" · ")) : pass(`${p.name}/${v} 터치타깃`, `컨트롤 ≥${CFG.thresholds.tapTargetMin}px·인라인 ≥${CFG.thresholds.inlineTargetMin}px`);
|
||
}
|
||
} else skip("터치타깃", "checks.tapTargets=false");
|
||
|
||
// ── 왼쪽 인셋(모바일) — 오버플로 검사는 오른쪽만 본다.
|
||
// 실측 사고: 제목 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);
|