designpaca/packages/skill/references/mobile-app-ux.md
Yun Chan 14cb138e1a 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(양앱) 범프 후 전체 재검증
2026-08-23 14:33:15 +09:00

144 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`)가 접근성 위반인 이유