designpaca/packages/skill/references/interaction-feel.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

326 lines
28 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.

# interaction-feel — 입력 축의 촉감
4-4에서 [motion.md](motion.md)와 함께 읽는다. `motion.md`는 **시간 축**을 다룬다 — 입력이 없어도 흐르는 움직임, duration·이징 토큰, 스크롤·페이지 전환. 이 문서는 **입력 축**을 다룬다 — 사용자가 누르고 끌고 기다리는 동안 화면이 무엇을 돌려주는가다. 겹치는 지점(눌림 트랜지션, hold-to-confirm의 clip-path)에서는 `motion.md`의 코드를 그대로 가리키고 여기서 다시 정의하지 않는다. `--dur-*`·`--ease-*` 토큰도 `tokens.md` §4가 정본이며, 이 문서는 새 토큰을 만들지 않는다.
## 이 문서를 읽는 법
| 상황 | 읽을 곳 |
|---|---|
| 입력에 반응이 굼뜨다는 지적을 받았다 | §0 |
| hover·focus·active 같은 상태를 정리해야 한다 | §1 |
| 버튼·카드 누름 피드백만 빨리 정하면 된다 | §2 |
| 직접 드래그·슬라이더·캐러셀을 만든다 | §3 |
| 애니메이션 도중 다시 조작하면 끊긴다 | §4 |
| 스와이프·던지기·바텀시트 스냅을 구현한다 | §5 |
| 스프링 라이브러리를 쓰거나 스프링감을 흉내 낸다 | §6 |
| 삭제·파괴적 동작의 확인 UI를 고른다 | §7 |
| 토스트·토글·스켈레톤 같은 흔한 컴포넌트를 만든다 | §8 |
| 사운드·햅틱을 쓸지 고민한다 | §9 |
| 모바일 폭에서 주요 컨트롤 위치를 정한다 | §10 |
| 커스텀 제스처 컴포넌트를 감사한다 | §11 |
| 구현 직전 확인 | §12 |
---
## 0. 반응성
**포인터다운은 화면 어딘가를 즉시 바꿔야 한다.** 하이라이트든 프레스 스케일이든, 사용자가 누른 순간과 화면이 반응하는 순간 사이에 빈 시간이 있으면 그 인터페이스는 무겁게 느껴진다. 이 원칙 자체는 `motion.md` §0의 "피드백" 역할과 같지만, 여기서는 **지연의 출처**를 감사 대상으로 삼는다.
**프로젝트 계약 — 입력 경로 지연 감사.** `tokens.md` §5 성능 예산에 다음을 포함해 확인한다. 필수가 아닌 지연은 전부 제거 대상이다 [SKILL-APPLE-DESIGN].
- **디바운스**: 사용자가 누른 것을 그대로 보여주지 않고 지연시키는 디바운스가 입력 경로 위에 있는가(검색어 자동완성처럼 디바운스가 필요한 곳과, 단순 클릭처럼 필요 없는 곳을 구분한다)
- **인위적 타이머**: `setTimeout`으로 "그럴듯해 보이려고" 넣은 지연이 있는가(가짜 로딩 등)
- **전환 대기**: 이전 트랜지션이 끝나야 다음 입력을 받는 코드가 있는가 — 이는 §4 인터럽트 가능성이 막는 문제와 같다
- **탭 지연**: `touch-action: manipulation`으로 더블탭 줌 판정 지연을 제거했는가(`mobile-app-ux.md`가 이미 전역 적용을 권한다)
**관찰 후보 — 체감 성능은 실제 로딩 시간과 다른 축이다.** 같은 대기 시간이라도 스피너 등장이 빠르면 더 짧게 느껴지고, 연속 조작(셀렉트 옵션 전환 등)은 트랜지션 자체를 줄이면 반응이 가벼워진다. 이징이 이 지각을 증폭시킨다는 점도 염두에 둔다 — 도착이 급정거처럼 보이는 이징은 빠른 반응도 무겁게 만든다 [SKILL-EMIL-DESIGN-ENG].
---
## 1. 상태 매트릭스
인터랙티브 요소는 여러 상태를 동시에 가진다. 상태마다 시각 신호·모션·접근성 속성을 따로 정해야 어느 하나가 비어도 사용자가 상태를 잃지 않는다.
**하드 게이트 — 모션은 유일한 신호가 되면 안 된다.** `prefers-reduced-motion`에서 모션이 꺼졌을 때 그 상태를 알려줄 정적 단서(색·아이콘·라벨·테두리)가 남아 있어야 한다. 모션이 유일한 신호였다면 감소 모션 사용자에게는 상태 자체가 사라진다 — `motion.md` §5 "접근성 — 타협 없음"과 같은 이유다 [SKILL-BETTER-UI].
| 상태 | 시각 신호 | 모션 | 접근성 속성 |
|---|---|---|---|
| hover | 배경·테두리·밝기 변화 | `--dur-instant`, `--ease-out` | **`@media (hover: hover) and (pointer: fine)` 안에서만 적용한다.** coarse 포인터(터치)에 hover 스타일을 걸면 탭 이후 눌린 채로 고정된 것처럼 보인다(`mobile-app-ux.md`의 실측 사고와 같은 부류) [SKILL-EMIL-DESIGN-ENG] |
| focus-visible | 포커스 링, 애니메이션 없이 즉시 | 없음 — 즉시 나타나고 즉시 사라진다 | `:focus-visible`만 쓰고 `:focus` 단독은 쓰지 않는다. 상세 판정 기준과 대비 요건은 [accessibility.md](accessibility.md) |
| active(누름) | §2 프레스 피드백 | `--dur-instant` | 포커스 링과 겹칠 때 순서는 프레스가 위, 링은 즉시 |
| disabled | 낮은 대비·커서 `not-allowed` | 없음 | `disabled`와 `aria-disabled`의 구분, 제출 버튼을 조기에 비활성화하지 않는 규칙은 [accessibility.md](accessibility.md) |
| loading | 스켈레톤 또는 스피너(§8) | 진입은 있어도 루프는 작은 면적만 | 진행 알림은 `role="status"`/라이브 리전 — [accessibility.md](accessibility.md) |
| selected | 배경·체크 표시·`aria-current`/`aria-selected` | 상태 전환만, 반복 없음 | 색만으로 표시하지 않는다(선택 안 된 항목과의 구분 신호를 색 외에도 둔다) |
| error | 테두리·아이콘·문구 동시 | 검증 실패 시 짧은 흔들림은 **관찰 후보**(과하면 산만함) | `aria-invalid`+`aria-describedby` 짝, 타이밍은 [accessibility.md](accessibility.md) |
---
## 2. 누름 피드백
**관찰 후보 — 시작값 `scale(0.96)`~`scale(0.98)`, `2~4%`를 넘기지 않는다.** duration은 새 리터럴을 만들지 않고 `tokens.md` §4가 이미 정한 `--dur-instant`(100ms)에 맞춘다 — 출처 스킬마다 `0.96`·`0.97`·`0.98`·`150ms`로 조금씩 다르지만 그 차이는 실질적이지 않다(이미 `tokens.md` §4가 이 매핑을 확정했다) [SKILL-APPLE-DESIGN][SKILL-BETTER-UI].
```css
.btn:active { scale: 0.97; transition: scale var(--dur-instant) var(--ease-out); }
```
**정적 토글 패턴.** 프레스 스케일을 컴포넌트 단위로 끌 수 있는 불리언 옵션(예: `static`)을 둔다. 전역 감소 모션 설정과는 별개로, 밀도 높은 데이터 테이블의 행 버튼처럼 프레스 모션이 소음이 되는 맥락에서 개별적으로 끄기 위함이다 [SKILL-BETTER-UI].
버튼·링크의 실제 CSS 레시피(밝기·그림자를 `opacity` 레이어로 바꾸는 것까지 포함한 전체 코드)는 `motion.md` 코드 A를 그대로 쓴다. 여기서 다시 적지 않는다.
---
## 3. 직접 조작
드래그 가능한 요소는 손가락·포인터를 **1:1로 그대로 따라가야** 한다. 배율을 걸거나 지연시키면 "끌려온다"는 느낌이 사라진다.
```js
el.addEventListener('pointerdown', (e) => {
if (isDragging) return; // 관찰 후보 — 멀티터치 가드. 추가로 들어오는 터치를
// 무시하지 않으면 손가락을 바꿔 쥘 때 요소가 점프한다 [SKILL-EMIL-DESIGN-ENG]
isDragging = true;
el.setPointerCapture(e.pointerId); // 포인터가 요소 경계를 벗어나도 트래킹을 유지한다
grabOffset = e.clientY - el.getBoundingClientRect().top; // 잡은 지점을 보존한다 — 중심으로 스냅하지 않는다
startTracking(e);
});
```
**관찰 후보 — 히스테리시스 시작값 약 10px.** `pointerdown` 즉시 하이라이트는 켜되, 실제 드래그로 판정하기까지 약 10px(실측 조정)의 이동 여유를 둔다. 그보다 작은 떨림은 탭으로 처리한다 [SKILL-APPLE-DESIGN].
**병렬 제스처 인식.** 같은 축을 두고 경쟁하는 제스처가 있으면(세로 스크롤 vs 카드 가로 스와이프) 포인터다운 시점에는 아직 승자를 정하지 않는다. 초기 이동 벡터로 어느 쪽이 의도된 제스처인지 가른 뒤 패자 쪽 제스처를 취소한다 [SKILL-APPLE-DESIGN].
**터치 표면 스코프.** `mobile-app-ux.md`가 이미 전역에 건 `touch-action: manipulation`과 별개로, 자체 드래그·줌을 구현하는 표면에는 `touch-action: none`을 그 요소에만 스코프해 적용한다 — 전역에 걸면 페이지 스크롤 자체가 막힌다 [SKILL-BETTER-A11Y].
**하드 게이트 — 드래그 전용 기능에는 단일 포인터 대체 조작이 있어야 한다** [WCAG-DRAG]. 구체적인 대체 조작 레시피는 §8 스와이프·당겨서 새로고침을 본다.
---
## 4. 인터럽트 가능성
WWDC 2018 *Designing Fluid Interfaces*가 제시하는 단일 최우선 원칙이다 [WWDC-FLUID][SKILL-APPLE-DESIGN].
**관찰 후보 — 전환 도중 입력을 잠그지 않는다.** 사용자가 애니메이션 중간에 다시 잡으면(재드래그, 다시 클릭), 새 애니메이션은 목표값이 아니라 **현재 렌더된 값(presentation value)**에서 시작한다. 방향을 반전할 때도 속도를 하드컷 없이 블렌드한다 — 순간 정지 후 반대로 출발하면 "벽에 부딪힌" 느낌을 준다.
이 원칙은 CSS 메커니즘 선택과 바로 연결된다. `@keyframes`는 인터럽트되면 처음부터 재시작하지만, `transition`은 중단된 지점에서 새 목표로 **재조준(retarget)**한다. 그래서 **재트리거될 수 있는 모든 UI(토스트 추가, 재드래그, 빠르게 토글되는 상태)는 키프레임 대신 트랜지션을 쓴다** [SKILL-EMIL-DESIGN-ENG].
2D로 움직이는 요소는 X/Y를 독립된 스프링으로 분해한다. 한 축만 반전돼도 다른 축의 진행이 깨지지 않는다.
가역적 전환(뒤로 가기처럼 같은 경로를 반대로 도는 것)은 이징도 미러링한다 — 나가는 경로에 쓴 커브의 대칭점을 들어오는 경로에 쓴다. `motion.md` 코드 D의 "팝오버는 클릭한 곳에서 나와야 한다"(트리거 위치를 `transform-origin`으로 삼아 전환의 시작점과 도착점을 대칭으로 맞추는 원칙, motion.md §"코드 D — 모달·팝오버 진입/퇴장")와 같은 계열이다 [SKILL-APPLE-DESIGN].
---
## 5. 던지기 물리
스와이프·드래그를 놓았을 때(release) 무엇으로 착지시킬지 정하는 절차다. 여기 나오는 수치는 전부 **시작값**이며 실측 조정이 필요하다.
### 5-1. 속도 인계
```
relativeVelocity = gestureVelocity / (targetValue − currentValue)
```
릴리즈 속도를 다음 애니메이션의 초기 속도로 넘긴다. 일부 스프링 API는 남은 거리로 정규화한 **상대 속도**를 요구하고, 다른 라이브러리는 px/s 절대 속도를 그대로 받는다 — 라이브러리 문서에서 velocity 파라미터의 단위를 반드시 먼저 확인한다 [SKILL-APPLE-DESIGN].
### 5-2. 모멘텀 투사
```js
// decelerationRate ≈ 0.998(일반 스크롤 감각), 0.99(더 스내피한 느낌) — 시작값, 실측 조정
function project(releaseVelocity /* px/s */, decelerationRate = 0.998) {
return (releaseVelocity / 1000) * decelerationRate / (1 - decelerationRate);
}
const projectedEndpoint = currentPosition + project(releaseVelocity);
const target = nearestSnapPoint(projectedEndpoint); // 릴리즈 지점이 아니라 "투사된 종착점" 기준으로 스냅
animateSpringTo(target, { velocity: releaseVelocity }); // 5-1의 속도를 그대로 인계한다
```
**왜 교과서식 `v²/(2·decel)` 공식이 맞지 않는가.** 그 공식은 등가속도(감속도가 일정함)를 전제한다. 위 감쇠 모델은 매 프레임 속도에 상수 비율(`decelerationRate`)을 곱하는 **지수 감쇠**다 — 감속도가 속도에 비례해 줄어든다. 그래서 등가속도 공식이 아니라 기하급수 합으로 총 이동 거리가 정해진다 [SKILL-APPLE-DESIGN].
### 5-3. 러버밴드(경계 저항)
```js
// overshoot: 경계를 넘은 거리, dimension: 드래그 가능한 표면의 크기, k=0.55(시작값)
function rubberband(overshoot, dimension, k = 0.55) {
return (overshoot * dimension * k) / (dimension + k * Math.abs(overshoot));
}
```
경계를 하드 클램프하는 대신, 넘어갈수록 저항이 커지는 점진적 저항을 쓴다. "실제 물체는 멈추기 전에 먼저 느려진다"는 감각이다 [SKILL-APPLE-DESIGN].
### 5-4. dismiss 임계값
```js
const velocity = Math.abs(dragDistance) / elapsedTime; // px/ms
if (Math.abs(dragDistance) >= DISTANCE_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
dismiss();
}
```
**관찰 후보 — 매직넘버, 반드시 실측 조정한다.** 참고 시작값: 카드형 스와이프 삭제는 거리 임계값을 카드 폭의 `25~30%`(또는 `100px`) 정도로 두고[SKILL-INTERACTION-DESIGN], 속도 임계값은 빠른 플릭 하나로도 거리 조건 없이 dismiss되도록 별도로 잡는다. 외부 출처(emil-design-eng)는 속도 임계값 약 `0.11`을 제시하지만 단위를 밝히지 않아(px/ms로 추정될 뿐) 그대로 옮기지 않는다[SKILL-EMIL-DESIGN-ENG]. 실제 기기에서 빠른 플릭이 거리 조건 없이도 통과하는지 확인한다.
---
## 6. 스프링
> 언제: 브리프·0단계 인터뷰([brief-interview.md](brief-interview.md))에서 스프링감(경쾌함·플레이풀함)이 톤으로 선택됐을 때, 또는 프로젝트가 이미 스프링 기반 라이브러리를 쓰고 있을 때만 연다. 절제된 톤에서는 스프링을 쓰지 않는다.
같은 "스프링감"을 세 가지 표기 체계가 서로 다른 언어로 규정한다. 병기하고, 프로젝트가 이미 쓰는 라이브러리가 있으면 그 표기를 우선한다.
| 표기 | 파라미터 | 주로 쓰는 곳 | 값(관찰 후보·시작값) |
|---|---|---|---|
| damping ratio + response | `damping`, `response`(초) | Apple 계열 모션 API | 기본 UI: `damping 1.0`(임계감쇠, 오버슈트 없음), `response 0.3~0.4s`. 모멘텀 제스처(플릭·던지기) 뒤에만: `damping ~0.8` [SKILL-APPLE-DESIGN] |
| stiffness + damping + mass | `stiffness`, `damping`, `mass` | 전통적 물리 기반 스프링 라이브러리 | `default 170/26/1`, `gentle 120/14/1`, `wobbly 180/12/1`, `stiff 210/20/1`, `slow 280/60/1`, `molasses 280/120/1` [SKILL-INTERACTION-DESIGN] |
| duration + bounce | `duration`(초), `bounce`(-1~1) | Apple 방식을 옮긴 Motion API | UI 전반 `duration 0.3~0.5`, `bounce`는 `0.1~0.3`로 은은하게 — 대부분의 UI 맥락에서는 `bounce 0`(오버슈트 없음)이 기본이고, drag-to-dismiss나 장난스러운 인터랙션에만 올린다 [SKILL-EMIL-DESIGN-ENG] |
**표기 사이의 변환식.** 감쇠 조화 진동자의 표준 관계식이다. stiffness `k`, damping `c`, mass `m`일 때:
```
dampingRatio ζ = c / (2 * sqrt(k * m)) # 1 = 임계감쇠(오버슈트 없음), 1 미만 = 바운스
response(초) = 2π * sqrt(m / k) # 감쇠가 없을 때 한 주기. duration+bounce 표기의 duration 과 같은 뜻으로 쓴다
bounce = 1 - ζ # ζ ≤ 1 일 때. ζ > 1(과감쇠)은 음수 bounce 로 표기하는 라이브러리가 있으니 문서를 확인한다
```
예: `default 170/26/1` → ζ ≈ 1.0, response ≈ 0.48초(바운스 거의 없음). `wobbly 180/12/1` → ζ ≈ 0.45, response ≈ 0.47초. 라이브러리마다 `duration` 정의가 조금씩 다르므로 변환 후 실제 렌더로 확인한다.
외부 출처가 함께 제시한 `duration = sqrt(stiffness) / damping` 근사식은 위 관계식과 맞지 않아(예: `280/60/1` 이 0.28초로 나온다) 옮기지 않는다 [SKILL-INTERACTION-DESIGN]. cubic-bezier 로 흉내 내는 방법(`bounce > 0` 이면 `cubic-bezier(0.34, 1.56, 0.64, 1)` 처럼 끝값을 넘기는 곡선)은 한 번의 오버슈트만 표현하는 거친 근사다 — 여러 번 흔들리는 스프링은 아래 `linear()` 로 근사한다.
**CSS `linear()`로 라이브러리 없이 근사한다.** `motion.md` §3이 이미 "무한 루프, `linear()` 스프링 근사"를 CSS 네이티브로 분류해 둔 것과 같은 방법이다 — damping/response 값을 표본점 배열로 뽑아 `animation-timing-function: linear(...)`에 채운다. 표본값은 프로젝트마다 도구로 생성하는 것이지 여기서 고정값을 두지 않는다.
**톤과 연결한다.** 플레이풀·장식적 톤일 때만 스프링에 바운스를 허용하고, 절제된 톤(스위스·럭셔리 계열 등)에서는 스프링 자체를 쓰지 않는다 [SKILL-EMIL-DESIGN-ENG].
**감소 모션에서는 바운스가 없다.** `prefers-reduced-motion`에서는 오버슈트·바운스를 모두 제거하고 임계감쇠(`damping 1.0`/`bounce 0`)로만 전환한다 — `motion.md` 코드 H와 같은 원칙이다.
---
## 7. 확인과 되돌림
**되돌리기가 확인보다 낫다.** 흐름이 끊기지 않기 때문이다(`preflight.md`의 "파괴적 행동에는 되돌림이 있다" 원칙과 같다). 확인 대화상자는 되돌리기 UI를 만들 수 없을 때의 차선책으로만 쓴다.
> 언제: 삭제처럼 파괴적이고 즉시 실행되는 동작에, 되돌리기 UI가 마땅치 않을 때만 hold-to-confirm을 고려한다.
**hold-to-confirm의 비대칭 타이밍.** 누르는 동작(사용자가 결정을 내리는 중)은 느리게, 놓는 반응(시스템이 응답하는 순간)은 항상 빠르게 — 결정에는 시간을, 반응에는 즉시성을 준다는 원칙이다. clip-path 오버레이 자체의 CSS 레시피(누르는 동안 채워지는 애니메이션, 놓았을 때의 스냅백)는 `motion.md` 코드 C-1("clip-path를 애니메이션할 때")을 그대로 쓴다. 프레스 피드백은 §2의 `scale(0.96)`~`0.98)`을 함께 건다 [SKILL-EMIL-DESIGN-ENG].
**2단계 확인(재클릭)은 대안일 뿐 기본값이 아니다.** designpaca는 이미 "되돌리기 우선"이라는 더 엄격한 원칙을 갖고 있다. N초 안에 다시 눌러야 확정되는 패턴은 되돌리기 UI가 정말 불가능할 때의 대안으로만 쓴다 [SKILL-INTERACTION-DESIGN].
**하드 게이트 — 실패한 요청을 성공 상태로 남기지 않는다**(SKILL.md 규범·기능 하드 게이트 5).
**낙관적 업데이트와 롤백 알림.** 좋아요·북마크·투표처럼 위험이 낮고 즉시 반응이 필요한 토글은 낙관적으로 먼저 반영하고 실패 시 되돌린다. 롤백은 §8 토스트로 알리고, 판정은 `preflight.md`의 "판정은 순수 함수다" 원칙에 따라 원래 검증 함수로 다시 심사한다 — UI 핸들러가 임의로 판단하지 않는다 [SKILL-INTERACTION-DESIGN].
---
## 8. 마이크로 레시피
### 토스트
**하드 게이트 — 상태 메시지는 포커스를 가져가지 않고 알려야 한다** [WCAG-STATUS]. 성공·정보 메시지는 `role="status"`, 오류는 `role="alert"`로 실질적인 라이브 리전을 만든다(자세한 라이브 리전 선택 기준은 [accessibility.md](accessibility.md) §7).
- 자동 소멸은 저위험 확인에만 쓰고, 쓴다면 **5초 이상**이 바닥값이다([accessibility.md](accessibility.md) §7) — 메시지 길이에 따라 연장하고, hover·focus 중에는 타이머를 정지한다. 외부 출처의 `3000ms`는 이 바닥값보다 짧아 옮기지 않는다 [SKILL-BETTER-A11Y][SKILL-INTERACTION-DESIGN]
- **행동(실행취소 등)이 있는 토스트는 자동 소멸시키지 않는다** — 사용자가 다 읽기 전에 행동 기회가 사라진다 [SKILL-BETTER-A11Y]
- 진입·퇴장 트랜지션은 새 토큰을 만들지 않고 `--dur-quick`/`--ease-out`(진입), `calc(var(--dur-quick) * 0.65)`/`--ease-in`(퇴장)을 그대로 재사용한다
- 스택 방향(위로 쌓을지 아래로 쌓을지)은 프로젝트 계약이다
### 토글 스위치
`role="switch"`, `aria-checked` 필수 — ARIA Authoring Practices Guide의 스위치 패턴을 따른다 [ARIA-APG]. 손잡이 이동은 `translate`(저비용 기본 선택, `motion.md` §2)로 하고, `transition: translate var(--dur-instant) var(--ease-out)`가 기본이다. 스프링감이 필요하면 §6의 조건(톤이 확인됐을 때)에서 `linear()` 근사를 컴포넌트에 로컬로 쓴다 — 전역 토큰을 새로 만들지 않는다.
### 스켈레톤 시머
원칙("로딩은 실제 비동기에만 쓴다", "스피너보다 스켈레톤")은 이미 `preflight.md`에 있다. 구현은 그라디언트 스윕 애니메이션이다.
```css
.skeleton {
background: linear-gradient(90deg, var(--surface-raised) 25%, var(--surface) 50%, var(--surface-raised) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
}
@keyframes shimmer { to { background-position: -200% 0; } }
@media (prefers-reduced-motion: reduce) {
.skeleton { animation: none; background: var(--surface-raised); } /* 정적 회색 블록으로 대체 */
}
```
무한 반복이지만 작은 면적이라 `motion.md` §0의 "무한 반복되는 큰 면적 움직임 실격" 규칙에 걸리지 않는다.
### 글자 수 카운터
**관찰 후보, 기본값 아님.** 표시 여부와 `maxLength`는 프로젝트 계약이다. 쓴다면 남은 글자 수가 `20`자 이하일 때 경고 신호, `0` 미만이면 초과 신호를 준다(둘 다 색만이 아니라 문구도 함께) [SKILL-INTERACTION-DESIGN].
### 툴팁 그룹 즉시 열림
첫 툴팁은 지연을 두고 등장한다(실수로 스친 hover를 걸러낸다). 하나가 열려 있는 동안 인접 툴팁으로 옮기면, 그 뒤로는 지연·등장 애니메이션 없이 즉시 연다 — `data-instant` 같은 속성으로 그룹 상태를 표시한다 [SKILL-EMIL-DESIGN-ENG].
### 스와이프 동작과 대체 조작
**하드 게이트 — 드래그·스와이프만으로 끝내지 않는다** [WCAG-DRAG]. 스와이프 삭제·재정렬에는 키보드·스크린리더로 도달 가능한 대체 조작(메뉴 버튼 등)을 반드시 병행한다. 임계 거리는 §5-4와 같은 시작값을 쓰고 실측 조정한다.
### 당겨서 새로고침
> 언제: 네이티브 앱 느낌이 브리프의 명시적 요구일 때만.
당겨서 새로고침도 같은 원칙([WCAG-DRAG])이 적용된다. 제스처 하나에 단일 의존하지 않고 **명시적 새로고침 버튼을 항상 병행**한다. 임계값(예: `60px`)은 근거 없는 시작값이므로 실측 조정 문구를 반드시 남긴다 [SKILL-INTERACTION-DESIGN].
### scroll-snap 캐러셀
```css
.snap-row { scroll-snap-type: x mandatory; overflow-x: auto; }
.snap-item { scroll-snap-align: start; }
@media (prefers-reduced-motion: reduce) {
.snap-row { scroll-behavior: auto; } /* snap 자체는 유지 — WCAG 2.3.3 대상 애니메이션이 아니다 */
}
```
**스크롤 트랩 경고.** 휠·키보드 스크롤로 항목 사이를 지나가야 하는데 snap이 한 항목씩 강하게 붙잡으면, 사용자가 목록을 빠져나가지 못하는 트랩처럼 느껴질 수 있다. `mandatory` 대신 `proximity`를 검토하거나 스크롤 컨테이너 경계에서 탈출이 되는지 실제로 확인한다.
### 리플 이펙트
**조건부 — 기본값이 아니다.** Material Design 특유의 시그니처 이펙트를 모든 버튼에 기본으로 걸면 특정 미학을 무조건 강요하는 것이 된다(antipatterns.md 원칙과 같은 이유). 브리프가 그 미학을 명시했거나 사용자가 선택했을 때만 조건부로 쓴다 [SKILL-INTERACTION-DESIGN].
---
## 9. 멀티모달 피드백
> 언제: 모바일 앱 수준 UI(PWA 포함)에서 사운드·햅틱이 브리프·타깃 기기상 유효할 때만. 데스크톱 랜딩페이지 같은 프로젝트에는 대부분 해당하지 않는다.
세 원칙 — Causality(인과성)·Harmony(조화)·Utility(유용성) [SKILL-APPLE-DESIGN].
- **Causality**: 피드백은 실제로 일어난 인과적 사건에서 트리거한다(토글이 뒤집히는 순간, 아이템이 제자리에 스냅되는 순간). 캐릭터를 행동의 물리성에 맞춘다.
- **Harmony**: 시각·사운드·햅틱은 같은 프레임에서 발화해야 한다. CSS 트랜지션이 오디오·햅틱을 지연시키지 않게 한다.
- **Utility**: 의미 있는 순간(성공·오류·커밋·스냅)에만 아껴 쓴다. 과잉 피드백은 사용자가 전부 무시하도록 학습시킨다.
**프로젝트 계약 — 소리는 기본 꺼짐(opt-in).** 사용자가 먼저 켜지 않은 채로 소리가 나면 대부분의 맥락에서 침해적이다.
**프로젝트 계약 — 햅틱은 점진적 향상이며 단독 신호가 될 수 없다.** Vibration API(`navigator.vibrate`)는 webstatus.dev 기준 Chromium 계열(Chrome·Edge, Android 포함)만 구현했고 Safari(iOS 포함) 구현 기록은 없다[WEB-BASELINE]. 그래서 지원 여부를 가정하지 않고, 햅틱은 항상 시각·상태 신호와 함께 켜는 보강 채널로만 쓴다. 이는 §1의 "모션이 유일한 신호가 되면 안 된다"는 규칙을 감각 채널 전체로 확장한 것이다.
---
## 10. 엄지 영역
모바일 폭에서 1차 조작(주로 쓰는 버튼·주요 CTA)은 엄지가 자연스럽게 닿는 영역에 배치한다. 하단이 상단보다 도달하기 쉽다는 원칙은 `mobile-app-ux.md`가 이미 하단 시트·바텀내비의 근거로 쓰고 있다(썸존 = 화면 하단 약 35%) [SKILL-IMPECCABLE]. 이 문서에서 새로 정의하지 않고 자세한 레이아웃 규칙은 [mobile-app-ux.md](mobile-app-ux.md)를 따른다.
---
## 11. 커스텀 컨트롤 제스처 QA
**레이아웃 통과는 제스처 통과가 아니다.** 정적 스크린샷이나 시각 회귀는 슬라이더·캐러셀·스와이프 카드 같은 커스텀 컨트롤이 실제로 조작 가능한지 증명하지 않는다 [SKILL-IMPECCABLE].
확인할 것:
- **드래그 완주**: 시작점부터 끝점까지 전체 경로가 끊기지 않고 동작하는가(중간 프레임에서 판정이 끊기지 않는가)
- **가로지르는 스와이프 vs 축을 따르는 드래그**: 컨트롤 자체의 축을 따라 미는 제스처와, 그 축을 가로지르며 스크롤과 경쟁하는 제스처를 구분해서 각각 검증한다(§3의 병렬 제스처 인식이 실제로 옳은 쪽을 골랐는지 확인하는 자리다)
- **증거 출처를 보고에 명시한다**: 에뮬레이션(DevTools 터치 에뮬레이션 등)·합성 터치 이벤트(스크립트로 디스패치한 이벤트)·실기기 중 무엇으로 확인했는지, 어떤 엔진(WebKit·Blink·Gecko)이었는지 적는다. 실기기 검증 절차는 [mobile-app-ux.md](mobile-app-ux.md) "실기기 검증" 절을 따른다
- **미검증은 통과로 세지 않는다.** 확인하지 못한 항목은 "미검증(공백)"으로 보고에 남긴다 — [audit-gate.md](audit-gate.md)의 미검증 상태값 원칙과 같다
---
## 12. 체크리스트
- [ ] 입력 경로에 불필요한 디바운스·타이머·전환 대기가 없다(§0)
- [ ] hover 전용 스타일이 `@media (hover: hover) and (pointer: fine)`로 게이트돼 있다(§1)
- [ ] 상태 변화(hover·active·selected·error 등)마다 모션이 꺼져도 남는 정적 신호가 있다(§1)
- [ ] 프레스 피드백이 `scale(0.96~0.98)` 범위이고 `--dur-instant`를 쓴다. 새 duration 리터럴을 만들지 않았다(§2)
- [ ] 커스텀 드래그 요소가 `setPointerCapture`·그랩 오프셋 보존·멀티터치 가드를 갖췄다(§3)
- [ ] 재트리거되는 UI에 `@keyframes` 대신 `transition`을 썼다(§4)
- [ ] 스와이프·당겨서 새로고침에 단일 포인터 대체 조작이 있다(§8, WCAG 2.5.7)
- [ ] 토스트가 `role="status"`/`role="alert"`이고, 행동이 있는 토스트는 자동 소멸하지 않는다(§8)
- [ ] 스프링을 썼다면 브리프·인터뷰로 톤이 확인됐고, 감소 모션에서는 바운스가 사라진다(§6)
- [ ] 사운드는 기본 꺼짐이고, 햅틱은 단독 신호로 쓰이지 않는다(§9)
- [ ] 커스텀 컨트롤을 실기기·엔진 근거로 검증했거나, 못 했다면 미검증으로 보고에 남겼다(§11)