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.
This commit is contained in:
parent
79e79c120b
commit
6805fb2be7
37 changed files with 5688 additions and 128 deletions
326
packages/skill/references/interaction-feel.md
Normal file
326
packages/skill/references/interaction-feel.md
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
# 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue