designpaca/packages/skill/references/mobile-app-ux.md
Yun Chan 6805fb2be7 feat(skill): absorb external design skills, restore interview gate, add review route
- Restore the step-0 interview as a mechanical gate the skill explicitly
  depends on; add harness.md (per-harness question tools, limits,
  fallbacks) and brief-interview.md (slots, question cards, rounds).
- Add 10 reference docs absorbed from external design skills
  (accessibility, interaction-feel, elevation, color, icons, product-copy,
  component-systems, critique, change-review, print-email) and extend
  existing references.
- Add a review-only route and two hard-gate clauses (truncated content
  reachability, three-flashes limit).
- design-gate: split tap targets into WCAG 2.5.8 and 44px contract layers,
  run axe-core when available, and fix false positives found on a real
  site (decorative alt="", stacked wordmark line count, url-only pages).
- lint-skill: fail if the interview gate section or its links disappear.
- Ship agents/openai.yaml and THIRD_PARTY_NOTICES.md.
2026-09-24 13:26:03 +09:00

307 lines
22 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[PLATFORM-HIG-TARGET] | 48×48dp[PLATFORM-M3-TARGET] | coarse 포인터에서 44px 최소, 인라인 링크는 24px |
| safe area | 노치·홈 인디케이터 회피 | 제스처 바 회피 | `viewport-fit=cover` + `env(safe-area-inset-*)` |
| 입력 확대 | 포커스 시 16px 미만이면 확대 | — | `@media (max-width:680px)` 입력 16px |
| 뒤로가기 | 스와이프 백 = 히스토리 | **하드웨어 백 = 히스토리** | `pushState`/`popstate` (아래 패턴) |
두 OS가 같은 것: 하단 시트는 그래버가 있고 썸존(화면 하단 35%)에서 열린다. 제스처 내비 예측 영역(가장자리)에 컨트롤을 두지 않는다.
### 엄지 우선 배치의 일반화
**프로젝트 계약**: 위 썸존(하단 시트가 열리는 위치)은 한 사례일 뿐이다. 일반 원칙으로 넓히면, 모바일 화면의 1차 조작(주요 CTA·자주 쓰는 토글·플로팅 버튼)은 화면 하단 1/3을 엄지의 자연스러운 도달권으로 보고 먼저 검토한다. 상단이 엄지보다 멀다는 사실 자체가 항상 이긴다는 뜻은 아니다 — 스캔 순서·기존 플랫폼 관습이 상단 배치를 요구할 수 있다. 상단에 둘 때는 그 근거를 design.md에 기록한다[SKILL-IMPECCABLE].
썸존 35%와 하단 1/3은 다른 두 값이다 — 바텀시트는 하단 약 35%에서 열리고, 1차 조작의 일반 도달권은 하단 1/3로 본다.
### 플랫폼 관용구 대응 (iOS ↔ Android)
웹은 두 플랫폼 중 하나를 흉내 내지 않는다. 다만 "iOS 느낌"·"Android 느낌"을 브리프가 언급하면 아래 대응표로 관용구를 옮겨라 — 한쪽 이름을 다른 쪽에 그대로 이식(transplant)하지 않는다[SKILL-IMPECCABLE].
| iOS | Android |
|---|---|
| 탭바(Tab bar) | 내비게이션 바 / 레일 / 드로어 |
| 가장자리 스와이프 백, 뒤로가기 셰브런 | Predictive Back 제스처 / 버튼 |
| 스위치, 세그먼트 컨트롤, 시스템 피커 | Material 스위치, 칩, Material 피커 |
| 액션 시트 | 바텀 시트 / Material 다이얼로그 |
| SF Symbols, SF Pro, Dynamic Type | Material Symbols, Roboto, sp 스케일링 |
| 시맨틱 시스템 색·머티리얼 | Material 색 역할, tonal elevation |
| 시스템 push/sheet 전환 | container transform, shared-axis, fade-through |
웹 구현은 두 관용구 중 하나를 그대로 베끼지 않고, 이 문서의 5축 요약(터치 타깃·safe area·뒤로가기)처럼 웹 네이티브 수단으로 옮긴 값을 쓴다.
## 구현 패턴
### 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 {}`) 안에 넣어야 한다.** 블록 밖에 부착하는 실수가 있었다(무시되고 조용히 죽는다).
### 콘텐츠는 bleed, 컨트롤은 float — 2층 모델
**프로젝트 계약**: 모바일 앱 수준 화면은 콘텐츠 레이어와 컨트롤 레이어를 다른 규칙으로 다룬다. 배경·히어로 미디어·스크롤 가능한 목록 같은 콘텐츠 레이어는 뷰포트 가장자리까지 채워도 된다(bleed). 텍스트와 상호작용 컨트롤은 레이아웃 마진과 safe area 안에 머물며 콘텐츠 위에 떠 있는다(float) — 콘텐츠 레이어가 뷰포트를 채운다고 컨트롤까지 가장자리에 붙이지 않는다[SKILL-BETTER-LAYOUT].
```css
.article {
display: grid;
grid-template-columns: 1fr min(65ch, calc(100% - 48px)) 1fr;
}
.article > * { grid-column: 2; }
.article > .full-bleed { grid-column: 1 / -1; } /* 콘텐츠만 bleed */
.fab {
position: fixed;
inset-inline-end: calc(16px + env(safe-area-inset-right, 0px));
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
}
/* safe-area env() 값은 물리 방향이다. RTL 에서 inline-end 는 왼쪽이므로 왼쪽 inset 을 더한다 */
.fab:dir(rtl) { inset-inline-end: calc(16px + env(safe-area-inset-left, 0px)); }
```
FAB(플로팅 액션 버튼)는 safe area와 논리 속성(`inset-inline-end`)을 함께 쓴다 — 물리적 `right`만 쓰면 RTL 로케일에서 반대편에 붙는다.
### 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`가 미디어쿼리를 이겨 확대가 살아있었다).
**두 해법 중 하나를 고른다. 둘 다 정답이고 무엇을 디자인이 원하는지가 다르다**[SKILL-BETTER-TYPE].
1. **모바일에서 키운다.** 위 코드처럼 입력을 16px로 렌더하고, 넓은 화면부터 디자인 크기로 되돌린다. 보정이 필요 없지만 모바일 입력이 데스크톱과 시각적으로 달라진다.
2. **16px를 유지하고 시각적으로 축소한다.** `font-size`는 항상 16px로 둬 Safari가 확대하지 않게 하고, `transform: scale()`로 의도한 크기로 보여준다. 모든 뷰포트에서 디자인이 그대로지만 보정 계산이 늘어난다 — 축소 비율의 역수로 폭을 넓힌다. `transform-origin: left`(RTL은 `right`)로 텍스트를 시작 가장자리에 고정한다.
```css
/* 16px 폰트를 13px로 보여주는 예: 13 / 16 = 0.8125 */
.input-shell { display: flex; align-items: center; border-radius: 10px; padding-inline: 10px; }
.input-shell input {
width: calc(100% / 0.8125);
transform: scale(0.8125);
transform-origin: left;
font-size: 16px;
line-height: 1.125;
background: transparent;
border: none;
outline: none;
}
@media (min-width: 640px) {
.input-shell input { width: 100%; transform: none; font-size: 13px; }
}
```
unitless `line-height`(위 예의 `1.125`처럼 배수로 쓴 값)는 `scale()`과 함께 줄어들어도 폰트 크기와의 비율이 유지되므로 나눗셈 보정이 필요 없다. px 같은 절대 길이 행간을 쓸 때만 나눗셈 보정이 필요하다.
트랜스폼은 글자만이 아니라 입력 박스 전체를 줄인다. 배경·테두리·포커스 링은 감싸는 `.input-shell`에 그리고 `input` 자체는 투명하게 둬라 — 축소된 요소에 테두리를 직접 걸면 의도한 히트 영역을 놓친다. 어느 쪽을 쓸지 브리프가 정하지 않았으면 0단계에서 묻는다.
### 하단 내비 + 더보기 시트
목적지가 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; } /* 더블탭 줌 지연 제거 */
```
자체 드래그·줌 표면(캔버스·커스텀 슬라이더)은 `touch-action: none`을 그 요소에만 스코프해 건다 — 전역에 걸면 페이지 스크롤까지 막힌다. 호버 전용 효과(카드 확대 등)는 `@media (hover: hover) and (pointer: fine)`로 게이트한다. 터치 기기는 탭이 호버를 일으켜 오탐을 만든다.
## 촉감 피드백(햅틱)
언제: PWA·모바일 웹 앱 브리프가 네이티브 앱 감각을 요구할 때만 연다. 랜딩 페이지·포트폴리오·데스크톱 우선 프로젝트에는 적용하지 않는다.
**프로젝트 계약**: Vibration API(`navigator.vibrate()`)는 webstatus.dev 기준 Chromium 계열(Chrome·Edge, Android 포함)만 구현했고 Safari(iOS 포함) 구현 기록은 없다[WEB-BASELINE]. 그래서 햅틱은 "있으면 더 좋은" 보강 신호로만 쓰고, 성공·오류·완료를 햅틱 단독으로 전달하지 않는다 — 시각 신호(토스트·상태 변화, [interaction-feel.md](interaction-feel.md) 참고)를 항상 함께 둔다.
멀티모달 피드백은 세 원칙을 따른다: **Causality(인과성)** — 실제 인과 사건(토글이 뒤집히는 순간, 아이템이 제자리에 스냅되는 순간)에서만 트리거한다. **Harmony(조화)** — 비주얼·사운드·햅틱이 같은 프레임에서 발화해야 한다. CSS 트랜지션이 `navigator.vibrate()` 호출을 지연시키지 않게 한다. **Utility(유용성)** — 의미 있는 순간(성공·오류·커밋)에만 아껴 쓴다. 과잉 피드백은 사용자가 전부 무시하도록 학습시킨다[SKILL-APPLE-DESIGN].
```js
function hapticTap(pattern = 10) {
if (!('vibrate' in navigator)) return; // iOS Safari 등 미지원 환경은 조용히 무시
navigator.vibrate(pattern);
}
```
## 제스처 대체 조작 — 하드 게이트
### 스와이프 삭제·재정렬의 대체 조작
**하드 게이트**: 드래그로만 되는 기능은 단일 포인터의 드래그 없는 방법으로도 가능해야 한다 — 브라우저가 정하는 스크롤 등은 예외다(WCAG 2.5.7)[WCAG-DRAG]. 카드 스와이프 삭제·리스트 드래그 재정렬을 만들 때는 같은 결과를 내는 버튼(항목 메뉴의 "삭제"·"위로 이동" 등)을 항상 함께 둔다.
```js
function bindSwipeDismiss(card, onDismiss) {
let startX = 0, dx = 0, dragging = false;
card.addEventListener('pointerdown', (e) => {
dragging = true; startX = e.clientX; card.setPointerCapture(e.pointerId);
});
card.addEventListener('pointermove', (e) => {
if (!dragging) return;
dx = e.clientX - startX;
card.style.transform = `translateX(${dx}px)`;
});
card.addEventListener('pointerup', () => {
dragging = false;
const threshold = card.offsetWidth * 0.28; // 시작값: 카드 폭의 25~30% — 실측 조정
if (Math.abs(dx) > threshold) onDismiss();
else card.style.transform = '';
dx = 0;
});
}
```
**관찰 후보**: 임계값은 카드 폭의 25~30% 또는 100px을 시작값으로 두고, 프로젝트에서 실측해 조정한다[SKILL-INTERACTION-DESIGN].
### 당겨서 새로고침 — 조건부
언제: 네이티브 앱 감각이 브리프 요구일 때만 연다.
**하드 게이트**: 위 스와이프 항목과 같은 이유(WCAG 2.5.7)로, 당겨서 새로고침도 명시적 새로고침 버튼을 항상 병행한다 — 제스처 하나에만 기능을 의존시키지 않는다.
```js
const PULL_THRESHOLD = 60; // 시작값 60px — 실측 조정
let startY = 0, pullY = 0, pulling = false;
document.addEventListener('touchstart', (e) => {
if (window.scrollY === 0) { startY = e.touches[0].clientY; pullY = 0; pulling = true; }
}, { passive: true });
document.addEventListener('touchmove', (e) => {
if (!pulling) return;
pullY = Math.max(0, e.touches[0].clientY - startY);
indicator.style.opacity = String(Math.min(pullY / PULL_THRESHOLD, 1));
}, { passive: true });
document.addEventListener('touchend', () => {
if (pulling && pullY > PULL_THRESHOLD) triggerRefresh();
pulling = false; pullY = 0; indicator.style.opacity = '0';
});
```
임계값 60px는 근거가 약한 시작값이다[SKILL-INTERACTION-DESIGN] — 프로젝트에서 실측해 조정한다.
### 드래그형 커스텀 컨트롤의 QA
슬라이더·드래그 표면·스크롤형 컨트롤 스트립은 레이아웃 검증(너비·배치)과 제스처 검증이 별개다 — 레이아웃이 통과해도 제스처는 조용히 실패할 수 있다. 최소한 다음을 확인한다.
1. 탭 응답뿐 아니라 대상 입력방식으로 드래그를 끝까지 완주해 본다(시작만이 아니라).
2. 컨트롤을 가로지르는 스와이프(페이지·컨테이너가 스크롤돼야 한다)와 컨트롤 축을 따르는 드래그(컨트롤이 움직여야 한다)를 둘 다 시험한다.
3. 증거 출처(에뮬레이션·합성 터치·실기기, 엔진명 Chromium/Safari)를 보고에 명시한다. 접근 불가한 하드웨어는 "보고된 갭"으로 정직하게 남긴다 — 차단 사유로 쓰지 않는다.
제스처 인식 알고리즘(히스테리시스·포인터 캡처·그랩 오프셋)과 속도 기반 dismiss 판정은 [interaction-feel.md](interaction-feel.md)를 참조한다.
## 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`)가 접근성 위반인 이유
- WCAG 2.2 SC 2.5.7 Dragging Movements — 드래그 전용 기능은 단일 포인터의 비드래그 대안이 있어야 한다[WCAG-DRAG]
- webstatus.dev — Vibration API 지원 범위(Chromium 계열만 구현, Safari(iOS 포함) 구현 기록 없음), 확인 2026-09-24[WEB-BASELINE]