- 핵심 규칙 6: 완료의 정의는 게이트 통과. 사용자를 QA로 쓰지 않는다 - 5단계 기계 검사 4번째: design-gate 실행 의무화 - references/audit-gate.md: 사고-검사 매핑, SEO/meta 체크리스트, OS/브라우저 특성, 하니스 규칙 - tools/design-gate.mjs: 범용 게이트(메타/SEO·대비·수축·리듬·트랙·스케일×폭 + 옵션 L0/L3/L4, checks 깊은 병합) - 리듬·트랙 불변식: 숫자 라벨 등폭·빈 셀 트랙 균일·등간격 — 시간표 자동배치 결함 재현 픽스처 검출, 앱 15뷰 통과(오탐 0) feat(site): 관리 앱 2종 프로덕션 콘솔(v6→v18) - 가온 학적부 9뷰·두레 수강신청 6뷰: shadcn 문법, Pretendard/Noto Serif/IBM Plex 폰트 전략, 볼드 금지(400/500/600), WCAG AA 대비 전면 교정, 한글 keep-all 조판 - LMS 필수 요소(알림 센터·공지·진도·평가 유형·출결 사유·학점 경고), 12명 기준 데이터 정합, SEO 구조(h1 유일·OG/twitter·og.png) - 시간표 자동배치 결함 수리(명시적 격자 좌표) - QA 게이트: L0 stylelint/html-validate · L1 단위 30 · L2 감사 61+불변식 · L3 시각회귀 30화면 · L4 WebKit · L5 키보드 탐색 — npm run verify 실패 시 배포 금지 체인
This commit is contained in:
parent
78ddc8b61a
commit
72337b7ee0
226 changed files with 18212 additions and 380 deletions
|
|
@ -1,7 +1,26 @@
|
|||
# @designpaca/skill
|
||||
# CHANGELOG
|
||||
|
||||
## 0.6.0 — 자율 검증 폐쇄 루프 + 리듬·트랙 불변식 (2026-08-22)
|
||||
|
||||
- 핵심 규칙 6: 완료의 정의 = 게이트 통과. 사용자를 QA 로 쓰지 않는다.
|
||||
- 5단계 기계 검사 4번째: design-gate 실행 의무.
|
||||
- references/audit-gate.md 신설(사고-검사 매핑·SEO/meta 체크리스트·OS/브라우저 특성·하니스 규칙).
|
||||
- tools/design-gate.mjs 신설: 메타/SEO·대비·수축·리듬·트랙(숫자 라벨 등폭·빈 셀 트랙·등간격)·스케일×폭 + 옵션 L0/L3/L4. checks 깊은 병합, 의존성 미비 SKIP.
|
||||
- 실전 검증: 시간표 자동배치 결함(라벨 폭 40/88 혼재) 재현 픽스처 검출 + 수정된 앱 15뷰 통과(오탐 0).
|
||||
|
||||
|
||||
## 자율 검증 폐쇄 루프 내재화 (2026-08-22)
|
||||
|
||||
- 핵심 규칙 6 추가: 완료의 정의 = 게이트 통과. 사용자를 QA 로 쓰지 않는다.
|
||||
- 5단계 기계 검사 4번째: design-gate 실행 의무화.
|
||||
- references/audit-gate.md 신설: 6계층·사고-검사 매핑·SEO/meta 체크리스트·OS/브라우저 특성·하니스 규칙 7개.
|
||||
- tools/design-gate.mjs 신설: 범용 게이트(불변식·SEO/meta·대비·수축·스케일×폭 + 옵션 시각회귀/WebKit), --init 스캐폴딩, 의존성 미비는 SKIP 처리.
|
||||
|
||||
#
|
||||
@designpaca/skill
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 8d02820: 어떤 하네스 에이전트든 스킬을 발견하게 만든다. 중립 경로 `agents` 타깃(`~/.agents/skills` · `.agents/skills`)을 새로 추가한다 — Cursor · Antigravity · ZCode 등 Agent Skills 규약을 읽는 하네스가 함께 잡는 자리다. `gemini-cli`(`.gemini/skills`)와 `copilot`(`.github/skills` · `~/.copilot/skills`) 타깃을 추가한다. Cursor 는 구식 `.cursor/rules/*.mdc` 변환을 버리고 네이티브 `.cursor/skills` 로 옮기며 전역 설치를 지원한다. Windsurf 도 규칙 파일(12,000자 상한 때문에 실제 스킬이 아예 설치되지 못했다)에서 `.windsurf/skills` · `~/.codeium/windsurf/skills` 네이티브 경로로 옮긴다. Antigravity 전역 경로를 공식 마이그레이션 문서대로 `~/.gemini/antigravity-cli/skills` 로 바로잡는다.
|
||||
- 8d02820: 어떤 하네스 에이전트든 스킬을 발견하게 만든다. 중립 경로 `agents` 타깃(`~/.agents/skills` · `.agents/skills`)을 새로 추가한다 — Cursor · Antigravity · ZCode 등 Agent Skills 규약을 읽는 하네스가 함께 잡는 자리다. `gemini-cli`(`.gemini/skills`)와 `copilot`(`.github/skills` · `~/.copilot/skills`) 타깃을 추가한다. Cursor 는 구식 `.cursor/rules/*.mdc` 변환을 버리고 네이티브 `.cursor/skills` 로 옮기며 전역 설치를 지원한다. Windsurf 도 규칙 파일(12,000자 상한 때문에 실제 스킬이 아예 설치되지 못했다)에서 `.windsurf/skills` · `~/.codeium/windsurf/skills` 네이티브 경로로 옮긴다. Antigravity 전역 경로를 공식 마이그레이션 문서대로 `~/.gemini/antigravity-cli/skills` 로 바로잡는다.
|
||||
|
|
@ -34,6 +34,7 @@ description: "웹 디자인 전 과정을 끌고 가는 파이프라인 스킬.
|
|||
3. **화려함은 4순위다.** Awwwards 배점이 Design 40 / Usability 30 / Creativity 20 / Content 10이다. 3D를 얹는 것보다 타입 스케일을 정돈하는 게 결과에 두 배 유효하다.
|
||||
4. **대담함은 한 곳에만.** 리스크는 하나다. 나머지는 조용히 받쳐준다. 두 곳에서 소리치면 둘 다 죽는다.
|
||||
5. **성능 예산을 넘기면 그 이펙트는 채택하지 않는다.** 예산은 3단계에서 정하고 5단계에서 검증한다. 데모·포트폴리오 브리프면 예산을 올려도 되지만 **올렸다고 말해야 한다**.
|
||||
6. **완료의 정의는 게이트 통과다(자율 검증 폐쇄 루프).** 5단계에서 `tools/design-gate.mjs` 를 프로젝트에 심어 돌리고, 실패 항목이 있으면 4단계로 돌아가 고치고 **전체를 재실행**한다. 게이트 없이 6단계로 넘어가지 않는다. 사용자를 QA 로 쓰지 않는다 — 검증은 기본 내장, 사용자 피드백은 보조다. 상세는 `references/audit-gate.md`.
|
||||
|
||||
**접근성은 예외다. 이것만은 협상하지 않는다.** 키보드·포커스·대비·`prefers-reduced-motion`은 어떤 이펙트보다, 어떤 브리프보다 우선한다. 픽셀은 왜곡해도 DOM은 살린다.
|
||||
|
||||
|
|
@ -276,6 +277,9 @@ HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는
|
|||
1. **슬롭 지문 grep** — 보라 CTA(`#6366f1`·`#8b5cf6` 계열), 전체 대문자 헤드라인, 번호 매긴 1·2·3 단계. 실측 검출률 상위 항목이다
|
||||
2. **카피 경쟁사 치환 테스트** — 제품명을 경쟁사 이름으로 바꿔도 문장이 성립하면, 그 카피는 아무것도 말하지 않았다
|
||||
3. **이펙트 전부 끄기** — CSS 필터·WebGL·애니메이션을 끈 상태에서 페이지가 여전히 읽히는가
|
||||
4. **게이트 실행(폐쇄 루프)** — `tools/design-gate.mjs --init` 으로 설정을 만들고 돌린다. 오버플로·h1·수축·대비·SEO/meta·스케일×폭까지 전 뷰 × 전 폭. L1(단위)·L5(탐색)는 프로젝트 `tools/` 에 보강해 `npm run verify` 체인으로. **게이트 통과 없이 완료 선언 금지.**
|
||||
|
||||
> 게이트가 잡은 실제 사고 목록과 6계층 설계 근거는 `references/audit-gate.md` 다. 자기 검사 스크립트를 매번 새로 쓰지 마라 — 이 게이트를 심고 확장해라.
|
||||
|
||||
#### 하드 게이트 — 전부 "아니오"여야 한다
|
||||
|
||||
|
|
@ -350,6 +354,8 @@ HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는
|
|||
| `references/experimental-canvas.md` | 4단계 — HTML-in-Canvas를 검토할 때 |
|
||||
| `references/preflight.md` | 5단계 — 감사 |
|
||||
| `references/antipatterns.md` | 3·5단계 — 한글 조판 / 슬롭 검출 |
|
||||
| `references/audit-gate.md` | **5단계 — 게이트(폐쇄 루프) 설계·설치·하니스 규칙** |
|
||||
| `tools/design-gate.mjs` | **5단계 — 범용 게이트 실행기(SEO/meta·대비·수축·시각·WebKit 옵션)** |
|
||||
|
||||
## 작업 중 지켜야 할 것
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@designpaca/skill",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"description": "designpaca 스킬 원본 — SKILL.md 와 참조 문서",
|
||||
"scripts": {
|
||||
|
|
|
|||
80
packages/skill/references/audit-gate.md
Normal file
80
packages/skill/references/audit-gate.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# 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 안 `<p>`, role="grid" 자식 구문 위반 | html-validate + axe |
|
||||
| 계산 로직 오류(행 배열을 값으로 셈 → 0/3) | 단위 테스트(브라우저 밖 경계값) |
|
||||
| 같은 데이터 속성이 여러 뷰에 존재해 선택자 오작동 | 하니스 규칙: 선택자는 컨테이너 스코프 |
|
||||
| 혼합 span 그리드의 자동 배치가 시간 라벨을 흩어놓음(같은 열 폭 40/78 혼재, Δ 불규칙) | **리듬·트랙 불변식** — 같은 부모·같은 클래스 형제 중 (a) 숫자/시간 라벨(등폭 의도)과 (b) 빈 격자 셀(트랙 균일 의도)은 폭이 균일해야 하고, 한 축 정렬·크기 균일 형제는 등간격이어야 한다. 한국어 라벨은 글자수가 같아도 내용 폭차가 자연스러우므로 폭 검사 대상에서 뺀다(오탐 방지). 근본 수칙: 혼합 span 그리드는 자동 배치를 믿지 않고 좌표를 명시하라 |
|
||||
|
||||
## 6계층
|
||||
|
||||
`tools/design-gate.mjs` 가 한 파일로 돌리는 것과 프로젝트가 보강하는 것이 있다.
|
||||
|
||||
| 계층 | 도구 | 비고 |
|
||||
|---|---|---|
|
||||
| L0 정적 | stylelint + html-validate | 문법·구문 위반을 브라우저 켜기 전에 |
|
||||
| L1 단위 | 순수 계산 로직 추출(calc.js) + 경계값 테스트 | 프로젝트별 작성 — 0명/만석/초과/빈배열 |
|
||||
| L2 불변식 | `design-gate.mjs` — 오버플로·h1·수축·대비·SEO/meta | 전 뷰 × 전 폭 |
|
||||
| L3 시각 회귀 | 기준 화면 pixelmatch diff (≥0.1% 실패) | 갱신은 검증된 배포 후 `--update-baseline` |
|
||||
| L4 WebKit | Playwright webkit | 사파리 엔진 렌더 차이 |
|
||||
| L5 탐색 | 키보드 완전 통과 + 사용자 여정 차터 | 프로젝트별 작성 — 마우스 금지 |
|
||||
|
||||
## 설치 (프로젝트에 게이트 심기)
|
||||
|
||||
```
|
||||
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"가 아니라 "무엇을 몇으로 확인했나".
|
||||
|
||||
## 리포트 양식
|
||||
|
||||
```
|
||||
게이트: 전 계층 통과 — 배포 가능 · 총 Ns
|
||||
| 계층 | 결과 | 항목 | 소요 |
|
||||
```
|
||||
|
||||
실패 상세는 원문 테일 포함. 리포트 파일(gate-report.md / verify-report.md)은 커밋 대상이다.
|
||||
359
packages/skill/tools/design-gate.mjs
Normal file
359
packages/skill/tools/design-gate.mjs
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
#!/usr/bin/env node
|
||||
// design-gate — 범용 디자인 QA 게이트 (designpaca 스킬 동반 도구)
|
||||
//
|
||||
// 조사 기반 6계층 중 이 파일 하나로 돌리는 것: L0(정적·옵션) + L2(불변식·SEO/meta) + L3(시각회귀·옵션) + L4(WebKit·옵션).
|
||||
// 프로젝트 고유의 L1(단위)·L5(탐색)는 각 프로젝트의 calc/테스트로 보강한다.
|
||||
//
|
||||
// 사용:
|
||||
// node design-gate.mjs --init # 설정파일 게이트 초안 생성
|
||||
// node design-gate.mjs # gate.config.json 기준 실행
|
||||
// node design-gate.mjs --update-baseline # 시각 기준화면 갱신(검증된 배포 후에만)
|
||||
//
|
||||
// 철학: 없는 도구는 SKIP(실패 아님), 검사 가능한 것은 전부 검사(실패면 exit 1).
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const cfgFile = path.resolve(process.cwd(), argv.includes("--init") ? "gate.config.json" : (argv.find((a) => a.startsWith("--config=")) || "gate.config.json").replace("--config=", ""));
|
||||
|
||||
const DEFAULT_CFG = {
|
||||
pages: [
|
||||
{
|
||||
name: "app",
|
||||
path: "index.html",
|
||||
viewAttribute: "data-view",
|
||||
views: ["main"],
|
||||
h1: "h1",
|
||||
brand: ".brand",
|
||||
},
|
||||
],
|
||||
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 },
|
||||
chromePath: null,
|
||||
};
|
||||
|
||||
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 <이 스크립트 경로>\" } 등록을 권장합니다.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const _user = JSON.parse(fs.readFileSync(cfgFile, "utf8"));
|
||||
const CFG = { ...DEFAULT_CFG, ..._user, checks: { ...DEFAULT_CFG.checks, ...(_user.checks || {}) } }; // checks 는 깊은 병합 — 새 검사가 기본 켜지도록
|
||||
const UPDATE = argv.includes("--update-baseline");
|
||||
const results = [];
|
||||
const pass = (name, detail = "") => results.push({ ok: true, name, detail });
|
||||
const fail = (name, detail = "") => results.push({ ok: false, name, detail });
|
||||
const skip = (name, detail) => results.push({ ok: true, name, detail: "SKIP — " + detail });
|
||||
|
||||
// ── 브라우저 확보 (puppeteer-core + 크롬 경로 자동 탐색) ──
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.CHROME_PATH,
|
||||
CFG.chromePath,
|
||||
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium-browser",
|
||||
].filter(Boolean);
|
||||
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);
|
||||
|
||||
// ── 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));
|
||||
} else skip("L0 정적", "checks.static=false");
|
||||
|
||||
// ── 브라우저 계층 ──
|
||||
if (!CHROME) {
|
||||
console.log("chrome/puppeteer-core 없음 — CHROME_PATH 를 설정하세요.");
|
||||
process.exit(1);
|
||||
}
|
||||
let puppeteer;
|
||||
try { puppeteer = await import("puppeteer-core"); }
|
||||
catch { console.log("puppeteer-core 미설치 — npm i -D puppeteer-core 필요"); process.exit(1); }
|
||||
|
||||
const UTILS = `
|
||||
window.__lines = (sel) => {
|
||||
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
|
||||
if (!el) return -1;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4);
|
||||
const tops = rects.map((r) => r.top).sort((a, b) => a - b);
|
||||
const ls = [];
|
||||
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
|
||||
return ls.length;
|
||||
};
|
||||
`;
|
||||
|
||||
const browser = await puppeteer.default.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] });
|
||||
const page = await browser.newPage();
|
||||
await page.setCacheEnabled(false);
|
||||
|
||||
for (const p of CFG.pages) {
|
||||
const href = pageUrl(p);
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(href, { waitUntil: "networkidle0" });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.evaluate(UTILS);
|
||||
|
||||
// ── SEO / meta (조사 기반 체크리스트) ──
|
||||
if (CFG.checks.meta) {
|
||||
const meta = await page.evaluate(() => {
|
||||
const get = (sel, attr = "content") => { const el = document.querySelector(sel); return el ? (el.getAttribute(attr) || "") : null; };
|
||||
const title = document.title.trim();
|
||||
const desc = get('meta[name="description"]');
|
||||
const og = (k) => get(`meta[property="og:${k}"]`);
|
||||
const imgs = [...document.querySelectorAll("img")];
|
||||
return {
|
||||
title, titleLen: title.length,
|
||||
desc, descLen: desc ? desc.length : 0,
|
||||
viewport: get('meta[name="viewport"]'),
|
||||
lang: document.documentElement.getAttribute("lang"),
|
||||
charset: !!document.querySelector("meta[charset], meta[http-equiv='Content-Type']"),
|
||||
canonical: get('link[rel="canonical"]', "href"),
|
||||
ogTitle: og("title"), ogDesc: og("description"), ogImage: og("image"), ogUrl: og("url"),
|
||||
twitter: get('meta[name="twitter:card"]'),
|
||||
robots: get('meta[name="robots"]'),
|
||||
favicon: !!document.querySelector('link[rel~="icon"]'),
|
||||
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; } }),
|
||||
jsonldCount: document.querySelectorAll('script[type="application/ld+json"]').length,
|
||||
};
|
||||
});
|
||||
const m = [];
|
||||
if (!meta.title) m.push("title 없음");
|
||||
else if (meta.titleLen < 10 || meta.titleLen > 65) m.push(`title ${meta.titleLen}자(10~65 권장)`);
|
||||
if (!meta.desc) m.push("description 없음");
|
||||
else if (meta.descLen < 60 || meta.descLen > 165) m.push(`description ${meta.descLen}자(60~165 권장)`);
|
||||
if (!meta.viewport) m.push("viewport 없음");
|
||||
if (!meta.lang) m.push("html lang 없음");
|
||||
if (!meta.charset) m.push("charset 없음");
|
||||
if (meta.ogTitle === null) m.push("og:title 없음");
|
||||
if (meta.ogImage === null) m.push("og:image 없음");
|
||||
if (meta.twitter === null) m.push("twitter:card 없음");
|
||||
if (!meta.favicon) m.push("favicon 없음");
|
||||
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 파싱 실패");
|
||||
// 데모·로컬 페이지는 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}결`);
|
||||
}
|
||||
|
||||
// ── 각 뷰 불변식 ──
|
||||
const goView = async (v) => {
|
||||
if (!p.viewAttribute || v === "single") return;
|
||||
await page.evaluate((attr, name) => {
|
||||
const btn = document.querySelector(`[${attr}="${name}"]`);
|
||||
btn && btn.click();
|
||||
}, p.viewAttribute, v);
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
};
|
||||
|
||||
for (const v of p.views) {
|
||||
await goView(v);
|
||||
// 오버플로 — 전 폭
|
||||
let overs = [];
|
||||
for (const w of CFG.widths) {
|
||||
await page.setViewport({ width: w, height: 900 });
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
if (over > 0) overs.push(`+${over}px@${w}`);
|
||||
}
|
||||
overs.length ? fail(`${p.name}/${v} 오버플로`, overs.join(" ")) : pass(`${p.name}/${v} 오버플로`, CFG.widths.join("/"));
|
||||
|
||||
// h1 1줄
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
const h1l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
|
||||
h1l === 1 ? pass(`${p.name}/${v} h1 1줄`) : (h1l > 1 ? fail(`${p.name}/${v} h1 ${h1l}줄`) : skip(`${p.name}/${v} h1`, "요소 없음"));
|
||||
|
||||
// 세로 쌓임(수축) — 짧은 라벨 3줄 이상
|
||||
if (CFG.checks.stack) {
|
||||
const stacked = await page.evaluate((limit) => {
|
||||
const out = [];
|
||||
document.querySelectorAll("body *").forEach((el) => {
|
||||
if (!el.offsetParent || el.querySelector("*")) return;
|
||||
const txt = el.textContent.trim();
|
||||
if (txt.length && txt.length <= 14 && window.__lines(el) >= limit) out.push(txt.slice(0, 10));
|
||||
});
|
||||
return [...new Set(out)].slice(0, 5);
|
||||
}, CFG.thresholds.stackLines);
|
||||
stacked.length ? fail(`${p.name}/${v} 세로쌓임(수축)`, stacked.join(",")) : pass(`${p.name}/${v} 수축 없음`);
|
||||
}
|
||||
|
||||
// 리듬·트랙 불변식 — 같은 부모·같은 클래스의 형제들이 한 축으로 정렬돼 있으면 폭·간격이 균일해야 한다
|
||||
// (시간표 시간 라벨이 자동 배치로 흩어지던 사고: 같은 열인데 폭 40/78 혼재, Δ 22·194 불규칙)
|
||||
if (CFG.checks.rhythm) {
|
||||
const rhythm = await page.evaluate(() => {
|
||||
const groups = {};
|
||||
document.querySelectorAll("body *").forEach((el) => {
|
||||
if (!el.offsetParent || !el.parentElement || !el.classList.length || el.children.length) return;
|
||||
const cls = el.classList[0];
|
||||
const key = (el.parentElement.className || el.parentElement.tagName) + "|" + cls;
|
||||
const r = el.getBoundingClientRect();
|
||||
(groups[key] = groups[key] || []).push({ x: r.left, y: r.top, w: r.width, h: r.height, t: el.textContent.trim().slice(0, 6), p: el.parentElement });
|
||||
});
|
||||
const issues = [];
|
||||
for (const [key, items] of Object.entries(groups)) {
|
||||
if (items.length < 4) continue;
|
||||
const spread = (v) => Math.max(...v) - Math.min(...v);
|
||||
const cls = key.split("|")[1];
|
||||
const hUniform = spread(items.map((i) => i.h)) <= 4;
|
||||
const txtUniform = spread(items.map((i) => i.t.length)) <= 1;
|
||||
const isDigits = items.every((i) => /^[0-9: .]+$/.test(i.t) && i.t.length > 0); // 숫자/시간 라벨 — 등폭이 의도
|
||||
const isEmpty = items.every((i) => !i.t); // 빈 격자 셀 — 트랙 균일이 의도
|
||||
// 1) 트랙 폭: 숫자 라벨·빈 셀·한 축 정렬 라벨은 폭이 균일해야 한다 — 자동 배치 흩어짐(열 섞임)을 잡는다.
|
||||
// 한국어 라벨은 글자수가 같아도 폭이 달라지므로, 한 축 정렬 또는 숫자 전용일 때만 검사한다(오탐 방지).
|
||||
const wSpread = spread(items.map((i) => i.w));
|
||||
const alignedCol = spread(items.map((i) => i.x)) < 3;
|
||||
if (hUniform && wSpread > 6 && (isDigits || isEmpty)) { // 숫자 라벨·빈 셀만 — 한국어 라벨은 내용 폭차가 자연스럽다
|
||||
issues.push(`${cls}: 폭 ${Math.round(Math.min(...items.map((i) => i.w)))}~${Math.round(Math.max(...items.map((i) => i.w)))}px 불균일 — 트랙/배치 의심`);
|
||||
}
|
||||
// 2) 리듬: 한 축으로 정렬된 균일 크기 형제는 등간격이어야 한다
|
||||
if (spread(items.map((i) => i.x)) < 3 && hUniform) {
|
||||
const ys = items.map((i) => i.y).sort((a, b) => a - b);
|
||||
const deltas = ys.slice(1).map((y, i) => y - ys[i]);
|
||||
if (deltas.length >= 3 && Math.max(...deltas) > Math.min(...deltas) * 1.6 + 4) issues.push(`${cls}: 세로 간격 Δ${Math.round(Math.min(...deltas))}~${Math.round(Math.max(...deltas))}px 불균일`);
|
||||
} else if (spread(items.map((i) => i.y)) < 3 && hUniform && spread(items.map((i) => i.w)) <= 4) {
|
||||
const xs2 = items.map((i) => i.x).sort((a, b) => a - b);
|
||||
const d2 = xs2.slice(1).map((x, i) => x - xs2[i]);
|
||||
if (d2.length >= 3 && Math.max(...d2) > Math.min(...d2) * 1.6 + 4) issues.push(`${cls}: 가로 간격 불균일 Δ${Math.round(Math.min(...d2))}~${Math.round(Math.max(...d2))}px`);
|
||||
}
|
||||
}
|
||||
return issues.slice(0, 4);
|
||||
});
|
||||
rhythm.length ? fail(p.name + "/" + v + " 리듬·트랙", rhythm.join(" · ")) : pass(p.name + "/" + v + " 리듬·트랙");
|
||||
}
|
||||
|
||||
// 대비
|
||||
if (CFG.checks.contrast) {
|
||||
const bad = await page.evaluate((cn, cl) => {
|
||||
const lum = ({ r, g, b }) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
|
||||
const rgb = (c) => { const m = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/); return m ? { r: +m[1], g: +m[2], b: +m[3], a: m[4] === undefined ? 1 : +m[4] } : null; };
|
||||
const blend = (t, b) => ({ r: t.r * t.a + b.r * (1 - t.a), g: t.g * t.a + b.g * (1 - t.a), b: t.b * t.a + b.b * (1 - t.a), a: 1 });
|
||||
const seen = new Set(); const out = [];
|
||||
document.querySelectorAll("body *").forEach((el) => {
|
||||
if (!el.offsetParent) return;
|
||||
if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) return;
|
||||
const cs = getComputedStyle(el);
|
||||
const fg = rgb(cs.color); if (!fg) return;
|
||||
let node = el; const stack = [];
|
||||
while (node && node !== document.documentElement) {
|
||||
const c = rgb(getComputedStyle(node).backgroundColor);
|
||||
if (c && c.a >= 0.95) { stack.unshift(c); break; }
|
||||
if (c && c.a > 0) stack.unshift(c);
|
||||
node = node.parentElement;
|
||||
}
|
||||
let bg = { r: 255, g: 255, b: 255, a: 1 };
|
||||
for (const s of stack) bg = blend(s, bg);
|
||||
const fgb = blend(fg, bg);
|
||||
const l1 = lum(fgb), l2 = lum(bg);
|
||||
const ratio = +(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05))).toFixed(2);
|
||||
const size = parseFloat(cs.fontSize); const w = +cs.fontWeight || 400;
|
||||
const need = size >= 24 || (size >= 18.66 && w >= 600) ? cl : cn;
|
||||
if (ratio < need) {
|
||||
const k = cs.color + Math.round(size);
|
||||
if (!seen.has(k)) { seen.add(k); out.push(`"${el.textContent.trim().slice(0, 8)}" ${ratio}:1`); }
|
||||
}
|
||||
});
|
||||
return out.slice(0, 4);
|
||||
}, CFG.thresholds.contrastNormal, CFG.thresholds.contrastLarge);
|
||||
bad.length ? fail(`${p.name}/${v} 대비`, bad.join(",")) : pass(`${p.name}/${v} 대비 AA`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 스케일×폭 h1 행렬 (안드로이드 글꼴 확대 재현) ──
|
||||
if (CFG.checks.scaleMatrix) {
|
||||
const bad = [];
|
||||
for (const w of [375, 390]) {
|
||||
for (const s of CFG.fontScales) {
|
||||
await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true });
|
||||
await page.goto(href, { waitUntil: "networkidle0" });
|
||||
await page.evaluate(UTILS);
|
||||
await page.addStyleTag({ content: `html { font-size: ${16 * s}px !important; }` });
|
||||
const l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
|
||||
if (l > 1) bad.push(`${w}@${s}x:${l}줄`);
|
||||
}
|
||||
}
|
||||
bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `scales ${CFG.fontScales.join("/")}`);
|
||||
// 브랜드 1줄
|
||||
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
|
||||
await page.goto(href, { waitUntil: "networkidle0" });
|
||||
await page.evaluate(UTILS);
|
||||
if (p.brand) {
|
||||
const bl = await page.evaluate(`__lines(${JSON.stringify(p.brand)})`);
|
||||
bl === 1 ? pass(`${p.name} 브랜드 1줄`) : fail(`${p.name} 브랜드 ${bl}줄(수축 의심)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── L3 시각 회귀 (옵션: pixelmatch·pngjs 설치 시) ──
|
||||
if (CFG.checks.visual) {
|
||||
try {
|
||||
const { default: pixelmatch } = await import("pixelmatch");
|
||||
const { PNG } = await import("pngjs");
|
||||
const BASE = path.resolve(process.cwd(), "__gate__/baseline");
|
||||
fs.mkdirSync(BASE, { recursive: true });
|
||||
let diffs = 0, n = 0;
|
||||
for (const p of CFG.pages) {
|
||||
for (const w of [390, 1440]) {
|
||||
await page.setViewport({ width: w, height: w === 390 ? 844 : 900 });
|
||||
await page.goto(pageUrl(p), { waitUntil: "networkidle0" });
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const name = `${p.name}-${w}.png`;
|
||||
const buf = await page.screenshot();
|
||||
const cur = PNG.sync.read(buf);
|
||||
const bf = path.join(BASE, name);
|
||||
if (UPDATE || !fs.existsSync(bf)) { fs.writeFileSync(bf, buf); continue; }
|
||||
const base = PNG.sync.read(fs.readFileSync(bf));
|
||||
if (base.width !== cur.width || base.height !== cur.height) { diffs++; fail(`L3 시각 ${name}`, "크기 불일치"); continue; }
|
||||
const cnt = pixelmatch(base.data, cur.data, null, base.width, base.height, { threshold: 0.1 });
|
||||
const pct = (cnt / (base.width * base.height)) * 100;
|
||||
n++;
|
||||
if (pct >= CFG.thresholds.visualDiffPct) { diffs++; fail(`L3 시각 ${name}`, pct.toFixed(3) + "% diff"); }
|
||||
}
|
||||
}
|
||||
!diffs && pass("L3 시각 회귀", `${n}화면 ${UPDATE ? "기준갱신" : "일치"}`);
|
||||
} catch (e) { skip("L3 시각 회귀", "pixelmatch/pngjs 미설치"); }
|
||||
} else skip("L3 시각 회귀", "checks.visual=false");
|
||||
|
||||
await browser.close();
|
||||
|
||||
// ── L4 WebKit (옵션: playwright 설치 시) ──
|
||||
if (CFG.checks.webkit) {
|
||||
try {
|
||||
const { webkit } = await import("playwright");
|
||||
const b2 = await webkit.launch();
|
||||
const pg = await b2.newPage();
|
||||
await pg.setViewportSize({ width: 390, height: 844 });
|
||||
let bad = 0;
|
||||
for (const p of CFG.pages) {
|
||||
await pg.goto(pageUrl(p), { waitUntil: "networkidle" });
|
||||
const over = await pg.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
if (over > 0) { fail(`L4 WebKit ${p.name} 오버플로`, `+${over}px`); bad++; }
|
||||
}
|
||||
await b2.close();
|
||||
!bad && pass("L4 WebKit", `${CFG.pages.length}페이지 오버플로 0`);
|
||||
} catch { skip("L4 WebKit", "playwright/webkit 미설치"); }
|
||||
} else skip("L4 WebKit", "checks.webkit=false");
|
||||
|
||||
// ── 리포트 ──
|
||||
const fails = results.filter((r) => !r.ok).length;
|
||||
const lines = results.map((r) => `${r.ok ? "PASS" : "FAIL"} ${r.name}${r.detail ? " — " + r.detail : ""}`);
|
||||
console.log(lines.join("\n"));
|
||||
const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
|
||||
fs.writeFileSync(path.resolve(process.cwd(), "gate-report.md"),
|
||||
`# 게이트 리포트 — ${stamp}\n\n${fails ? `**${fails}건 실패 — 배포 금지**` : "**전 항목 통과 — 배포 가능**"}\n\n\`\`\`\n${lines.join("\n")}\n\`\`\`\n`);
|
||||
console.log(`\n${fails ? `게이트 실패(${fails})` : "게이트 통과"} — gate-report.md 생성`);
|
||||
process.exit(fails ? 1 : 0);
|
||||
Loading…
Add table
Add a link
Reference in a new issue