#!/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(/]+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(/]+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" + 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);