# audit-gate — 자율 검증 폐쇄 루프 5단계(감사)를 **완료 선언의 게이트**로 만드는 문서다. 핵심 원칙은 하나다: > **완료의 정의는 게이트 통과다. 사용자를 QA 로 쓰지 않는다.** "고쳤다"는 말의 근거는 검증 리포트의 수치다. 게이트가 실패 항목을 내면 4단계로 돌아가 고치고 **다시 전체를 돌린다** — 고친 것만 재검하지 않는다. 이 루프(구현 → 게이트 → 실패 → 수정 → 재게이트)가 designpaca의 기본 동작이며, 게이트 없이 6단계로 넘어가는 것은 경로 무관 금지다. ## 왜 필요한가 — 실제 사고 목록 이 게이트의 각 검사는 사용자 보고로야 발견됐던 결함에서 왔다. 어느 검사가 어느 사고를 잡는지가 설계 근거다. | 사고 (실제) | 검사 | |---|---| | CSS 닫는 중괄호 누락 — 이후 규칙 절반이 죽은 채 렌더 | stylelint(구문) + 중괄호 균형 | | 브랜드가 모바일에서 1글자 폭으로 수축, 세로로 쌓임 | 수축 탐지(짧은 라벨 n줄 이상) + 브랜드 1줄 | | 기기 글꼴 확대(안드로이드 큰 글꼴 1.3×)에서 제목 부서짐 | 스케일×폭 h1 행렬 | | 본문 보조색이 배경 대비 4.4:1 (AA 미달) | 전 요소 대비 스캔(블렌드 계산) | | 조사만 하고 안 고친 영역(관심 과목 등) | 전 뷰 순회 — 검사는 '내가 고친 것'이 아니라 전체 표면 | | 대시보드 26명 ↔ 명단 12명 숫자 모순 | 콘텐츠 정합(요약 = 재계산 비교) | | aria-label 을 div/ul 에 오용(보조기술 무시) | html-validate + axe | | dl 안 `
`, role="grid" 자식 구문 위반 | html-validate + axe |
| 계산 로직 오류(행 배열을 값으로 셈 → 0/3) | 단위 테스트(브라우저 밖 경계값) |
| 같은 데이터 속성이 여러 뷰에 존재해 선택자 오작동 | 하니스 규칙: 선택자는 컨테이너 스코프 |
| 혼합 span 그리드의 자동 배치가 시간 라벨을 흩어놓음(같은 열 폭 40/78 혼재, Δ 불규칙) | **리듬·트랙 불변식** — 같은 부모·같은 클래스 형제 중 (a) 숫자/시간 라벨(등폭 의도)과 (b) 빈 격자 셀(트랙 균일 의도)은 폭이 균일해야 하고, 한 축 정렬·크기 균일 형제는 등간격이어야 한다. 한국어 라벨은 글자수가 같아도 내용 폭차가 자연스러우므로 폭 검사 대상에서 뺀다(오탐 방지). 근본 수칙: 혼합 span 그리드는 자동 배치를 믿지 않고 좌표를 명시하라 |
| 프리셋 전환(v7)에서 브랜드 표면이 누락 — theme-color·파비콘이 종이 시대 값으로 11버전 생존 | **theme-color 정합**(메타 값 ↔ 실제 배경색 비교) + 파비콘·og:image 존재. 토큰 마이그레이션은 브랜드 표면 3종을 갱신하기 전까지 끝나지 않는다 |
| 컴포넌트 제거 시 CSS/JS 잔존 — 미사용 선택자 12종·이중 계산 블록(첫 블록이 즉시 덮임) | **죽은 선택자 검사**(CSS 클래스 ↔ HTML·JS 텍스트 사용률 대조). 제거는 HTML·CSS·JS 삼위일체 |
| 크롬에 컨트롤 1개 추가 → 그 줄의 최소폭 합 초과(320px +18px) | 오버플로 전 폭 검사(기존) + 규칙: 크롤에 컨트롤을 더하면 최소폭 합을 가장 좁은 폭에서 다시 잰다 |
| 같은 명시도의 숨김 규칙이 기본 규칙보다 앞에 있어 짐 — 모바일에서 숨기려던 kbd 안내가 렌더됨(6% 시각 diff) | 시각 회귀 + 규칙: **숨김 오버라이드는 기본 규칙보다 파일 뒤쪽에** 두거나 컴포넌트 규칙 근처의 미디어쿼리로 |
| 탐색 차터가 결과를 단언하지 않아 "9288점" 입력이 조용히 통과(값을 덧붙인 타이핑) | 하니스 규칙 8(검사는 단언)·9(number 입력 조작) |
| 모바일 라운드 — 중앙 모달이 썸존 밖, 백 키가 앱을 통째로 종료, safe area 무시, 입력 확대 | L6 시나리오(백·시트·터치타깃 스윕) + `references/mobile-app-ux.md` 표준 — **OS 관습은 조사 없이 바꾸지 않는다** |
| 탭 전환 스크롤 잔류 — 짧은 뷰에서 클램프되어 헤더 반쯤 잘림, 뷰마다 크롬 위치 제각각 | 뷰 전환 `scrollTo(0,0)` 단언(M5) + mobile-app-ux.md |
| 크럼을 헤더로 내보내 빈약한 헤더 — 계측 전부 통과했지만 사용자에게 '깨져 보인다' | 헤더 sticky·브랜드+액션 구조 단언 + **시각 무게 평가(눈)** — mobile-app-ux.md |
| 왼쪽 인셋 리듬 불일치(제목 16 vs 앱바·카드 24) — 오버플로 검사는 오른쪽만 보므로 통과 | M6 왼쪽 인셋 스위트(텍스트 시작점 ≥12px·같은 축 ±10px) + preflight §0-E |
## 7계층
`tools/design-gate.mjs` 가 한 파일로 돌리는 것과 프로젝트가 보강하는 것이 있다.
| 계층 | 도구 | 비고 |
|---|---|---|
| L0 정적 | stylelint + html-validate | 문법·구문 위반을 브라우저 켜기 전에 |
| L1 단위 | 순수 계산 로직 추출(calc.js) + 경계값 테스트 | 프로젝트별 작성 — 0명/만석/초과/빈배열. **입력 검증·신청 가능 같은 '판정'(가능/불가+사유)도 여기 둔다** — UI 는 판정을 문장으로 번역만 |
| L2 불변식 | `design-gate.mjs` — 오버플로·h1·수축·대비·SEO/meta·죽은 선택자·토큰 위생·theme-color 정합 | 전 뷰 × 전 폭 |
| L3 시각 회귀 | 기준 화면 pixelmatch diff (≥0.1% 실패) | 갱신은 검증된 배포 후 `--update-baseline` |
| L4 WebKit | Playwright webkit | 사파리 엔진 렌더 차이 |
| L5 탐색 | 키보드 완전 통과 + 사용자 여정 차터 | 프로젝트별 작성 — 마우스 금지 |
| L6 시나리오 E2E | 실사용자 여정 재현 — 페르소나의 하루·장기 과업 포함 | 프로젝트별 작성. **기대값 단언 필수**(하니스 규칙 8), 백 키·시트·양방향 흐름은 `mobile-app-ux.md` 패턴 기준. 실측: RED 1,742 단언 중 318 실패가 신규 기능에 정확히 집중 — 시나리오를 먼저 쓰면 미구현이 수치로 드러난다 |
## 설치 (프로젝트에 게이트 심기)
```
node <스킬>/tools/design-gate.mjs --init # gate.config.json 초안
# pages[].views 를 실제 뷰 목록으로, viewAttribute 를 data-view 등으로
npm i -D puppeteer-core # CHROME_PATH 또는 자동 탐색
package.json: "verify": "node <스킬>/tools/design-gate.mjs"
배포는 항상: npm run verify && build && deploy # verify 실패(exit 1)면 배포 불가
```
L1·L5 는 프로젝트 안에 `tools/unit/*.test.mjs`, `tools/exploratory.mjs` 로 보강하고 verify 체인에 묶는다. 근거와 완성 예는 designpaca 저장소 `apps/site/tools/` 가 정본 샘플이다.
## SEO / meta 감사 (design-gate 내장)
조사 기준(2026): title 10~65자·유일, description 60~165자, viewport, `html lang`, charset, canonical(프로덕션), og:title/description/image/url, twitter:card, favicon, h1 유일, img alt 전부, JSON-LD 파싱. 데모·로컬 페이지는 canonical/robots 없음을 허용한다 — **검사는 맥락을 존중하되 나열 근거는 리포트에 남긴다.**
## OS · 브라우저 특성 — 검사에 반영할 것
- **Windows**: 스크롤바가 오버레이가 아니다 — `overflow-x:auto` 짝에는 `overflow-y:hidden` + `scrollbar-width` 검토. 스크롤바 자체가 레이아웃을 민다.
- **iOS/Safari(WebKit)**: `100vh` 주소창 문제(`100dvh`), 텍스트 인플레이션(`-webkit-text-size-adjust:100%`), WebKit 만의 flex/행높이 차이 → L4 로 재검.
- **안드로이드**: 시스템 글꼴 확대(1.15~1.3× 흔함) — rem 전면 확대로 제목 칸 수축 → 스케일×폭 행렬으로 재현·검증.
- **폰트 스왑**: `font-display: swap` 의 CLS — 폴백 메트릭 보정(typography.md §4) 없이 검사 통과를 선언하지 않는다.
## 하니스 규칙 (테스트를 만드는 테스트)
1. **선택자는 스코프필수** — 같은 data-* 가 다른 뷰에 있으면 숨은 요소를 잡는다(`#as-table [data-assign]` 처럼).
2. **page.evaluate 클로저 금지** — Node 스코프 함수를 페이지 안에서 부르지 않는다. 측정 코드는 문자열로 주입.
3. **줄 수 측정의 오탐 두 종류** — 폰트 메트릭 top 차이(±5px 허용오차), 인라인 아이콘과 텍스트의 top 차이(텍스트 노드만 분리 측정).
4. **CSS 수정 직후 검증은 캐시 차단**(`setCacheEnabled(false)`) + 실제 로드된 `?v=` 확인.
5. **비전(모델) 검수는 '여부'가 아니라 '지점 평가'에만** — 여부는 계측으로.
6. **모달 닫힘 직후 포커스 강탈** — 뷰 전환+타깃 포커스는 한 evaluate 로, settle 후 유실 재포커스.
7. **게이트를 통과해도 리포트에 수치를 남긴다** — "ALL PASS"가 아니라 "무엇을 몇으로 확인했나".
8. **검사는 단언한다** — `pass("차터", st)` 처럼 상태를 상세로만 찍는 건 검사가 아니다. 실측 사고: 점수 입력에 "88"을 `type()` 하면 기존 값에 **덧붙어** "9288"이 되었고, 차터는 상태 텍스트만 인쇄해 통과했다. 기대값과 비교해 pass/fail 을 내라.
9. **number 입력은 선택-덮어쓰기가 안 된다** — 트리플 클릭 선택이 무시된다. 값을 비우고 타이핑하거나 `el.value = x; dispatchEvent(new Event("input"))` 로 설정한다.
10. **등장 모션 지속시간 ≤ 감사 도구 대기시간** — 시각 회귀가 뷰 전환 100ms 뒤에 스크린샷을 찍는다면 등장 애니메이션은 그보다 짧게(≤70ms). 아니면 매번 다른 프레임이 찍혀 회귀가 흔들린다.
11. **같은 diff 수치가 반복되면 패치가 적용되지 않은 것** — 수정하고도 시각 diff %가 소수점까지 동일하면 수정이 렌더에 닿지 않았다(캐시·캐스케이드 순서·잘못된 파일). 고치기를 반복하지 말고 적용 자체를 의심해라.
12. **페이지→노드 직렬화는 NaN 방어** — `getComputedStyle(el).fontSize` 등을 문자열 보간하면 "16px"이 NaN이 되고, 하니스는 NaN을 `null`로 받아 `"////px"` 같은 고장난 상세를 리포트한다. 수치는 항상 `parseFloat(x) || 0` 로 감싼다.
13. **`position: fixed` 요소의 보임 판정은 offsetParent 로 하지 마라** — fixed 는 offsetParent 가 `null`이다. display 계산값·높이로 판정한다.
14. **실기기(에뮬레이터) 검증은 텍스트 근거로** — `uiautomator dump` 의 `text="…"`에서 화면 제목·활성 탭을 읽어 단언한다. 스크린샷+비전은 "백 직후 전환 중 프레임"을 잡는 타이밍 오탐이 있다(실측 사고). 물리 폰이 붙어 있으면 `adb -s