# audit-gate — 자율 검증 폐쇄 루프 5단계(감사)를 **완료 선언의 게이트**로 만드는 문서다. 핵심 원칙은 하나다: > **완료는 기능·프로젝트 계약·렌더 시각 평가가 함께 확인된 상태다. 사용자를 QA 로 쓰지 않는다.** "고쳤다"는 말의 근거는 검증 리포트의 수치와 실제 렌더 평가다. 게이트가 실패 항목을 내면 4단계로 돌아가 고치고, 변경한 영역과 연결된 회귀 위험을 다시 검사한다. 프로젝트가 정한 전체 검증 계약은 그대로 수행한다. 이 루프(구현 → 게이트 → 실패 → 수정 → 재게이트)가 designpaca의 기본 동작이며, 기능·접근성 자동 검사와 시각 평가(위계·브랜드·타입·구도·이미지·리듬)는 별도 결과로 보고한다. ## 왜 필요한가 — 실제 사고 목록 이 게이트의 각 검사는 사용자 보고로야 발견됐던 결함에서 왔다. 어느 검사가 어느 사고를 잡는지가 설계 근거다. | 사고 (실제) | 검사 | |---|---| | CSS 닫는 중괄호 누락 — 이후 규칙 절반이 죽은 채 렌더 | stylelint(구문) + 중괄호 균형 | | 브랜드가 모바일에서 1글자 폭으로 수축, 세로로 쌓임 | 수축 탐지(짧은 라벨 n줄 이상) + 브랜드 1줄 | | 기기 글꼴 확대(안드로이드 큰 글꼴 1.3×)에서 제목 부서짐 | 스케일×폭 h1 행렬 | | 본문 보조색이 배경 대비 4.4:1 (AA 미달) | 전 요소 대비 스캔(블렌드 계산) | | CSS `color-mix()`의 computed `color(srgb …)`를 0~255 rgb로 오독해 정상 대비를 1:1로 거짓 실패 | CSS Color 4 `color(srgb 0~1)` 파싱 + 알파 합성 RED fixture | | `.sr-only`·`aria-hidden` 장식·빈 overlay의 범위를 보이는 셀/버튼 글자로 합산해 0px 여백 거짓 실패 | text node 단위 실측 + 비가시 조상 제외. 화면에 보이는 글자만 인셋 판정 | | 조사만 하고 안 고친 영역(관심 과목 등) | 전 뷰 순회 — 검사는 '내가 고친 것'이 아니라 전체 표면 | | 대시보드 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 관습은 조사 없이 바꾸지 않는다** |
| 전역 `* { margin: 0 }`가 native dialog의 UA 중앙 여백을 지워 좌상단에 고정, 짧은 화면의 패널은 고정 크롬에 눌려 내부 scroll이 0px | dialog의 `fixed/inset/margin:auto`를 명시하고 390·1440·2560 중심 오차, viewport rect, `html` scroll lock, short-height scroll owner를 hard fail로 측정. 모달 행동은 [WAI-ARIA APG](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)를 따른다 |
| 2560px 분할 히어로에서 사진이 1400px 이상으로 팽창하고 카피 열은 200px 안팎으로 붕괴 | hero·copy·media·후속 정보 레일의 rect 계약. stage/media/copy/workbench 토큰을 분리하고 media 상한·copy 최소폭·정렬을 hard fail로 측정 |
| 탭 전환 스크롤 잔류 — 짧은 뷰에서 클램프되어 헤더 반쯤 잘림, 뷰마다 크롬 위치 제각각 | 뷰 전환 `scrollTo(0,0)` 단언(M5) + mobile-app-ux.md |
| 크럼을 헤더로 내보내 빈약한 헤더 — 계측 전부 통과했지만 사용자에게 '깨져 보인다' | 헤더 sticky·브랜드+액션 구조 단언 + **시각 무게 평가(눈)** — mobile-app-ux.md |
| 왼쪽 인셋 리듬 불일치(제목 16 vs 앱바·카드 24) — 오버플로 검사는 오른쪽만 보므로 통과 | M6 왼쪽 인셋 스위트(텍스트 시작점 ≥12px·같은 축 ±10px) + preflight §0-E |
| 모바일에서 절대 배치 지도 자식의 부모를 `position: static`으로 바꿈 — offsetParent가 BODY가 되어 격자·핀이 히어로 전체를 덮음. 기하 게이트 8/8 통과 | **상태별 실제 캡처를 열어 보는 시각 E2E** + absolute/fixed 자식의 containing block 단언(하니스 규칙 19) |
| 새 관리자·가맹 서브페이지가 생겼지만 `public/work/*/index.html`만 검사 — 새 HTML 오류 8건이 검증 밖 | 정적 입력을 `public/work/**/*.html`·`**/*.css`로 재귀화 + 검사 대상 파일 수 단언(하니스 규칙 20) |
| 마케팅 h1의 의도적 2줄 때문에 범용 줄 수 규칙을 강제할 위험 | 페이지별 읽기 폭·문구·글꼴 확대의 계약을 기록하고 375·390 × 글꼴 확대에서 실제 줄 수와 가독성을 판정. 줄 수를 맞추려고 글자 크기를 축소하지 않음 |
## 7계층
`tools/design-gate.mjs` 가 한 파일로 돌리는 것과 프로젝트가 보강하는 것이 있다.
| 계층 | 도구 | 비고 |
|---|---|---|
| L0 정적 | stylelint + html-validate | 문법·구문 위반을 브라우저 켜기 전에 |
| L1 단위 | 순수 계산 로직 추출(calc.js) + 경계값 테스트 | 프로젝트별 작성 — 0명/만석/초과/빈배열. **입력 검증·신청 가능 같은 '판정'(가능/불가+사유)도 여기 둔다** — UI 는 판정을 문장으로 번역만 |
| L2 불변식 | `design-gate.mjs` — 오버플로·제목 존재/가시성·수축·대비·SEO/meta·죽은 선택자·토큰 위생·theme-color 정합. 명시한 양의 `h1MaxLines`만 줄 수 계약으로 검사 | 전 뷰 × 전 폭 |
| L3 시각 회귀 | 기준 화면 pixelmatch diff (≥0.1% 실패) + 핵심 상태 캡처 | 갱신은 검증된 배포 후 `--update-baseline`. 캡처 존재와 사람의 시각 판정은 별개다 |
| L4 WebKit | Playwright webkit | 사파리 엔진 렌더 차이 |
| L5 탐색 | 키보드 완전 통과 + 사용자 여정 차터 | 프로젝트별 작성 — 마우스 금지 |
| L6 시나리오 E2E | 실사용자 여정 재현 — 페르소나의 하루·장기 과업 포함 | 프로젝트별 작성. **기대값 단언 필수**(하니스 규칙 8), 백 키·시트·양방향 흐름은 `mobile-app-ux.md` 패턴 기준. 실측: RED 1,742 단언 중 318 실패가 신규 기능에 정확히 집중 — 시나리오를 먼저 쓰면 미구현이 수치로 드러난다 |
## 렌더 측정 계약
자동 측정은 실제 장면을 재현할 때만 의미가 있다. 새 범용 JS 엔진을 만들라는 뜻이 아니다. 프로젝트의 기존 E2E·시각 회귀 도구에 아래 계약을 적고, 페이지와 기능의 위험에 맞는 단언만 구현한다.
### 1. 장면과 레이아웃
각 artifact에는 빌드·revision 식별자, URL, viewport의 너비·높이·종횡비·zoom, 스크롤 위치, UI 상태, 로드한 폰트·이미지·영상 자산을 함께 남긴다. 폭만 적어서는 같은 폭에서 높이가 달라진 크롭, 짧은 화면의 고정 크롬, 확대에 따른 줄바꿈을 재현할 수 없다. 짧은 가로·긴 세로·확대가 결과에 영향을 주면 대표 장면에 포함한다. 배경 위치, 분기 종횡비, 헤더 높이, viewport 단위 선택은 프로젝트 계약으로 정한다.
화면 맞춤 hero는 헤더가 overlay인지, 문서 흐름에서 높이를 차지하는지 먼저 구분한다. 그 역할을 포함해 첫 화면의 남은 높이를 재고, 내용이 고정 높이보다 커지면 자연스럽게 확장되어야 한다. 모든 페이지 섹션에 `100vh`를 강요하지 않는다. 컨테이너의 `overflow=0`만으로 피사체·카피·행동의 안전 영역이 보존됐다고 판정하지 말고, 실제 크롭과 인접 콘텐츠의 겹침을 화면에서 확인한다.
### 2. 모션과 상태
reveal·전환은 실제 UI 스크롤·클릭·키보드 이벤트로 장면을 먼저 활성화한다. 그 장면에 필요한 폰트·이미지가 준비된 뒤 대상과 전환에 관여하는 조상 요소의 유한 전환 완료를 관측하고, 프로젝트가 의도한 최종 가시성·색·대비·위치에 도달했는지 확인한다. 최종 가시성은 `opacity: 1`을 일괄 요구하는 값이 아니다. 의도한 반투명 텍스트도 조상 opacity 합성을 포함한 실제 대비와 상태 계약으로 판정한다. 정한 상한 시간 안에 끝나지 않으면 실패 또는 미검증으로 기록하고 원인을 남긴다.
계속 움직이는 배경·카운터·캔버스는 별도 상태 계약으로 관측한다. 모든 페이지 애니메이션이 끝날 때까지 기다리는 검사는 만들지 않는다. 정지 상태의 대비, 일반 설정에서 trigger와 완료가 실제로 작동하는지, `prefers-reduced-motion` 경로를 각각 보고한다. 안정한 정상 상태의 대비가 영구 애니메이션의 저대비나 발화 실패까지 통과시키지 않는다.
고정 sleep, 검사 중 `opacity: 1` 강제, `animation: none` 주입으로 제품 모션 검사를 GREEN으로 만들지 않는다. 썸네일·OG용 정지 프레임은 별도 산출물이다. 그 경우에는 완성된 프레임을 고정하고 축소 모션을 적용할 수 있지만, 실제 동작 검수의 대체가 아니다.
### 3. 텍스트·클리핑·폰트
`Range.getClientRects()`는 선택 범위의 요소 box와 텍스트 font metric에 따른 사각형이며 글리프 잉크 경계가 아니다. heading·line box 밖 rect만으로 실제 잘림을 확정하지 않는다. viewport 밖 rect는 가시성 후보로 계속 검사하되, viewport와 실제 `overflow` clipping 조상을 함께 추적한다. `clip-path`·mask·transform처럼 rect만으로 설명하기 어려운 경계는 실제 렌더를 열어 수동 판정한다. 텍스트 후보가 보이면 인접 콘텐츠와 겹치거나 읽기 영역을 침범하는지도 함께 확인한다. [CSSOM View의 `Range.getClientRects()` 편집 초안](https://drafts.csswg.org/cssom-view/#dom-range-getclientrects)을 2026-09-20에 확인했다.
`document.fonts.ready`와 FontFaceSet 상태는 로드 준비의 근거일 뿐, 모든 화면 문자가 의도한 폰트로 그려졌다는 증명은 아니다. 필요한 문자·언어·숫자·기호와 웨이트를 실제 카피로 렌더하고, 브라우저가 제공하는 rendered-font 확인 또는 필요한 글리프 검증과 화면 관찰을 결합한다. 폰트 선택·subset·fallback의 세부 절차는 [typography.md](typography.md)를 따른다.
### 4. 실패와 증거
실패는 제품 결함, 측정기 오류, 계약 변경으로 분류한다. 실패 artifact와 원문 결과는 보존한다. 의도 변경 근거와 전후 렌더 증거 없이 기대값을 고치거나 assertion을 완화해 통과시키지 않는다. 측정기가 틀렸다면 UI를 먼저 고치지 말고 RED 재현을 남겨 측정기를 고친 뒤 같은 사례를 GREEN으로 확인한다.
artifact 한 건에는 빌드·revision, viewport·zoom·스크롤·상태, 기대값과 관측값, 자동 결과, 실제 화면의 시각 판정을 같이 남긴다. 수치와 스크린샷이 서로 다른 장면이면 증거가 아니다.
| 작은 재현 예제 | 재현 조건 | 정상 기대 | 오판 / 금지되는 처리 |
|---|---|---|---|
| reveal 진행 중 / 완료 뒤 저대비 | 실제 스크롤로 reveal을 시작한 직후와 유한 전환 종료 뒤를 각각 캡처한다 | 진행 중 프레임은 완료 판정에서 제외하고, 종료 뒤에는 의도한 색·합성 대비와 가시성을 확인한다 | sleep 한 번 뒤 진행 프레임을 PASS로 쓰거나, 종료했지만 실제 색 대비가 부족한 텍스트를 통과시킨다 |
| line box 넘침 / 실제 hidden 조상 clip | 같은 긴 문장의 실제 잉크가 heading·line box 밖으로 나가게 두고, 한쪽은 `overflow: visible`, 다른 쪽은 잉크가 경계를 넘는 `overflow: hidden` 조상 안에 둔다 | 전자는 실제 잉크와 인접 영역을 보고 보존 여부를 판정하며, 후자는 실제 clip을 화면에서 확인한다 | Range rect 하나로 두 사례를 모두 잘림 또는 모두 정상으로 확정한다 |
| 같은 폭, 다른 높이의 피사체 크롭 | 같은 폭의 이미지 장면을 짧은 viewport와 긴 viewport에서 header 역할·스크롤 위치를 같게 맞춘다 | 피사체와 행동의 안전 영역이 두 높이에서 의도대로 남는지 비교한다 | 폭만 기록해 짧은 화면에서 잘린 얼굴·문구를 놓친다 |
| 의도 변경 없는 낡은 expected 값 | 렌더가 달라졌지만 디자인 의도 변경 기록이 없는 시각 회귀를 만든다 | 기존 기대값을 유지하고 제품 결함 또는 측정기 오류를 분류한다 | diff를 없애려고 expected·baseline만 무단 갱신한다 |
## 설치 (프로젝트에 게이트 심기)
```
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 로 재검.
- **안드로이드**: 시스템 글꼴 확대는 제목·컨트롤을 수축시킬 수 있다. gate의 `fontScales`와 computed root 스케일은 회귀 신호일 뿐, px 고정 서체의 실제 OS 확대를 보장하지 않는다. 실제 접근성 확대와 WCAG 200% zoom은 별도 브라우저·기기 검증으로 보고한다.
- **폰트 스왑**: `font-display: swap` 의 CLS — 폴백 메트릭 보정(typography.md §4) 없이 검사 통과를 선언하지 않는다.
## 하니스 규칙 (테스트를 만드는 테스트)
1. **선택자는 스코프필수** — 같은 data-* 가 다른 뷰에 있으면 숨은 요소를 잡는다(`#as-table [data-assign]` 처럼).
2. **page.evaluate 클로저 금지** — Node 스코프 함수를 페이지 안에서 부르지 않는다. 측정 코드는 문자열로 주입.
3. **줄 수 측정의 오탐 두 종류** — 폰트 메트릭 top 차이(±5px 허용오차), 인라인 아이콘과 텍스트의 top 차이(텍스트 노드만 분리 측정). line box와 실제 글리프 잘림의 구분은 [렌더 측정 계약](#렌더-측정-계약)을 따른다.
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. **등장 모션과 캡처 상태를 맞춘다** — 모션 지속시간은 사용자 경험의 목적·입력 반응·감소 모션 계약으로 정한다. 시각 회귀는 그 값을 줄이라고 요구하지 않고, 실제 완료를 관측한 뒤 캡처한다. 임의 sleep으로 진행 프레임을 통과시키지 않으며 실제 모션 검수는 [렌더 측정 계약](#렌더-측정-계약)을 따른다.
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