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:
Yun Chan 2026-08-23 14:33:15 +09:00
parent 72337b7ee0
commit 14cb138e1a
133 changed files with 2139 additions and 208 deletions

View file

@ -1,5 +1,7 @@
# @designpaca/core
## 0.7.0
## 0.5.0
### Minor Changes

View file

@ -1,6 +1,6 @@
{
"name": "@designpaca/core",
"version": "0.5.0",
"version": "0.7.0",
"private": true,
"description": "designpaca 설치 엔진 — 타깃 어댑터, 매니페스트, 드리프트 감지",
"type": "module",

View file

@ -35,6 +35,7 @@ description: "웹 디자인 전 과정을 끌고 가는 파이프라인 스킬.
4. **대담함은 한 곳에만.** 리스크는 하나다. 나머지는 조용히 받쳐준다. 두 곳에서 소리치면 둘 다 죽는다.
5. **성능 예산을 넘기면 그 이펙트는 채택하지 않는다.** 예산은 3단계에서 정하고 5단계에서 검증한다. 데모·포트폴리오 브리프면 예산을 올려도 되지만 **올렸다고 말해야 한다**.
6. **완료의 정의는 게이트 통과다(자율 검증 폐쇄 루프).** 5단계에서 `tools/design-gate.mjs` 를 프로젝트에 심어 돌리고, 실패 항목이 있으면 4단계로 돌아가 고치고 **전체를 재실행**한다. 게이트 없이 6단계로 넘어가지 않는다. 사용자를 QA 로 쓰지 않는다 — 검증은 기본 내장, 사용자 피드백은 보조다. 상세는 `references/audit-gate.md`.
7. **OS·플랫폼 관습은 조사 없이 바꾸지 않는다.** 내비 구조·뒤로가기·시트·safe area·터치 타깃 같은 관습은 사용자가 이미 OS 전체에서 배운 것이다. "웹에서는 그랬다"를 폰에 옮기면 백 키가 앱을 종료하는 웹앱이 된다(실측 사고). 바꾸기 전에 HIG·M3·NN/g 에서 근거를 찾고, 모바일 앱 수준 브리프면 `references/mobile-app-ux.md` 를 구현 전에 읽는다.
**접근성은 예외다. 이것만은 협상하지 않는다.** 키보드·포커스·대비·`prefers-reduced-motion`은 어떤 이펙트보다, 어떤 브리프보다 우선한다. 픽셀은 왜곡해도 DOM은 살린다.
@ -258,6 +259,12 @@ headed 브라우저가 있으면 스크린샷과 실측값을 바로 받는다
**4-4. 모션과 인터랙션** → `references/motion.md`
모션은 장식이 아니라 **문법**이다. 무엇이 어디서 와서 어디로 가는지 말한다. 이유 없는 등장 애니메이션은 넣지 않는다.
**4-5. 서류로서의 완성** — 프리셋이 서류·원장·콘솔 계열(장부, 시간표, 관리 화면)일 때만 도는 단계다.
그 앱이 종이에서 하던 일을 화면이 이어받아야 프로덕션이다. 셋을 검토한다:
- **인쇄** — `@media print`. 앱 크롬(내비·툴바·버튼)은 물러나고 활성 화면이 서류가 된다. 모달이 열려 있으면 그 서류만(`body:has(dialog[open])` 로 앱을 숨긴다). 상태색은 `print-color-adjust: exact` 로 보존. 인쇄 버튼을 크롬에 더했다면 **그 줄의 최소폭 합**을 가장 좁은 폭에서 다시 잰다(하드 게이트 5)
- **키보드 단축키** — 콘솔의 손가락 문법. 숫자키 뷰 전환, `/` 검색 포커스. `kbd` 물리 키 칩으로 안내하고, 입력 요소에 포커스가 있을 때는 무력화한다
- **본연의 도메인 동작** — 학적부는 기록, 시간표는 격자, 주문은 원장. 그 도메인이 종이에서 하던 핵심 동작 하나가 빠져 있으면 그게 곧 '데모 티'다
**실험 경로** → `references/experimental-canvas.md`
HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는다. 기본값은 쓰지 않는 것이다.
@ -272,12 +279,13 @@ HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는
→ `references/preflight.md` (전체 체크리스트)
→ `references/antipatterns.md` (슬롭 지문 목록 + grep 검출 + 자가 채점표)
특히 다음 셋은 기계적으로 검사할 수 있다. **반드시 돌려라**:
특히 다음 다섯은 기계적으로 검사할 수 있다. **반드시 돌려라**:
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` 체인으로. **게이트 통과 없이 완료 선언 금지.**
4. **게이트 실행(폐쇄 루프)** — `tools/design-gate.mjs --init` 으로 설정을 만들고 돌린다. 오버플로·h1·수축·대비·SEO/meta·죽은 선택자·토큰 위생·스케일×폭까지 전 뷰 × 전 폭. L1(단위)·L5(탐색)·L6(시나리오 E2E)는 프로젝트 `tools/` 에 보강해 `npm run verify` 체인으로. **게이트 통과 없이 완료 선언 금지.**
5. **상태 완결성 스윕** — 입력·파괴·열림이 있는 화면은 실제로 조작해 본다: 수치 입력에 범위 밖 값을 넣고, 파괴적 행동을 되돌려보고, 열린 메뉴를 Esc 로 닫는다. 기준은 `references/preflight.md` §4-1. 통과 못 하면 4단계로
> 게이트가 잡은 실제 사고 목록과 6계층 설계 근거는 `references/audit-gate.md` 다. 자기 검사 스크립트를 매번 새로 쓰지 마라 — 이 게이트를 심고 확장해라.
@ -300,6 +308,8 @@ HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는
걸렸으면 셋 중 하나다: (a) `{{SETUP_TIME}}` 같은 **명시적 placeholder** 로 바꾸고 6단계 `design.md` 미확정 목록에 올린다, (b) 사용자에게 실제 값을 묻고 멈춘다, (c) 그 섹션 자체를 다른 구조로 바꾼다.
**placeholder 는 통과다.** 숫자 모양의 구멍은 정직하고, 지어낸 숫자는 슬롭이다.
12. `<div>` 로 만든 가짜 스크린샷·가짜 브라우저바·가짜 폰 프레임이 있는가?
13. **토큰 체계·프리셋을 바꿨는데 브랜드 표면(theme-color 메타·파비콘·og:image)이 옛 값을 그대로 쓰고 있는가?** 실측 사고: 프리셋 전환 11버전 뒤에 종이 시대 파비콘·theme-color 가 살아 있었다. 토큰 마이그레이션은 이 셋을 갱신하기 전까지 끝난 게 아니다. `theme-color` 는 실제 배경색과 같아야 한다(게이트가 비교한다)
14. **HTML·JS 에서 사라진 컴포넌트의 CSS 규칙·JS 함수가 남아 있는가?** 제거는 HTML·CSS·JS 삼위일체다. 게이트의 죽은 선택자 검사가 클래스 사용률을 대조한다
#### 카운트 규칙
@ -312,9 +322,9 @@ HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는
- 히어로: 헤드라인 ≤ 2줄, 서브텍스트 ≤ 20단어, 텍스트 요소 ≤ 4개
**국소 경로는 카운트를 페이지 전체로 다시 세지 않는다.** 내가 손댄 부분이 기존 카운트를 넘기게 만드는지만 본다.
**하드 게이트 12개는 경로와 무관하게 전부 돈다.**
**하드 게이트 14개는 경로와 무관하게 전부 돈다.**
> 통과 조건: 하드 게이트 12개 전부 "아니오", 카운트 규칙 통과, `preflight.md` 체크리스트 통과. 실패 항목이 있으면 4단계로 돌아간다.
> 통과 조건: 하드 게이트 14개 전부 "아니오", 카운트 규칙 통과, `preflight.md` 체크리스트 통과. 실패 항목이 있으면 4단계로 돌아간다.
---
@ -352,6 +362,7 @@ HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는
| `references/three.md` | 4-3 — 입체가 필요할 때 |
| `references/motion.md` | 4-4 — 움직임을 설계할 때 |
| `references/experimental-canvas.md` | 4단계 — HTML-in-Canvas를 검토할 때 |
| `references/mobile-app-ux.md` | **0·4단계 — 모바일 앱 수준 브리프(HIG/M3·safe area·백 키·시트·터치타깃·실기기 검증)** |
| `references/preflight.md` | 5단계 — 감사 |
| `references/antipatterns.md` | 3·5단계 — 한글 조판 / 슬롭 검출 |
| `references/audit-gate.md` | **5단계 — 게이트(폐쇄 루프) 설계·설치·하니스 규칙** |

View file

@ -23,19 +23,26 @@
| 계산 로직 오류(행 배열을 값으로 셈 → 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 관습은 조사 없이 바꾸지 않는다** |
## 6계층
## 7계층
`tools/design-gate.mjs` 가 한 파일로 돌리는 것과 프로젝트가 보강하는 것이 있다.
| 계층 | 도구 | 비고 |
|---|---|---|
| L0 정적 | stylelint + html-validate | 문법·구문 위반을 브라우저 켜기 전에 |
| L1 단위 | 순수 계산 로직 추출(calc.js) + 경계값 테스트 | 프로젝트별 작성 — 0명/만석/초과/빈배열 |
| L2 불변식 | `design-gate.mjs` — 오버플로·h1·수축·대비·SEO/meta | 전 뷰 × 전 폭 |
| 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 실패가 신규 기능에 정확히 집중 — 시나리오를 먼저 쓰면 미구현이 수치로 드러난다 |
## 설치 (프로젝트에 게이트 심기)
@ -69,6 +76,13 @@ L1·L5 는 프로젝트 안에 `tools/unit/*.test.mjs`, `tools/exploratory.mjs`
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 <emulator>` 로 대상을 한정한다.
## 리포트 양식

View file

@ -0,0 +1,144 @@
# mobile-app-ux — 모바일 앱 수준 UX 표준
데스크톱 웹앱을 폰에서 "앱처럼" 쓸 수 있게 만들 때 읽는다. 브리프에 "모바일 앱 수준", "iOS/Android 둘 다", "PWA" 같은 말이 있으면 **구현 전에 이 문서를 먼저 읽는다.** 기준은 취향이 아니라 플랫폼 가이드라인(HIG·M3)과 실측 사고다.
## UX 변경 전 조사 규칙
**UX 패턴을 바꾸기 전에 반드시 근거를 찾는다.** "내 생각엔 이게 낫겠다"로 내비 구조·뒤로가기·시트 같은 OS 레벨 관습을 바꾸면 사용자는 앱을 익히는 순간 배움이 무효가 된다. 순서:
1. 공식 가이드(HIG·M3·NN/g)에서 해당 패턴의 규범을 찾는다
2. 규범이 없으면 실제 앱 2~3개의 동작을 확인한다
3. 그래도 없으면 브리프에 물어서 정한다 — **기본값으로 OS 관습을 어기지 않는다**
실측 사고: 데스크톱 중앙 모달을 그대로 폰에 두면 하단 콘텐츠가 손닿지 않는 영역에 있고, 뷰 전환마다 히스토리가 쌓이지 않아 하드웨어 뒤로가기가 앱을 **통째로 종료**했다. 둘 다 "웹에서는 그랬다"는 관습을 모바일에 그대로 옮긴 실패다.
## 5축 요약 — iOS(HIG) vs Android(M3) vs 웹 구현
| 축 | iOS (HIG) | Android (M3) | 웹 구현 |
|---|---|---|---|
| 하단 내비 목적지 | 탭바 **5~6 이하** | 바텀내비 **3~5** | 초과분은 "더보기" 시트로 |
| 터치 타깃 | 44×44pt | 48×48dp | coarse 포인터에서 44px 최소, 인라인 링크는 24px |
| safe area | 노치·홈 인디케이터 회피 | 제스처 바 회피 | `viewport-fit=cover` + `env(safe-area-inset-*)` |
| 입력 확대 | 포커스 시 16px 미만이면 확대 | — | `@media (max-width:680px)` 입력 16px |
| 뒤로가기 | 스와이프 백 = 히스토리 | **하드웨어 백 = 히스토리** | `pushState`/`popstate` (아래 패턴) |
두 OS가 같은 것: 하단 시트는 그래버가 있고 썸존(화면 하단 35%)에서 열린다. 제스처 내비 예측 영역(가장자리)에 컨트롤을 두지 않는다.
## 구현 패턴
### safe area
```html
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
```
```css
:root { --safe-top: env(safe-area-inset-top, 0px); /* 하·좌·우 동일 */ }
.bottom-nav { padding-bottom: max(8px, var(--safe-bottom)); }
.appbar { padding-top: max(0px, var(--safe-top)); }
```
`env()` 는 `viewport-fit=cover` 없이는 0으로 굳는다. 패딩에 더할 때는 `max(기존값, safe)` — safe가 0인 기기에서 기존 리듬이 살아야 한다. **주의: 토큰 블록(`:root {}`) 안에 넣어야 한다.** 블록 밖에 부착하는 실수가 있었다(무시되고 조용히 죽는다).
### iOS 입력 확대 방지
포커스된 입력이 16px 미만이면 iOS 사파리가 자동 확대한다. 확대는 컨트롤러가 아니라 뷰포트를 밀어 레이아웃을 흔든다.
```css
@media (max-width: 680px) {
input, select, textarea, #q, .note-form input { font-size: 16px; }
}
```
**`maximum-scale=1` 로 잡지 마라.** 확대를 막는 건 WCAG 1.4.4 위반이다. 폰트 크기로만 잡는다. ID 선택자가 폰트를 `font: inherit`으로 정의한 경우 같은 명시도를 파일 뒤에 붙여 이겨야 한다(실측: `#q`가 미디어쿼리를 이겨 확대가 살아있었다).
### 하단 내비 + 더보기 시트
목적지가 5개 이하면 바텀내비에 전부 노출한다. 넘으면: **4개 고정 + 더보기**로 나누고, 나머지는 바텀 시트에 그룹 헤더를 달아 노출한다.
- 항목: `min-height: 52px` 이상, 라벨+아이콘 또는 라벨만, 활성 표시는 색+`aria-current="page"`
- 더보기 소속 뷰가 떠 있을 때는 **더보기 탭이 활성**이어야 한다(사용자가 자기 위치를 안다)
- 시트: 그래버(36×4px), `max-height: 92dvh`, `border-radius`는 위쪽만, 등장은 `sheet-up` 모션(≤ 감사 도구 대기시간 — audit-gate 하니스 규칙 10)
- 시트 항목도 `min-height: 48px`
### 하드웨어 뒤로가기 = 앱 내 히스토리 (핵심 패턴)
안드로이드 백 키와 iOS 스와이프 백이 **앱을 종료하지 않고 앱 내 뒤로** 작동해야 한다. SPA 뷰 전환마다 `history.pushState` 로 스택을 쌓고 `popstate` 로 소비한다. 다이얼로그도 같은 스택에 태운다:
```js
const DIALOGS = ["stu-dialog", "as-dialog", "more-dialog"];
let suppressPop = false; // close 이벤트가 history.back() 유발 → popstate 무시
let popClosing = false; // popstate가 다이얼로그를 닫는 중 → close의 back() 무시
const openDialog = (dlg) => {
if (dlg.open) return;
history.pushState({ v: currentView(), d: dlg.id }, "");
dlg.showModal();
};
DIALOGS.forEach((id) => {
const dlg = document.getElementById(id);
dlg.addEventListener("close", () => {
if (popClosing) return;
if (history.state && history.state.d === dlg.id) { suppressPop = true; history.back(); }
});
});
let pendingMoreSelect = null; // 더보기 시트: 선택은 back() 후 push로 이어진다
window.addEventListener("popstate", () => {
if (suppressPop) { suppressPop = false; return; }
const s = history.state || {};
popClosing = true;
DIALOGS.forEach((id) => { const d = document.getElementById(id); if (d.open && s.d !== id) d.close(); });
popClosing = false;
if (s.v) setCurrentView(s.v);
if (pendingMoreSelect) { const v = pendingMoreSelect; pendingMoreSelect = null; gotoView(v, true); }
});
```
- 닫기(Esc·backdrop·close 버튼)는 `dlg.close()` 대신 `history.back()` 경로로 — 스택이 남지 않게
- 더보기 시트에서 항목 선택: `pendingMoreSelect = view; history.back();` → popstate가 시트를 닫고 뷰 전환까지 마무리
- 진입 시 `history.replaceState({ v: initialView }, "")` 로 스택 바닥을 명시한다
- **검증은 진짜 백 키로**: 에뮬레이터 `adb shell input keyevent 4` 후 화면 상태를 텍스트 근거로 단언 (아래 "실기기 검증")
### 터치·스크롤 위생
```css
html { -webkit-tap-highlight-color: transparent; }
body { overscroll-behavior-y: none; } /* 페이지 전체가 통째로 튕기는 것 방지 */
:is(button, a, input, select, textarea) { touch-action: manipulation; } /* 더블탭 줌 지연 제거 */
```
## PWA 최소 세트
"앱처럼"의 하한線: `manifest.webmanifest`(name·short_name·display: standalone·theme_color·아이콘 192/512) + `apple-touch-icon` + `mobile-web-app-capable` 메타 2종. 아이콘은 SVG에서 sharp로 PNG를 뽑는다(브랜드 문양 — 장부 문항, 시간표 블록 같은 **도메인 은유**). 홈 화면에 설치하면 아이콘이 곧 브랜드다 — 파비콘 재사용으로 땜빵하지 않는다.
## 실기기 검증 (안드로이드 에뮬레이터)
시뮬레이터가 없는 OS(Windows 등)에서 iOS는 WebKit 엔진(L4)으로 검증하고 문서로 남긴다. 안드로이드는 에뮬레이터에서 진짜 Chrome로:
1. `astro preview --host 127.0.0.1` — **바인딩 주의**: 기본 `localhost` 바인딩은 adb reverse가 닿지 않아 `ERR_EMPTY_RESPONSE`가 뜬다(실측 사고)
2. `adb reverse tcp:PORT tcp:PORT` 후 `am start -a android.intent.action.VIEW -d "http://127.0.0.1:PORT/…" com.android.chrome`
3. 조작은 `input tap x y`·`input keyevent 4`(백) — 좌표는 `uiautomator dump` 의 bounds 중심으로
4. **판정은 텍스트 근거로**: `uiautomator dump` 의 `text="…"` 에서 화면 제목·활성 탭을 읽는다. 비전(모델) 검수는 "백 직후 전환 중 프레임"을 잡는 등 타이밍 오탐이 있다 — 여부 판정은 계측, 비전은 레이아웃 평가에만(audit-gate 하니스 규칙 5)
5. Git Bash(Windows)에서 `/sdcard/...` 인자는 MSYS 경로 변환으로 깨진다 — `export MSYS_NO_PATHCONV=1`
6. 물리 폰이 adb에 붙어 있을 수 있다 — **항상 `-s emulator-XXXX` 로 대상을 한정한다**
실측 검증 세트(전부 텍스트 단언): 렌더(제목·콘텐츠) → 탭 전환(제목·활성 상태) → 백 키(이전 뷰 복귀) → 더보기 시트(그룹·항목 노출) → 시트 선택(대상 뷰) → 백 키(복귀).
## E2E 시나리오 (L6) 와의 연결
이 문서의 패턴들은 시나리오로 검증한다 — `audit-gate.md` 의 L6 계층 참조. 모바일 UX 검증 시나리오 최소 세트:
- **백 키/스와이프 백**: 뷰 전환 N번 → 백 N번이 정확히 역순으로 복귀 (양방향)
- **시트 수명주기**: 열림 → 선택 → 대상 뷰 → 백 → 시트를 연 뷰
- **터치타깃 스윕**: 전 상호작용 요소 44px(coarse) — 요소별·인라인 별도 기준
- **매트릭스**: 320/390/768 × 앱 전 뷰 — 오버플로·h1·브랜드
## 출처
- Apple Human Interface Guidelines — Tab bars, Sheets, Layout & safe areas (developer.apple.com/design)
- Google Material 3 — Bottom app bar / Navigation bar, 48dp touch targets (m3.material.io)
- Nielsen Norman Group — Bottom Sheets UX, Touchscreen Touch Targets (nngroup.com/articles)
- MDN — `env(safe-area-inset-*)`, `viewport-fit`, `overscroll-behavior`, `touch-action`
- WebAIM/WCAG 1.4.4 — 확대 금지(`maximum-scale=1`)가 접근성 위반인 이유

View file

@ -737,6 +737,7 @@ const progress = clamp01((start - rect.top) / travel);
- [ ] `addEventListener('scroll'`이 소스에 없다 (**#10**)
- [ ] duration·이징이 전부 `var(--dur-*)`·`var(--ease-*)`다. 인라인 ms 값이 없다
- [ ] 이펙트를 전부 끈 상태에서 페이지가 완성돼 있다
- [ ] **등장 모션이 감사 도구의 대기시간보다 짧다** — 시각 회귀가 뷰 전환 뒤 N ms 에 스크린샷을 찍는다면 등장 애니메이션은 그보다 짧게(관례: ≤70ms). 아니면 회귀가 매번 다른 프레임을 찍어 흔들린다. 자세한 규칙은 `audit-gate.md` 하니스 10
**동작**
- [ ] scroll-driven을 쓴 곳에 `@supports` 가드가 있고 **초기 상태가 그 블록 안에** 있다. Firefox에서 백지가 되지 않는다

View file

@ -37,6 +37,11 @@ canvas { display: none !important; }
### C. 카피 경쟁사 치환
제품명을 경쟁사 이름으로 바꿔 읽는다. **문장이 그대로 성립하면 그 카피는 아무것도 말하지 않았다.** 히어로 문구부터 검사한다.
### D. 죽은 코드·토큰 위생 (design-gate 내장 검사)
- CSS 클래스가 HTML·JS 어디에도 등장하지 않으면 죽은 것이다. 컴포넌트를 없앨 때 HTML·CSS·JS 를 **삼위일체로** 지웠는지 대조한다(동적 클래스 `is-${type}`·`seg-${n}` 처럼 조립되는 접두사는 게이트 설정의 `deadCssIgnore` 로 제외한다)
- 토큰 파일이 있는데 다른 CSS 파일에 색 리터럴(hex·rgb)이 남아 있으면 실패다. 반지름 리터럴도 마찬가지 — 눈 검사로는 절대 전부 못 잡는다(실측: 8건의 상수색이 눈 검사를 통과했다)
- `theme-color` 메타가 실제 배경색과 다르면 **낡은 토큰**이다. 프리셋 전환에서 브랜드 표면(theme-color·파비콘·og:image)이 누락됐다는 뜻이다
---
## 1. 접근성 — 타협 없음
@ -118,6 +123,7 @@ canvas { display: none !important; }
## 3. 반응형
- [ ] **320px** 폭에서 가로 스크롤 없음 (하드 게이트 #5 는 320~1920px 전 구간을 요구한다. 375px 만 보면 그 아래가 뚫린다)
- [ ] **크롬(탑바·앱바·툴바)에 컨트롤을 더했다면 그 줄의 최소폭 합을 가장 좁은 폭에서 다시 잰다** — 버튼 하나가 320px를 뚫는 사고가 반복된다(실측: 인쇄 버튼 1개로 +18px). 줄이 넘으면 감싸기(2행 랩)가 기본 수습이다
- [ ] 1920px 이상에서 콘텐츠가 늘어지지 않음(최대 폭 제한)
- [ ] 중간 뷰포트(768~1024px)에서 레이아웃이 깨지지 않음 — **가장 자주 빠뜨리는 구간**
- [ ] 터치 타깃: **버튼·아이콘·카드 등 독립 컨트롤은 44×44px 이상**(Apple/Google 권고)
@ -139,6 +145,18 @@ canvas { display: none !important; }
---
## 4-1. 상태 완결성 — 동작하는 화면의 빈틈
정적 마케팅 페이지에는 없는 검사다. **입력·파괴·열림**이 있는 화면(폼·콘솔·앱)은 네 가지를 갖춰야 프로덕션이다. 실제로 조작해 본다 — 눈으로 봐서는 안 보인다(실측: 범위 밖 101점이 조용히 평균에 반영되고 있었다).
- [ ] **수치 입력에는 범위가 있다** — 범위 밖 값은 인라인 오류로 이유를 말하고 제출을 막는다(`aria-invalid`). 스트랩 검증만 믿지 마라: `type="number"` 의 `min`/`max` 는 직접 입력을 막지 못한다
- [ ] **판정은 순수 함수다** — 가능/불가+사유의 판정 로직은 UI 핸들러에 두지 않고 `calc.js` 같은 모듈로 추출해 단위 테스트(L1)가 같은 판정을 공유하게 한다. UI 는 판정 결과를 문장으로 번역만 한다. 실행취소의 재심사도 같은 함수를 쓴다
- [ ] **파괴적 행동에는 되돌림이 있다** — 삭제·취소는 실행취소 또는 확인. 되돌리기가 확인보다 낫다(흐름이 끊기지 않는다). 재신청·복원은 원래 판정 함수로 다시 심사한다 — 사이에 끼어든 다른 변화가 있으면 정당하게 막혀야 한다
- [ ] **열리는 것은 Esc 로 닫힌다** — 모달만이 아니다. 드롭다운·팝오버·알림 메뉴도. 닫힐 때 포커스는 연 요소로 돌아간다
- [ ] **로딩 상태는 실제 비동기에만** — 동기 인라인 데이터에 스켈레톤을 붙이는 건 저장을 흉내내는 것이다. 없는 지연을 만들지 마라
---
## 5. 디자인 자체
기계로 못 잡는 부분이다. **정직하게 답해라.**
@ -156,11 +174,12 @@ canvas { display: none !important; }
전부 통과해야 끝난다.
- [ ] 0장 기계 검사 3종 통과
- [ ] 0장 기계 검사 4종 통과
- [ ] 접근성 표 전 항목 통과
- [ ] 성능 예산 내 (또는 초과 이유 명시)
- [ ] 반응형 체크리스트 통과
- [ ] 콘텐츠 체크리스트 통과
- [ ] 상태 완결성(§4-1) — 입력 검증·되돌림·Esc 를 실제 조작으로 확인
- [ ] 5장 여섯 질문에 정직하게 답했고, 실패 항목을 고쳤다
- [ ] `antipatterns.md` 자가 채점표 통과

View file

@ -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 설치 시) ──