- 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.
307 lines
22 KiB
Markdown
307 lines
22 KiB
Markdown
# 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]
|