feat(site,skill): 모바일 앱 수준 UX — 바텀내비·백 키 히스토리·바텀 시트·safe area·PWA 와 L6 시나리오 E2E
- HIG(탭바 5~6·시트)·M3(바텀내비 3~5·48dp)·NN/g·CSS-Tricks 조사(research/mobile-app-ux-v1.md) 후 Nielsen 10 감사로 위반 도출 - 바텀 내비 5탭 + 더보기 바텀 시트(그룹 헤더), 뷰·다이얼로그 pushState/popstate — 안드로이드 백 키가 앱 내 뒤로 작동 - safe area(env+max)·입력 16px(iOS 확대 방지)·coarse 44px 타깃·tap-highlight·overscroll 위생, 인쇄에서 모바일 크롬 제거 - PWA: manifest + sharp 생성 아이콘(가온=장부 문항, 두레=시간표 블록) - tools/e2e.mjs L6 시나리오: RED 1,742 단언 318 실패 → GREEN 1,762/0, verify 체인 7계층 통과 - 실기기(안드로이드 에뮬레이터·Chrome) 텍스트 근거 검증 — 백 키·시트·탭 전환, iOS 는 WebKit(L4) 대체 - 스킬 내재화: mobile-app-ux.md, audit-gate 7계층·하니스 12~14, design-gate 터치타깃 검사, SKILL.md 규칙 7 - 도그푸드: 게이트가 죽은 선택자 8종·thresholds 병합 버그 적발 — CSS v22(양앱) 범프 후 전체 재검증
This commit is contained in:
parent
72337b7ee0
commit
14cb138e1a
133 changed files with 2139 additions and 208 deletions
|
|
@ -1,8 +1,8 @@
|
|||
#!/usr/bin/env node
|
||||
// design-gate — 범용 디자인 QA 게이트 (designpaca 스킬 동반 도구)
|
||||
//
|
||||
// 조사 기반 6계층 중 이 파일 하나로 돌리는 것: L0(정적·옵션) + L2(불변식·SEO/meta) + L3(시각회귀·옵션) + L4(WebKit·옵션).
|
||||
// 프로젝트 고유의 L1(단위)·L5(탐색)는 각 프로젝트의 calc/테스트로 보강한다.
|
||||
// 조사 기반 7계층 중 이 파일 하나로 돌리는 것: L0(정적·옵션 + 죽은 선택자·토큰 위생) + L2(불변식·SEO/meta·theme-color 정합·터치타깃) + L3(시각회귀·옵션) + L4(WebKit·옵션).
|
||||
// 프로젝트 고유의 L1(단위)·L5(탐색)·L6(시나리오 E2E)는 각 프로젝트의 calc/테스트로 보강한다.
|
||||
//
|
||||
// 사용:
|
||||
// node design-gate.mjs --init # 설정파일 게이트 초안 생성
|
||||
|
|
@ -31,8 +31,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 },
|
||||
checks: { meta: true, contrast: true, stack: true, wrap: true, rhythm: true, scaleMatrix: true, static: false, visual: false, webkit: false },
|
||||
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 },
|
||||
deadCssIgnore: ["is-"], // 동적으로 조립되는 클래스 접두사(is-${type} 등). 오탐 방지 — 프로젝트마다 추가한다
|
||||
chromePath: null,
|
||||
};
|
||||
|
||||
|
|
@ -44,7 +45,8 @@ if (argv.includes("--init")) {
|
|||
}
|
||||
|
||||
const _user = JSON.parse(fs.readFileSync(cfgFile, "utf8"));
|
||||
const CFG = { ...DEFAULT_CFG, ..._user, checks: { ...DEFAULT_CFG.checks, ...(_user.checks || {}) } }; // checks 는 깊은 병합 — 새 검사가 기본 켜지도록
|
||||
// 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 });
|
||||
|
|
@ -73,6 +75,80 @@ if (CFG.checks.static) {
|
|||
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;
|
||||
};
|
||||
|
||||
if (CFG.checks.deadCss) {
|
||||
const ignore = CFG.deadCssIgnore || [];
|
||||
for (const p of CFG.pages) {
|
||||
let html = "";
|
||||
try { html = fs.readFileSync(path.resolve(process.cwd(), p.path), "utf8"); }
|
||||
catch { skip(`${p.name} 죽은 선택자`, "HTML 읽기 실패"); continue; }
|
||||
const files = cssFilesOf(p);
|
||||
if (!files.length) { skip(`${p.name} 죽은 선택자`, "로컬 CSS 없음"); continue; }
|
||||
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 (!html.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 전 클래스 사용 중(무시 ${ignore.length}접두사)`);
|
||||
}
|
||||
} else skip("죽은 선택자", "checks.deadCss=false");
|
||||
|
||||
if (CFG.checks.cssHygiene) {
|
||||
for (const p of CFG.pages) {
|
||||
const files = cssFilesOf(p);
|
||||
if (!files.length) { skip(`${p.name} 토큰 위생`, "로컬 CSS 없음"); continue; }
|
||||
// 토큰 파일 = 파일명에 tokens 가 있는 것. 이 안의 값이 프로젝트의 단일 원본이다
|
||||
const tokenFiles = files.filter((f) => /tokens/i.test(path.basename(f)));
|
||||
const others = files.filter((f) => !tokenFiles.includes(f));
|
||||
const hexes = []; const radii = new Set();
|
||||
for (const f of others) {
|
||||
const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
for (const m of src.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) hexes.push(`${path.basename(f)}:${m[0]}`);
|
||||
for (const m of src.matchAll(/(?<![\w.-])rgba?\(/g)) hexes.push(`${path.basename(f)}:${m[0]}`);
|
||||
for (const m of src.matchAll(/border-radius:\s*([^;]+);/g)) if (!m[1].includes("var(")) radii.add(m[1].trim());
|
||||
}
|
||||
const bad = [];
|
||||
if (tokenFiles.length && hexes.length) bad.push(`토큰 밖 색 리터럴 ${hexes.length}건(토큰 파일이 있는데 상수색)`);
|
||||
if (radii.size >= CFG.thresholds.radiusLiterals) bad.push(`radius 리터럴 ${radii.size}종([${[...radii].join(", ")}])`);
|
||||
bad.length
|
||||
? fail(`${p.name} 토큰 위생`, bad.join(" · ") + (hexes.length ? " — 예: " + hexes.slice(0, 4).join(" ") : ""))
|
||||
: pass(`${p.name} 토큰 위생`, `색 ${hexes.length}·radius ${radii.size} 리터럴(토큰 ${tokenFiles.length}파일)`);
|
||||
}
|
||||
} else skip("토큰 위생", "checks.cssHygiene=false");
|
||||
|
||||
// ── 브라우저 계층 ──
|
||||
if (!CHROME) {
|
||||
console.log("chrome/puppeteer-core 없음 — CHROME_PATH 를 설정하세요.");
|
||||
|
|
@ -126,6 +202,12 @@ for (const p of CFG.pages) {
|
|||
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; } }),
|
||||
|
|
@ -144,6 +226,14 @@ for (const p of CFG.pages) {
|
|||
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 파싱 실패");
|
||||
|
|
@ -161,7 +251,7 @@ for (const p of CFG.pages) {
|
|||
await new Promise((r) => setTimeout(r, 120));
|
||||
};
|
||||
|
||||
for (const v of p.views) {
|
||||
for (const v of p.views || ["single"]) {
|
||||
await goView(v);
|
||||
// 오버플로 — 전 폭
|
||||
let overs = [];
|
||||
|
|
@ -296,6 +386,35 @@ for (const p of CFG.pages) {
|
|||
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");
|
||||
}
|
||||
|
||||
// ── L3 시각 회귀 (옵션: pixelmatch·pngjs 설치 시) ──
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue