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.
This commit is contained in:
Yun Chan 2026-09-24 13:26:03 +09:00
parent 79e79c120b
commit 6805fb2be7
37 changed files with 5688 additions and 128 deletions

View file

@ -12,6 +12,7 @@
// 철학: 없는 도구는 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";
@ -32,7 +33,9 @@ const DEFAULT_CFG = {
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 },
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,
};
@ -41,12 +44,20 @@ 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 });
@ -66,13 +77,17 @@ const CHROME_CANDIDATES = [
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 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));
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 정적: 죽은 선택자·토큰 위생 (브라우저 불필요. 실측 사고:
@ -80,6 +95,7 @@ if (CFG.checks.static) {
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)) {
@ -114,6 +130,7 @@ const contractFilesOf = (p, key, fallback) => {
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)) {
@ -128,7 +145,7 @@ const scriptFilesOf = (p) => {
const selectorDocumentsOf = (p) => contractFilesOf(
p,
"selectorDocuments",
[path.resolve(process.cwd(), p.path), ...scriptFilesOf(p)],
hasPath(p) ? [path.resolve(process.cwd(), p.path), ...scriptFilesOf(p)] : [],
);
const missingFiles = (files) => files.filter((file) => !fs.existsSync(file));
@ -136,6 +153,7 @@ 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]);
@ -162,6 +180,7 @@ if (CFG.checks.deadCss) {
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)));
@ -214,6 +233,17 @@ 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;
@ -232,6 +262,21 @@ const UTILS = `
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); }
@ -318,7 +363,9 @@ for (const p of CFG.pages) {
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,
// 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,
};
@ -347,7 +394,7 @@ for (const p of CFG.pages) {
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}결`);
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}`);
}
// ── 각 뷰 불변식 ──
@ -509,50 +556,122 @@ for (const p of CFG.pages) {
}
}
bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `줄수 정보 ${measurements.join(", ")}`);
// 브랜드 1줄
// 브랜드 줄바꿈 — 텍스트 블록별(예: 로고의 로크업 두 줄) 각자 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 bl = await page.evaluate((sel) => {
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 window.__textLines(el);
return el ? window.__brandTextBlocks(el) : null;
}, p.brand);
bl === 1 ? pass(`${p.name} 브랜드 1줄`) : fail(`${p.name} 브랜드 ${bl}줄(수축 의심)`);
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줄`);
}
}
}
// ── 터치 타깃 (모바일 — HIG 44pt·M3 48dp, WCAG 2.5.8 인라인 최소 24px.
// 실측: coarse 포인터에서 28px 버튼이 정확히 못 눌렸다는 보고로 시작된 검사.
// 기준: 컨트롤(button·input·role) 44px — 본문 흐름 속 텍스트 링크(a)·예외 선택자는 24px) ──
// ── 터치 타깃 — 두 층으로 나눠 판정한다 (실측: 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 bad = await page.evaluate((min, inlineMin, inlineSel) => {
const out = [];
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 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 ? "(인라인)" : ""}`);
}
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 });
});
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`);
// 간격 예외: 반지름 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 로 글자 시작점을 잰다