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:
parent
79e79c120b
commit
6805fb2be7
37 changed files with 5688 additions and 128 deletions
|
|
@ -42,6 +42,52 @@ const run = (name, html, extra = {}) => {
|
|||
return spawnSync(process.execPath, [gate, `--config=${config}`], { cwd: work, encoding: "utf8" });
|
||||
};
|
||||
|
||||
// 터치 타깃 두 층(WCAG 2.5.8 하드 게이트 / 44px 프로젝트 계약) 전용 설정 —
|
||||
// configFor 는 extra 를 page 객체에 병합하므로, 최상위 tapTargetPolicy 를 얹으려면 따로 조립한다.
|
||||
const runTap = (name, html, { policy, thresholds } = {}) => {
|
||||
const page = path.join(work, `${name}.html`);
|
||||
const config = path.join(work, `${name}.json`);
|
||||
fs.writeFileSync(page, html.replace("<!doctype html>", "<!doctype html><style>html { overflow-x: clip; } body { margin: 0; }</style>"));
|
||||
fs.writeFileSync(config, JSON.stringify({
|
||||
pages: [{ name: "fixture", path: page, h1: "h1" }],
|
||||
widths: [375, 390],
|
||||
fontScales: [1],
|
||||
checks: { meta: false, contrast: false, stack: false, rhythm: false, scaleMatrix: false, tapTargets: true, leftInset: false, deadCss: false, cssHygiene: false },
|
||||
...(policy ? { tapTargetPolicy: policy } : {}),
|
||||
...(thresholds ? { thresholds } : {}),
|
||||
}));
|
||||
return spawnSync(process.execPath, [gate, `--config=${config}`], { cwd: work, encoding: "utf8" });
|
||||
};
|
||||
|
||||
// SEO/meta 검사만 켠 고정 헤더 — alt 판정(G1) 외 다른 meta 실패로 노이즈가 섞이지 않게
|
||||
// title/description/viewport/lang/charset/OG/favicon 계약을 전부 채운다.
|
||||
const runMeta = (name, bodyHtml) => {
|
||||
const page = path.join(work, `${name}.html`);
|
||||
const config = path.join(work, `${name}.json`);
|
||||
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Design gate meta fixture page</title><meta name="description" content="${"D".repeat(80)}"><meta property="og:title" content="t"><meta property="og:image" content="https://example.com/o.png"><meta name="twitter:card" content="summary"><link rel="icon" href="data:,"></head><body style="margin:0"><h1>Heading</h1>${bodyHtml}</body></html>`;
|
||||
fs.writeFileSync(page, html);
|
||||
fs.writeFileSync(config, JSON.stringify({
|
||||
pages: [{ name: "fixture", path: page, h1: "h1" }],
|
||||
widths: [375, 390],
|
||||
fontScales: [1],
|
||||
checks: { meta: true, contrast: false, stack: false, rhythm: false, scaleMatrix: false, tapTargets: false, leftInset: false, deadCss: false, cssHygiene: false, axe: false },
|
||||
}));
|
||||
return spawnSync(process.execPath, [gate, `--config=${config}`], { cwd: work, encoding: "utf8" });
|
||||
};
|
||||
|
||||
// path 없이 url(data: URL)만 있는 페이지 — G3 크래시 재현·SKIP 확인용
|
||||
const runUrlOnly = (name, bodyHtml) => {
|
||||
const config = path.join(work, `${name}.json`);
|
||||
const dataUrl = `data:text/html,${encodeURIComponent(bodyHtml)}`;
|
||||
fs.writeFileSync(config, JSON.stringify({
|
||||
pages: [{ name: "fixture", url: dataUrl, h1: "h1" }],
|
||||
widths: [375, 390],
|
||||
fontScales: [1],
|
||||
checks: { meta: false, contrast: false, stack: false, rhythm: false, scaleMatrix: false, tapTargets: false, leftInset: false, static: false, deadCss: true, cssHygiene: true, axe: false },
|
||||
}));
|
||||
return spawnSync(process.execPath, [gate, `--config=${config}`], { cwd: work, encoding: "utf8" });
|
||||
};
|
||||
|
||||
const outputOf = (result) => result.stdout + result.stderr;
|
||||
|
||||
try {
|
||||
|
|
@ -74,7 +120,84 @@ try {
|
|||
assert.equal(empty.status, 1, outputOf(empty));
|
||||
assert.match(outputOf(empty), /h1 — 텍스트 없음/);
|
||||
|
||||
console.log("design-gate-contract: 7 passed");
|
||||
const isolated = runTap(
|
||||
"tap-isolated",
|
||||
`<!doctype html><style>h1 { position: absolute; top: 0; left: 0; } .btn { position: absolute; top: 400px; width: 20px; height: 20px; } #a { left: 20px; } #b { left: 300px; }</style><h1>Isolated tap targets</h1><button id="a" class="btn">A</button><button id="b" class="btn">B</button>`,
|
||||
{ policy: "wcag" },
|
||||
);
|
||||
assert.equal(isolated.status, 0, outputOf(isolated));
|
||||
|
||||
const adjacent = runTap(
|
||||
"tap-adjacent",
|
||||
`<!doctype html><style>h1 { position: absolute; top: 0; left: 0; } .btn { position: absolute; top: 400px; width: 20px; height: 20px; } #a { left: 20px; } #b { left: 42px; }</style><h1>Adjacent tap targets</h1><button id="a" class="btn">A</button><button id="b" class="btn">B</button>`,
|
||||
{ policy: "wcag" },
|
||||
);
|
||||
assert.equal(adjacent.status, 1, outputOf(adjacent));
|
||||
assert.match(outputOf(adjacent), /WCAG 2\.5\.8/);
|
||||
|
||||
const contractFail = runTap(
|
||||
"tap-contract-fail",
|
||||
`<!doctype html><style>h1 { position: absolute; top: 0; left: 0; } .btn { position: absolute; top: 400px; left: 20px; width: 36px; height: 36px; }</style><h1>Contract only target</h1><button class="btn">C</button>`,
|
||||
);
|
||||
assert.equal(contractFail.status, 1, outputOf(contractFail));
|
||||
assert.match(outputOf(contractFail), /계약 44px/);
|
||||
|
||||
const contractNote = runTap(
|
||||
"tap-contract-note",
|
||||
`<!doctype html><style>h1 { position: absolute; top: 0; left: 0; } .btn { position: absolute; top: 400px; left: 20px; width: 36px; height: 36px; }</style><h1>Contract only target</h1><button class="btn">C</button>`,
|
||||
{ policy: "wcag" },
|
||||
);
|
||||
assert.equal(contractNote.status, 0, outputOf(contractNote));
|
||||
assert.match(outputOf(contractNote), /계약 참고/);
|
||||
assert.match(outputOf(contractNote), /계약 44px/);
|
||||
|
||||
const axeSkip = run("axe-skip", `<!doctype html><style>h1 { width: 9ch; font-size: 2rem; }</style><h1>A flexible display heading</h1>`);
|
||||
assert.equal(axeSkip.status, 0, outputOf(axeSkip));
|
||||
assert.match(outputOf(axeSkip), /미검증/);
|
||||
|
||||
// G1: alt="" 는 장식 선언 — alt 속성 자체가 없을 때만 실패
|
||||
const altDecorative = runMeta("alt-decorative", `<img src="a.png" alt="">`);
|
||||
assert.equal(altDecorative.status, 0, outputOf(altDecorative));
|
||||
assert.match(outputOf(altDecorative), /alt 누락 0 · 장식\(alt=""\) 1/);
|
||||
|
||||
const altMissing = runMeta("alt-missing", `<img src="b.png">`);
|
||||
assert.equal(altMissing.status, 1, outputOf(altMissing));
|
||||
assert.match(outputOf(altMissing), /img alt 없음 1개/);
|
||||
|
||||
// G2: 브랜드가 텍스트 블록 여러 개(로크업)로 나뉘어 있어도 블록마다 1줄이면 통과 —
|
||||
// 전체 Range 를 한 번에 재던 예전 방식은 조각달 ".wordmark"(inline-grid span+small)에서
|
||||
// "3줄"로 오카운트했다.
|
||||
const brandTwoBlocks = run(
|
||||
"brand-two-blocks",
|
||||
`<!doctype html><style>.brand { display: inline-grid; min-height: 44px; align-content: center; line-height: 1; } .brand > span { font-size: 26px; } .brand small { margin-top: 4px; font-size: 14px; display: block; }</style><h1>Fixture heading</h1><a class="brand"><span>Foo</span><small>Bar</small></a>`,
|
||||
{ brand: ".brand" },
|
||||
);
|
||||
assert.equal(brandTwoBlocks.status, 0, outputOf(brandTwoBlocks));
|
||||
assert.match(outputOf(brandTwoBlocks), /브랜드 줄바꿈 없음 — 텍스트 블록 2개, 각 1줄/);
|
||||
|
||||
// 회귀 금지: 텍스트 노드가 하나뿐인 브랜드가 실제로 수축(줄바꿈)하면 여전히 실패해야 한다
|
||||
const brandWrapped = run(
|
||||
"brand-wrapped",
|
||||
`<!doctype html><style>.brand { display: inline-block; width: 60px; font-size: 20px; }</style><h1>Fixture heading</h1><a class="brand">Shrink Wrap Text</a>`,
|
||||
{ brand: ".brand" },
|
||||
);
|
||||
assert.equal(brandWrapped.status, 1, outputOf(brandWrapped));
|
||||
assert.match(outputOf(brandWrapped), /브랜드 줄바꿈 — "Shrink Wrap.*"\s*\d+줄\(수축 의심\)/);
|
||||
|
||||
// G3: url 만 있고 path 가 없는 페이지 — 파일 기반 검사는 크래시 없이 SKIP, 렌더 검사는 그대로 통과
|
||||
const urlOnly = runUrlOnly("url-only", `<!doctype html><h1>URL only fixture</h1>`);
|
||||
assert.equal(urlOnly.status, 0, outputOf(urlOnly));
|
||||
assert.match(outputOf(urlOnly), /fixture 죽은 선택자 — SKIP — path 없음\(url 전용 페이지\)/);
|
||||
assert.match(outputOf(urlOnly), /fixture 토큰 위생 — SKIP — path 없음\(url 전용 페이지\)/);
|
||||
|
||||
// path·url 둘 다 없으면 설정 오류로 exit 2
|
||||
const noSourceConfig = path.join(work, "no-source.json");
|
||||
fs.writeFileSync(noSourceConfig, JSON.stringify({ pages: [{ name: "nosrc", h1: "h1" }], checks: { meta: false } }));
|
||||
const noSource = spawnSync(process.execPath, [gate, `--config=${noSourceConfig}`], { cwd: work, encoding: "utf8" });
|
||||
assert.equal(noSource.status, 2, outputOf(noSource));
|
||||
assert.match(outputOf(noSource), /path 또는 url/);
|
||||
|
||||
console.log("design-gate-contract: 18 passed");
|
||||
} finally {
|
||||
fs.rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 로 글자 시작점을 잰다
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue