feat(skill): route fixes, delivery report, composition diversity, platform refresh

- route table: extension route for products with an existing visual
  language, feedback row, derived state tokens stay local
- completion: blocking 0 plus reported unverified; hard-gate surface is
  the touched scope; delivery report starts with what and how to open
- design.md created at step 0 with a skeleton, fingerprint line and
  change log; product-surface rubric reading; direction delegation
  states the chosen card and reason
- layout: hero families, nav forms, footer forms; art-direction page
  convergence 6 axes; genre sections; image treatments; placeholder
  conventions; multi-screen order; Tailwind theme mapping
- platform: scroll-driven examples, anchor positioning, canvas OT,
  three/R3F pins, shadcn style prefixes, Safari 26 notes, subgrid
- design-gate: four result states, exit codes, cwd module resolution,
  --url/--out-dir/--strict, oklch contrast, dead selector word match
- Korean font budget split from the Latin baseline
This commit is contained in:
Yun Chan 2026-10-06 16:55:38 +09:00
parent 9bcb45cbd3
commit 88c11682b1
51 changed files with 5834 additions and 2259 deletions

View file

@ -2,6 +2,21 @@
데스크톱 웹앱을 폰에서 "앱처럼" 쓸 수 있게 만들 때 읽는다. 브리프에 "모바일 앱 수준", "iOS/Android 둘 다", "PWA" 같은 말이 있으면 **구현 전에 이 문서를 먼저 읽는다.** 기준은 취향이 아니라 플랫폼 가이드라인(HIG·M3)과 실측 사고다.
## 이 문서를 읽는 법
| 상황 | 읽을 곳 |
|---|---|
| OS 관습을 바꾸고 싶다 | "UX 변경 전 조사 규칙" 먼저 |
| iOS·Android·웹의 기본값 비교(내비 개수·터치 타깃·safe area·뒤로가기) | "5축 요약" |
| 노치·홈 인디케이터·입력 확대 | "구현 패턴"의 safe area·iOS 입력 확대 방지 |
| 하단 내비가 5개를 넘는다 | "하단 내비 + 더보기 시트" |
| 안드로이드 백 키가 앱을 닫는다 | "하드웨어 뒤로가기" |
| 스크롤 튕김·더블탭 지연·호버 오탐 | "터치·스크롤 위생" |
| 스와이프·당겨서 새로고침 | "제스처 대체 조작"(하드 게이트) |
| 가상 키보드·`svh/dvh`·사용자 글꼴 크기 | "모바일 웹 세부" |
| 홈 화면 설치(iOS 26+는 PWA 브리프가 아니어도 해당) | "PWA 최소 세트" |
| 실기기로 확인한다 | "실기기 검증" |
## UX 변경 전 조사 규칙
**UX 패턴을 바꾸기 전에 반드시 근거를 찾는다.** "내 생각엔 이게 낫겠다"로 내비 구조·뒤로가기·시트 같은 OS 레벨 관습을 바꾸면 사용자는 앱을 익히는 순간 배움이 무효가 된다. 순서:
@ -16,19 +31,18 @@
| 축 | iOS (HIG) | Android (M3) | 웹 구현 |
|---|---|---|---|
| 하단 내비 목적지 | 탭바 **5~6 이하** | 바텀내비 **3~5** | 초과분은 "더보기" 시트로 |
| 터치 타깃 | 44×44pt[PLATFORM-HIG-TARGET] | 48×48dp[PLATFORM-M3-TARGET] | coarse 포인터에서 44px 최소, 인라인 링크는 24px |
| 하단 내비 목적지 | 탭바 **3~5**(iPhone. 넘으면 4개 + More 탭) | 바텀내비 **3~5** | 초과분은 "더보기" 시트로 |
| 터치 타깃 | 44×44pt[PLATFORM-HIG-TARGET] | 48×48dp[PLATFORM-M3-TARGET] | coarse 포인터에서 44px 최소(모바일 앱 수준 계약), 인라인 링크는 24px. 두 층 판정은 [accessibility.md](accessibility.md) §8 |
| safe area | 노치·홈 인디케이터 회피 | 제스처 바 회피 | `viewport-fit=cover` + `env(safe-area-inset-*)` |
| 입력 확대 | 포커스 시 16px 미만이면 확대 | — | `@media (max-width:680px)` 입력 16px |
| 뒤로가기 | 스와이프 백 = 히스토리 | **하드웨어 백 = 히스토리** | `pushState`/`popstate` (아래 패턴) |
두 OS가 같은 것: 하단 시트는 그래버가 있고 썸존(화면 하단 35%)에서 열린다. 제스처 내비 예측 영역(가장자리)에 컨트롤을 두지 않는다.
두 OS가 같은 것: 하단 시트는 그래버가 있고 화면 아래에서 올라온다. 제스처 내비 예측 영역(가장자리)에 컨트롤을 두지 않는다.
### 엄지 우선 배치의 일반화
**프로젝트 계약**: 위 썸존(하단 시트가 열리는 위치)은 한 사례일 뿐이다. 일반 원칙으로 넓히면, 모바일 화면의 1차 조작(주요 CTA·자주 쓰는 토글·플로팅 버튼)은 화면 하단 1/3을 엄지의 자연스러운 도달권으로 보고 먼저 검토한다. 상단이 엄지보다 멀다는 사실 자체가 항상 이긴다는 뜻은 아니다 — 스캔 순서·기존 플랫폼 관습이 상단 배치를 요구할 수 있다. 상단에 둘 때는 그 근거를 design.md에 기록한다[SKILL-IMPECCABLE].
**프로젝트 계약**: 하단 시트는 엄지 우선 배치의 한 사례일 뿐이다. 일반 원칙으로 넓히면, 모바일 화면의 1차 조작(주요 CTA·자주 쓰는 토글·플로팅 버튼)은 화면 중앙~하단 중앙을 엄지의 자연스러운 도달권으로 보고 먼저 검토한다. 상단 모서리와 큰 폰의 하단 극단 모서리는 닿기 어렵다(Hoober의 휴대 자세 관찰). 도달권을 고정 비율로 단정하지 않고 대상 기기 크기에서 확인한다. 상단이 엄지보다 멀다는 사실 자체가 항상 이긴다는 뜻은 아니다 — 스캔 순서·기존 플랫폼 관습이 상단 배치를 요구할 수 있다. 상단에 둘 때는 그 근거를 design.md에 기록한다[SKILL-IMPECCABLE].
썸존 35%와 하단 1/3은 다른 두 값이다 — 바텀시트는 하단 약 35%에서 열리고, 1차 조작의 일반 도달권은 하단 1/3로 본다.
### 플랫폼 관용구 대응 (iOS ↔ Android)
@ -69,7 +83,7 @@
```css
.article {
display: grid;
grid-template-columns: 1fr min(65ch, calc(100% - 48px)) 1fr;
grid-template-columns: 1fr min(var(--measure), calc(100% - 48px)) 1fr; /* 한글 measure는 ch로 적지 않는다 */
}
.article > * { grid-column: 2; }
.article > .full-bleed { grid-column: 1 / -1; } /* 콘텐츠만 bleed */
@ -130,7 +144,7 @@ unitless `line-height`(위 예의 `1.125`처럼 배수로 쓴 값)는 `scale()`
- 항목: `min-height: 52px` 이상, 라벨+아이콘 또는 라벨만, 활성 표시는 색+`aria-current="page"`
- 더보기 소속 뷰가 떠 있을 때는 **더보기 탭이 활성**이어야 한다(사용자가 자기 위치를 안다)
- 시트: 그래버(36×4px), `max-height: 92dvh`, `border-radius`는 위쪽만, 등장은 `sheet-up` 모션(≤ 감사 도구 대기시간 — audit-gate 하니스 규칙 10)
- 시트: 그래버(36×4px), `max-height: 92dvh`, `border-radius`는 위쪽만, 등장은 `sheet-up` 모션(duration은 `--dur-normal` — [motion.md](motion.md) §1 "표면 등장" 행. 시각 회귀는 모션 완료를 관측한 뒤 캡처한다 — 모션을 테스트 대기시간에 맞춰 줄이지 않는다, [audit-gate-rules.md](audit-gate-rules.md) G10)
- 시트 항목도 `min-height: 48px`
### 뷰 전환은 스크롤을 리셋하고, 헤더는 하나다
@ -143,7 +157,7 @@ unitless `line-height`(위 예의 `1.125`처럼 배수로 쓴 값)는 `scale()`
### 하드웨어 뒤로가기 = 앱 내 히스토리 (핵심 패턴)
안드로이드 백 키와 iOS 스와이프 백이 **앱을 종료하지 않고 앱 내 뒤로** 작동해야 한다. SPA 뷰 전환마다 `history.pushState` 로 스택을 쌓고 `popstate` 로 소비한다. 다이얼로그도 같은 스택에 태운다:
안드로이드 백 키와 iOS 스와이프 백이 **앱을 종료하지 않고 앱 내 뒤로** 작동해야 한다. SPA 뷰 전환마다 `history.pushState` 로 스택을 쌓고 `popstate` 로 소비한다. 다이얼로그도 같은 스택에 태운다. 아래는 History API 기준의 검증된 패턴이다. 지원 하한이 2026년 초 이후 브라우저면 Navigation API(`navigate` 이벤트의 `intercept()`)가 같은 일을 한곳에서 처리한다(Baseline Newly 2026-01-13 [WEB-BASELINE]). 한 프로젝트는 한 방식만 쓰고, 어느 쪽이든 검증은 진짜 백 키로 한다:
```js
const DIALOGS = ["stu-dialog", "as-dialog", "more-dialog"];
@ -185,10 +199,12 @@ window.addEventListener("popstate", () => {
```css
html { -webkit-tap-highlight-color: transparent; }
body { overscroll-behavior-y: none; } /* 페이지 전체가 통째로 튕기는 것 방지 */
.sheet, .modal, .scroll-panel { overscroll-behavior: contain; } /* 내부 스크롤이 끝에 닿아도 뒤 페이지로 번지지 않게 */
:is(button, a, input, select, textarea) { touch-action: manipulation; } /* 더블탭 줌 지연 제거 */
```
**`overscroll-behavior`는 스크롤 컨테이너·시트·모달에만 `contain`으로 건다.** `body`에 `overscroll-behavior-y: none`을 전역으로 걸면 안드로이드 Chrome의 당겨서 새로고침과 iOS의 바운스라는 OS 관습이 꺼진다(핵심 규칙: OS 관습은 조사 없이 바꾸지 않는다). 앱이 자체 당겨서 새로고침을 제공하는 등 브리프 근거가 있을 때만 전역 `none`을 쓰고 이유를 `design.md`에 적는다.
자체 드래그·줌 표면(캔버스·커스텀 슬라이더)은 `touch-action: none`을 그 요소에만 스코프해 건다 — 전역에 걸면 페이지 스크롤까지 막힌다. 호버 전용 효과(카드 확대 등)는 `@media (hover: hover) and (pointer: fine)`로 게이트한다. 터치 기기는 탭이 호버를 일으켜 오탐을 만든다.
## 촉감 피드백(햅틱)
@ -197,7 +213,7 @@ body { overscroll-behavior-y: none; } /* 페이지 전체가 통째로
**프로젝트 계약**: 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].
멀티모달 피드백의 세 원칙(인과성·조화·유용성)은 [interaction-feel.md](interaction-feel.md) §9가 정본이다. 여기서는 웹 구현만 다룬다[SKILL-APPLE-DESIGN].
```js
function hapticTap(pattern = 10) {
@ -270,9 +286,17 @@ document.addEventListener('touchend', () => {
제스처 인식 알고리즘(히스테리시스·포인터 캡처·그랩 오프셋)과 속도 기반 dismiss 판정은 [interaction-feel.md](interaction-feel.md)를 참조한다.
## 모바일 웹 세부 — 키보드·뷰포트 단위·글꼴 크기
- **가상 키보드**: 키보드가 열릴 때 하단 고정 바·시트가 키보드에 가려지거나 함께 밀려 올라가는지 정한다. 레이아웃을 키보드만큼 줄이려면 viewport 메타에 `interactive-widget=resizes-content`를 검토하고(Android Chrome 108+·Android Firefox 133+만. iOS Safari는 지원하지 않는다 — 확인 2026-10-06 [WEB-BASELINE]), 고정 요소 위치는 `visualViewport`의 `resize` 이벤트로 보정한다. iOS에서는 이 보정이 유일한 수단이므로 하단 고정 입력창은 iPhone 실기기에서 키보드 열기 → 닫기 → 스크롤 순서로 확인한다.
- **뷰포트 높이 단위**: `100vh`는 모바일 주소창이 접힌 상태 기준이라 첫 화면에서 넘친다. 첫 화면 꽉 채움은 `100svh`(가장 작은 뷰포트), 주소창 변화에 따라가야 하면 `100dvh`, 최대 높이 기준은 `100lvh`다. `dvh`는 스크롤 중 값이 바뀌어 레이아웃이 흔들릴 수 있으니 큰 섹션 높이에는 `svh`를 먼저 검토한다.
- **사용자 글꼴 크기 존중**: `html { font-size: 16px; }`처럼 루트를 px로 고정하지 않는다. 루트는 비워 두거나 %로 두고 나머지를 `rem`으로 쓴다 — 사용자가 키운 기본 글꼴 크기가 반영된다. 시스템 글꼴 크기 설정에서 200%까지 키워 레이아웃을 확인한다.
## PWA 최소 세트
"앱처럼"의 최소 기준: `manifest.webmanifest`(name·short_name·display: standalone·theme_color·아이콘 192/512) + `apple-touch-icon` + `mobile-web-app-capable` 메타 2종. 아이콘은 SVG에서 sharp로 PNG를 뽑는다(브랜드 문양 — 장부 문항, 시간표 블록 같은 **도메인 은유**). 홈 화면에 설치하면 아이콘이 곧 브랜드다 — 파비콘 재사용으로 땜빵하지 않는다.
"앱처럼"의 최소 기준: `manifest.webmanifest`(name·short_name·display: standalone·theme_color·아이콘 192/512, 안드로이드 적응형 아이콘용 `purpose: "maskable"` 아이콘 별도 — 안전영역 안에 문양을 두고 가장자리까지 배경을 채운다) + `apple-touch-icon` + `mobile-web-app-capable` 메타 2종. 아이콘은 SVG에서 sharp로 PNG를 뽑는다(브랜드 문양 — 장부 문항, 시간표 블록 같은 **도메인 은유**). 홈 화면에 설치하면 아이콘이 곧 브랜드다 — 파비콘 재사용으로 땜빵하지 않는다.
**iOS 26부터는 매니페스트가 없어도 홈 화면에 추가한 사이트가 기본으로 웹앱(standalone)으로 열린다**(사용자가 "웹 앱으로 열기"를 끄지 않는 한 — WebKit 공지, 확인 2026-10-06). 그래서 PWA 브리프가 아니어도 여러 페이지짜리 사이트는 두 가지를 확인한다. (1) 브라우저의 뒤로가기 버튼 없이도 모든 화면에서 이전 화면·홈으로 돌아갈 수 있다. (2) 상단 고정 헤더가 상태 표시줄 영역과 겹치지 않는다(`viewport-fit=cover`를 쓰면 `env(safe-area-inset-top)` 적용). 브랜드가 중요한 사이트는 매니페스트와 `apple-touch-icon`을 두어 이름·아이콘·시작 URL을 사이트가 정한다.
## 실기기 검증 (안드로이드 에뮬레이터)
@ -281,7 +305,7 @@ document.addEventListener('touchend', () => {
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)
4. **판정은 텍스트 근거로**: `uiautomator dump` 의 `text="…"` 에서 화면 제목·활성 탭을 읽는다. 비전(모델) 검수는 "백 직후 전환 중 프레임"을 잡는 등 타이밍 오탐이 있다 — 여부 판정은 계측, 비전은 레이아웃 평가에만([audit-gate-rules.md](audit-gate-rules.md) G5)
5. Git Bash(Windows)에서 `/sdcard/...` 인자는 MSYS 경로 변환으로 깨진다 — `export MSYS_NO_PATHCONV=1`
6. 물리 폰이 adb에 붙어 있을 수 있다 — **항상 `-s emulator-XXXX` 로 대상을 한정한다**