designpaca/packages/skill/references/mobile-app-ux.md
Yun Chan 2b7fa32b5b
All checks were successful
ci / build (push) Successful in 51s
release / release (push) Successful in 2m34s
feat: 한국어 우선 쇼케이스 정리
2026-08-26 18:14:41 +09:00

152 lines
11 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`
### 뷰 전환은 스크롤을 리셋하고, 헤더는 하나다
**탭을 바꾸면 새 화면은 맨 위에서 시작한다.** 뷰 전환 함수 마지막에 `window.scrollTo(0, 0)` 을 넣어라 — `focus({ preventScroll: true })` 만으로는 부족하다. 실측 사고: 이전 뷰의 스크롤이 남은 채 탭을 바꾸면, 새 뷰가 짧을 때 브라우저가 스크롤을 클램프해 헤더가 **반쯤 잘린 채** 뜨고, 길면 화면 중간에 떨어진다. 뷰마다 크롬 위치가 달라져 "상단 바가 왔다갔다한다"는 보고로 나타났다.
**모바일 헤더는 브랜드+액션 한 줄이다.** 데스크톱의 브랜드 바(사이드바 상단)와 화면 크럼 바를 좁은 화면에 그대로 쌓으면 90px+ 크롬이 콘텐츠를 옥에 끼운다. 하나를 고른다 — **브랜드(마크+이름)와 액션을 헤더에 남기고, 크럼(화면 이름)은 숨긴다.** 화면 이름은 각 뷰의 헤드와 활성 탭이 이미 말한다. 실측 사고: 크럼을 헤더로 내보냈더니 "작은 회색 텍스트가 52px 바에 떠 있는" 빈약한 헤더가 돼 **사용자에게 '깨져 보인다'는 보고**를 받았다 — 구조는 맞아도 시각적 무게가 없으면 깨진 것으로 읽힌다. 고른 헤더는 `position: sticky; top: 0` + safe-top 패딩으로 스크롤 내내 상단에 남는다. 검증은 E2E 로: 300px+ 스크롤 상태에서 탭 전환 → `scrollY === 0`·헤더 `top === 0`, 그리고 **눈으로** — 계측이 전부 통과해도 보기에 빈약하면 실패다.
**왼쪽 인셋은 하나의 축이어야 한다.** 헤더 텍스트·화면 제목·카드가 각기 다른 x에서 시작하면 리듬이 무너지고, 큰 제목이 8px만 왼쪽에 나가도 사용자는 "가장자리에 붙어서 깨졌다"고 읽는다(실측 사고: 제목 16px vs 앱바·카드 24px 혼재). 컨테이너 인셋을 토큰 하나로 통일하라. **오버플로 검사는 오른쪽만 본다** — 왼쪽 인셋의 최소값(≥12px)과 정렬 정합(헤더-제목-카드 ±10px)은 별도 검사로: 텍스트 시작점 기준으로 잰다(전폭 컨테이너의 보더박스가 아니라). `display:none` 요소의 rect 는 0이므로 보임 필터를 먼저 통과시킨다.
### 하드웨어 뒤로가기 = 앱 내 히스토리 (핵심 패턴)
안드로이드 백 키와 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`)가 접근성 위반인 이유