feat(skill): 왼쪽 인셋 리듬·픽셀 판정을 스킬에 내재화

이번 라운드(스크롤 잔류·빈약한 헤더·인셋 불일치 — 사용자 보고 3건)의 교훈을 스킬로 승격:
- design-gate leftInset 검사 내장(텍스트 노드 Range 기준 ≥12px, 모바일 390px 전 뷰) — 오버플로 검사는 오른쪽만 보는 계측 공백을 메움. 도그푸드: 양앱 통과(가온 16·두레 24 각자 일관 축)
- 브랜드 검사 오탐 수정(동일 셀렉터 다중 요소 시 보이는 것 기준 — 도그푸드가 잡음)
- preflight §0-E 왼쪽 인셋 리듬, 하니스 규칙 15~17(기준 갱신 의도 리뷰·스크린샷 픽셀 판정·RED 재현), 사고표 4행
- SKILL.md 규칙 6 보강: 계측의 공백 — 보고가 오면 픽셀 판정으로 수치화하고 게이트에 추가
This commit is contained in:
Yun Chan 2026-08-23 15:40:58 +09:00
parent 527b9c2379
commit 46b47884c9
7 changed files with 74 additions and 6 deletions

View file

@ -31,8 +31,8 @@ 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 },
checks: { meta: true, contrast: true, stack: true, wrap: true, rhythm: true, scaleMatrix: true, tapTargets: true, static: false, visual: false, webkit: false, deadCss: true, cssHygiene: true },
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,
};
@ -382,7 +382,12 @@ for (const p of CFG.pages) {
await page.goto(href, { waitUntil: "networkidle0" });
await page.evaluate(UTILS);
if (p.brand) {
const bl = await page.evaluate(`__lines(${JSON.stringify(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.__lines(el);
}, p.brand);
bl === 1 ? pass(`${p.name} 브랜드 1줄`) : fail(`${p.name} 브랜드 ${bl}줄(수축 의심)`);
}
}
@ -415,6 +420,40 @@ for (const p of CFG.pages) {
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 설치 시) ──