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
|
|
@ -7,12 +7,16 @@
|
|||
| 상황 | 읽을 곳 |
|
||||
|---|---|
|
||||
| **모션을 넣을지 아직 안 정했다** | **§0만.** 절반은 여기서 "안 넣는다"로 끝나고 그게 정답이다 |
|
||||
| 하루에 몇 번 쓰는 동작인지로 판단하고 싶다 | §0 사용 빈도 표 |
|
||||
| duration·이징만 고르면 된다 | §1 결정 표. 여기서 끝내라 |
|
||||
| 하고 싶은 게 게이트 #8에 걸린다 | §2 우회표 |
|
||||
| 스크롤에 뭔가 물려야 한다 | §3 코드 B·C — 게이트 #10의 정답이 여기 있다 |
|
||||
| 하고 싶은 게 저비용 속성(transform/opacity) 밖이다 | §2 우회표 |
|
||||
| 스크롤에 뭔가 물려야 한다 | §3 코드 B·C — '애니메이션·스크롤 로직이 입력을 방해하지 않는다' 하드 게이트의 정답이 여기 있다 |
|
||||
| 모달·페이지 전환 / 텍스트·숫자 연출 | §3 코드 D·E / F·G |
|
||||
| 라이브러리를 깔지 말지 | §4 |
|
||||
| 형태 자체가 잘려야 한다(clip-path) | §3 코드 C-1 |
|
||||
| 테마(라이트·다크) 전환이 번져 보인다 | §3 코드 I |
|
||||
| 라이브러리를 깔지 말지 / 정리 규율 | §4 |
|
||||
| 감사 직전 | §5 체크리스트 |
|
||||
| 드래그·제스처·촉감 피드백을 만들어야 한다 | 이 문서가 아니라 [interaction-feel.md](interaction-feel.md) — 입력 축은 전부 거기서 다룬다 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -29,6 +33,25 @@
|
|||
|
||||
모든 인터랙션에는 입력 결과를 알 수 있는 피드백이 필요하다. 다만 모션은 상태 변화의 이해를 도울 때만 쓰며, 즉시 바뀌는 값·텍스트·포커스도 피드백이 될 수 있다.
|
||||
|
||||
### 사용 빈도가 답을 정할 때도 있다
|
||||
|
||||
빈도를 모르면 넣지 말고, 브리프에서 확인하거나 가정으로 남긴다.
|
||||
|
||||
| 빈도 | 위계 | 예 | 처방 |
|
||||
|---|---|---|---|
|
||||
| 하루 100회 이상, 키보드 단축키·커맨드 팔레트로 실행 | 관찰 후보 — 시작값 | 커맨드 팔레트 열기, 단축키 토글 | 애니메이션을 넣지 않는다. 반복 마찰이 속도보다 크다 |
|
||||
| 하루 수십 회, 반복 목록·호버 내비 | 관찰 후보 — 시작값 | 리스트 항목 호버, 필터 전환 | 넣더라도 `--dur-instant` 이하로 줄이고 상태는 즉시 반영한다 |
|
||||
| 가끔, 세션당 몇 번 | 관찰 후보 | 모달·드로어·토스트 | §1 결정 표의 표준값을 쓴다 |
|
||||
| 드물게, 최초 1회 | 관찰 후보 | 온보딩, 첫 방문 히어로 | 딜라이트 여지가 가장 크다 |
|
||||
|
||||
첫 두 행의 임계값(100회, 수십 회)은 근거 출처가 없는 매직넘버다. 실측해 조정할 시작값으로만 써라. [SKILL-EMIL-DESIGN-ENG]
|
||||
|
||||
**관찰 후보 — 체감 성능은 실제 처리 시간과 다른 축이다.** 같은 대기 시간이라도 회전이 빠르거나 duration이 짧으면 더 빠르게 느껴진다. 이징도 이 지각에 관여한다 — `--ease-out`은 도착이 이르게 느껴지고 `linear`는 기계적으로 느껴진다. 같은 그룹 안에서 반복되는 인터랙션(예: 툴팁 그룹, §3 코드 D 인근)은 두 번째부터 지연·연출을 줄이면 전체가 더 빠르게 느껴진다. [SKILL-EMIL-DESIGN-ENG]
|
||||
|
||||
**프로젝트 계약 — 흩어진 효과보다 오케스트레이션된 한 순간.** 섹션마다 다른 등장 효과를 걸거나 모든 카드에 같은 호버 트랜지션을 반복해 붙이는 것은 AI가 만든 티가 가장 잘 나는 패턴 중 하나다. 페이지 로드 시퀀스 하나, 리빌 하나로 정한 순간이 흩어진 열 개의 작은 효과보다 낫다. [SKILL-FRONTEND-DESIGN][SKILL-IMPECCABLE]
|
||||
|
||||
**프로젝트 계약 — 섹션마다 스크롤 리빌을 거는 것은 기본값이 아니다.** §3 코드 B는 "리빌을 쓰기로 했을 때"의 구현 기본값이지, 모든 섹션에 리빌을 걸라는 뜻이 아니다. 뒤의 "패럴랙스는 섹션마다 다른 말을 해야 한다"의 마지막 줄(움직이지 않는 것도 결정이다)과 같은 원칙이다.
|
||||
|
||||
다섯 번째 **"멋있어서"는 이유가 아니다.** 예외는 브랜드 표현이 브리프의 명시적 요구일 때뿐이고, 그때도 (a) 사용자가 스크롤·호버로 통제하거나 (b) 1회성이며 (c) `prefers-reduced-motion`에서 완전히 사라져야 한다.
|
||||
|
||||
**넣지 말아야 할 곳**: 고빈도 반복 작업(폼·표·필터) · 오류 복구 경로 · 결과가 이미 예측되는 전환(탭) · **첫 화면**(콘텐츠는 즉시 읽혀야 한다) · 숫자가 계속 바뀌는 곳.
|
||||
|
|
@ -79,6 +102,13 @@
|
|||
|
||||
**stagger 시작값 예시**: 이 프로젝트에서는 `(항목수 − 1) × --stagger + duration ≤ 800ms`, 12개 이하를 초기 예산으로 둘 수 있다. 목적·입력 지연·전체 소요·감소 모션에 맞춰 계약을 정하고 실제 렌더에서 확인한다. 방향은 읽기 방향(좌→우, 상→하)과 일치시킨다.
|
||||
|
||||
**각주 넷.**
|
||||
|
||||
1. **프로젝트 계약 — "UI에 ease-in을 절대 쓰지 않는다"는 통념은 진입(entrance)에만 해당한다.** 진입에 감속 없는 가속 곡선을 쓰면 도착이 급정거처럼 보이기 때문이다. 위 표의 퇴장 `--ease-in`(화면 밖으로 나가는 것)은 의도적으로 유지한다 — 나가는 것은 가속해도 자연스럽다. [SKILL-EMIL-DESIGN-ENG]
|
||||
2. **프로젝트 계약 — 트랜지션과 키프레임은 재트리거 가능성으로 고른다.** CSS `transition`은 중간에 끊겨도 현재 값에서 다시 조준(retarget)되지만 `@keyframes`는 중단되면 처음부터 다시 재생된다. 토스트 스태킹, 호버 상태 깜빡임처럼 짧은 간격으로 다시 발생할 수 있는 요소는 `transition`을 기본으로 쓰고, `@keyframes`는 1회성·비재트리거 애니메이션(코드 B의 리빌, 코드 G의 카운트업)에만 쓴다. [SKILL-EMIL-DESIGN-ENG]
|
||||
3. **관찰 후보 — 크로스페이드가 이징·duration 조정으로도 어색하면 아주 작은 블러를 겹친다.** `filter: blur(2px)` 정도를 크로스페이드 구간에만 더하면 전환이 매끄러워 보인다. 블러는 20px 미만으로 제한하고, Safari에서 `filter`가 비싼 속성이라는 점을 §2와 함께 확인한다. [SKILL-EMIL-DESIGN-ENG]
|
||||
4. **관찰 후보 — 시작값. 드로어류에는 별도 이징 곡선을 조건부로 둘 수 있다.** iOS 스타일 드로어가 브리프의 요구일 때만, 드로어 컴포넌트 스코프에 로컬로 `--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1)`을 정의해 쓴다(전역 3단계 토큰에는 추가하지 않는다, 실측 조정). [SKILL-EMIL-DESIGN-ENG]
|
||||
|
||||
---
|
||||
|
||||
## 2. 저비용 기본 선택으로 만들기
|
||||
|
|
@ -110,6 +140,21 @@
|
|||
- `display`·`overlay`의 `transition-behavior: allow-discrete`는 보간하지 않는 선택지다. 다른 속성도 자동 금지가 아니며, 정적 게이트 검출은 측정·계약 검토가 필요한 후보를 알릴 뿐이다
|
||||
- View Transitions의 저자 키프레임은 `opacity`와 transform 계열을 먼저 검토한다. 다른 속성은 목적·비용·입력 간섭·감소 모션·예산을 실제로 확인한 근거가 있을 때만 채택한다
|
||||
|
||||
**프로젝트 계약 — `transition: all`은 쓰지 않는다.** 전환할 속성을 `transition-property`로 명시한다(`transition: scale var(--dur-instant) var(--ease-out), opacity var(--dur-instant) var(--ease-out)`처럼 속성별로 쓰거나 `transition-property: scale, opacity`로 모아 쓴다). `all`은 의도하지 않은 속성(`height`·`box-shadow` 등)까지 전환에 끌어들여 위 표의 저비용 원칙을 조용히 깬다. Tailwind의 `transition-transform`은 `transform`·`translate`·`scale`·`rotate` 넷을 한 번에 잡으므로 그 넷만 전환할 때 쓰고, 다른 속성이 섞이면 대괄호 문법(`transition-[scale,opacity]`)으로 좁힌다. [SKILL-BETTER-UI]
|
||||
|
||||
**`will-change`가 실제로 GPU 합성을 만드는 속성은 셋뿐이다.**
|
||||
|
||||
| 속성 | 합성 가능 | 비고 |
|
||||
|---|---|---|
|
||||
| `transform` | O | 저비용 기본 |
|
||||
| `opacity` | O | 저비용 기본 |
|
||||
| `filter` | O | Safari에서 특히 체감 효과가 크다 |
|
||||
| `clip-path` | 불안정 | 신형 Chromium 한정. 신뢰하지 마라 |
|
||||
| `top`·`left`·`width`·`height` | X | 레이아웃 속성. `will-change`를 걸어도 합성되지 않는다 |
|
||||
| `background`·`border`·`color` | X | 페인트 속성. 합성되지 않는다 |
|
||||
|
||||
**관찰 후보 — 첫 프레임에서 실제로 끊김이 보일 때만 추가한다.** 상시 걸어두면 레이어가 늘어 오히려 느려진다(`preflight.md`·`svg-filters.md`의 경고와 같은 이유). [SKILL-BETTER-UI]
|
||||
|
||||
---
|
||||
|
||||
## 3. CSS 네이티브 우선 — 라이브러리를 끌어오기 전에
|
||||
|
|
@ -122,7 +167,7 @@
|
|||
| 페이지 전환 (View Transitions) | 레이아웃 변화 FLIP (그리드 → 리스트) |
|
||||
| 무한 루프, `linear()` 스프링 근사 | 텍스트 자동 분해, SVG 패스 모핑, 포인터 추종 |
|
||||
|
||||
**지원 현황(2026-08).** `prefers-reduced-motion`·`linear()`는 Baseline widely — 무조건 쓴다. `@starting-style`·`transition-behavior`(2024-08), same-document View Transitions(2025-10)는 Baseline newly — 폴백 두고 쓴다. **scroll-driven animations와 cross-document View Transitions는 Firefox 미지원이라 `@supports` 가드가 필수다.**
|
||||
**지원 현황(확인 2026-09-24, 1차 출처).** `prefers-reduced-motion`·`linear()`는 Baseline widely — 무조건 쓴다. `@starting-style`은 2024-08-06 Baseline newly가 됐고(Firefox 129), same-document View Transitions는 2025-10-14 Baseline newly가 됐다(Firefox 144가 마지막으로 지원) — 둘 다 아직 widely(저 시점부터 30개월)는 아니므로 폴백을 두고 쓴다. **scroll-driven animations(`animation-timeline`)는 Baseline Limited다 — Firefox는 안정판에서 아직 지원하지 않는다**(지원: Chrome/Chrome Android/Edge 115, Safari 26). cross-document View Transitions도 Baseline Limited(Firefox 미지원)다. 두 기능 모두 `@supports` 가드가 필수이고, 가드 **안의 초기 상태가 실제로 콘텐츠를 보이게 하는지**를 반드시 확인한다(아래 "가장 흔한 사고"). [WEB-BASELINE]
|
||||
|
||||
> **가장 흔한 사고**: 미지원 브라우저에서 `opacity: 0`이 남아 콘텐츠가 영영 안 보이는 것. **초기 상태를 `@supports` 블록 *안에* 넣어라.** 밖에 두면 Firefox에서 백지가 된다.
|
||||
|
||||
|
|
@ -167,7 +212,7 @@
|
|||
|
||||
### 코드 B — 스크롤 진입 리빌 (scroll-driven animation)
|
||||
|
||||
> 언제: **기본값.** 리빌·진행 바·시차·헤더 축소. 컴포지터 스레드에서 돌아 메인 스레드가 막혀도 끊기지 않는다. 게이트 #10의 정답이 이것이다.
|
||||
> 언제: **스크롤 리빌을 쓰기로 했을 때의 구현 기본값.** 리빌 자체를 걸지는 §0("섹션마다 스크롤 리빌을 거는 것은 기본값이 아니다")에서 먼저 정한다. 쓰기로 했다면 진행 바·시차·헤더 축소를 포함해 이 방식이 기본이다 — 컴포지터 스레드에서 돌아 메인 스레드가 막혀도 끊기지 않는다. **scroll-driven animations는 Baseline Limited다. Firefox 안정판이 지원하지 않으므로**(§3 도입부) `@supports` 가드 없이 쓰면 Firefox에서 리빌이 영영 실행되지 않고, 초기 상태를 가드 밖에 두면 콘텐츠가 사라진 채로 남는다. '애니메이션·스크롤 로직이 입력을 방해하지 않는다' 하드 게이트의 정답이 이것이다. [WEB-BASELINE]
|
||||
|
||||
```css
|
||||
/* 초기 상태를 @supports 안에 둔다 — 미지원 브라우저에서는 그냥 보인다 */
|
||||
|
|
@ -184,7 +229,7 @@
|
|||
to { opacity: 1; translate: 0 0; }
|
||||
}
|
||||
}
|
||||
/* 읽기 진행 바 — width가 아니라 scaleX (게이트 #8) */
|
||||
/* 읽기 진행 바 — width가 아니라 scaleX (§2 저비용 속성 원칙) */
|
||||
@supports (animation-timeline: scroll()) {
|
||||
.progress {
|
||||
position: fixed; inset-block-start: 0; inset-inline: 0; height: 3px;
|
||||
|
|
@ -237,9 +282,48 @@ if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|||
}
|
||||
```
|
||||
|
||||
### 코드 C-1 — clip-path를 애니메이션할 때
|
||||
|
||||
> 언제: 형태 자체가 잘려야 하는 경우만. 단순 리빌(텍스트·카드 등장)은 여전히 `overflow: hidden` 래퍼 안 자식의 `translate`가 저비용 기본이다(§2 표, 코드 F의 각주). 이 소절은 그 대안이 아니라 **다른 문제**를 푼다 — 탭 배경이 선택된 탭의 모양으로 바뀌거나, 눌러서 채우는 확인, 두 이미지를 가르는 슬라이더처럼 **경계선 자체가 움직여야** 할 때다. [SKILL-EMIL-DESIGN-ENG]
|
||||
|
||||
`clip-path`는 GPU 합성이 불안정하다(§2 will-change 표). 아래 레시피는 실제 렌더에서 프레임을 재보고, 끊기면 `mask-image`나 두 겹 이미지 + `overflow: hidden`으로 되돌린다.
|
||||
|
||||
```css
|
||||
/* 탭 배경 전환 — 선택된 탭 아래로 배경이 미끄러져 들어온다.
|
||||
JS가 선택된 탭의 실제 rect를 읽어 --tab-x/--tab-w를 갱신한다 */
|
||||
.tabs { position: relative; }
|
||||
.tabs__bg {
|
||||
position: absolute; inset: 0; background: var(--accent);
|
||||
clip-path: inset(0 calc(100% - var(--tab-x) - var(--tab-w)) 0 var(--tab-x) round var(--radius-pill));
|
||||
transition: clip-path var(--dur-normal) var(--ease-soft);
|
||||
}
|
||||
|
||||
/* hold-to-confirm — 누르는 동안(결정)은 느리게 채우고, 놓으면(해제)은 항상 빠르게 되감는다.
|
||||
"누르는 동작은 느리게, 해제는 항상 빠르게"라는 비대칭 원칙의 구체 사례다(preflight.md의 되돌림 원칙과 연결) */
|
||||
.confirm { position: relative; }
|
||||
.confirm::after {
|
||||
content: ''; position: absolute; inset: 0; background: var(--ink);
|
||||
clip-path: inset(0 100% 0 0);
|
||||
transition: clip-path calc(var(--dur-quick) * 0.65) var(--ease-in); /* 해제 스냅백 */
|
||||
}
|
||||
.confirm:active::after { clip-path: inset(0 0 0 0); transition: clip-path 2s linear; } /* 실측 조정 */
|
||||
|
||||
/* 비교 슬라이더 — 두 이미지를 가르는 경계 자체가 움직인다. --split은 드래그로 갱신 */
|
||||
.compare__after { clip-path: inset(0 0 0 var(--split, 50%)); }
|
||||
|
||||
/* 이미지 리빌 — 사각형이 아니라 비정형 경계로 드러나야 할 때 */
|
||||
.reveal-shape {
|
||||
clip-path: polygon(0 0, 0 0, 0 100%, 0 100%);
|
||||
transition: clip-path var(--dur-slow) var(--ease-out);
|
||||
}
|
||||
.reveal-shape[data-shown] { clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%); }
|
||||
```
|
||||
|
||||
**관찰 후보.** 네 레시피 모두 §5 모션 QA로 실제 기기에서 프레임을 확인한다. [SKILL-EMIL-DESIGN-ENG]
|
||||
|
||||
### 코드 D — 모달·팝오버 진입/퇴장 (`@starting-style`)
|
||||
|
||||
> 언제: `display: none`에서 나타나거나 top layer에 올라가는 모든 것. 라이브러리가 필요 없다.
|
||||
> 언제: `display: none`에서 나타나거나 top layer에 올라가는 모든 것. 라이브러리가 필요 없다. (`@starting-style`은 2024-08-06 Baseline newly — §3 도입부. [WEB-BASELINE])
|
||||
|
||||
```html
|
||||
<button popovertarget="tip">도움말</button>
|
||||
|
|
@ -270,11 +354,38 @@ if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|||
@media (prefers-reduced-motion: reduce) { .tip { scale: 1; } } /* 이동은 --shift-sm이 0이 되며 자동 처리 */
|
||||
```
|
||||
|
||||
**팝오버는 클릭한 곳에서 나와야 한다.** 화면 정중앙에서 페이드인하는 팝오버는 "어디서 왔는지"를 버리는 것이다. `transform-origin`을 트리거 위치로 잡아라.
|
||||
**프로젝트 계약 — `scale(0)`에서 시작하지 않는다.** 0에서 커지는 진입은 평평했다가 갑자기 부풀어 보인다. `scale(0.9)` 이상에서 시작하고 항상 `opacity`와 함께 쓴다 — 위 코드의 `scale: 0.96`이 그 예다. [SKILL-EMIL-DESIGN-ENG]
|
||||
|
||||
**팝오버는 클릭한 곳에서 나와야 한다.** 화면 정중앙에서 페이드인하는 팝오버는 "어디서 왔는지"를 버리는 것이다. `transform-origin`을 트리거 위치로 잡아라. **모달은 예외다** — 뷰포트 중앙에 고정되므로 `transform-origin: center`를 유지한다. [SKILL-EMIL-DESIGN-ENG]
|
||||
|
||||
**관찰 후보 — 툴팁 그룹은 첫 번째만 지연한다.** 같은 그룹의 인접 트리거 사이를 연속으로 오갈 때마다 매번 딜레이·트랜지션을 다시 타면 굼떠 보인다. 첫 툴팁이 열린 뒤 일정 시간 안에 그룹 내 다른 트리거로 옮기면 지연·애니메이션 없이 즉시 연다.
|
||||
|
||||
```js
|
||||
let groupTimer = null;
|
||||
group.addEventListener('pointerenter', ({ target }) => {
|
||||
if (!target.closest('[data-tip-trigger]')) return;
|
||||
if (groupTimer) group.dataset.tipInstant = ''; // 그룹이 방금 열려 있었으면 두 번째부터 즉시 연다
|
||||
clearTimeout(groupTimer);
|
||||
groupTimer = setTimeout(() => { // 1500ms 는 실측 조정 시작값
|
||||
delete group.dataset.tipInstant;
|
||||
groupTimer = null; // 창이 닫히면 다음 첫 툴팁은 다시 지연한다
|
||||
}, 1500);
|
||||
}, true); // pointerenter 는 버블링되지 않으므로 캡처 단계에서 그룹이 받는다
|
||||
```
|
||||
|
||||
```css
|
||||
[data-tip-instant] .tip { transition-delay: 0s; transition-duration: 0s; }
|
||||
```
|
||||
|
||||
[SKILL-EMIL-DESIGN-ENG]
|
||||
|
||||
**프로젝트 계약 — 모달 스크림·비차단 패널·스택 시트는 디밍이 다르다.** 모달성 과업은 배경을 어둡게 하는 스크림(`::backdrop` 또는 별도 레이어)과 함께 배경을 살짝 뒤로 후퇴시킨다. 사이드바처럼 흐름을 막지 않고 나란히 떠 있는 패널은 스크림 없이 반투명·오프셋만으로 존재감을 준다. 시트가 겹겹이 쌓이면 열릴 때마다 이전 레이어를 한 단계씩 더 어둡고 더 뒤로 민다. [SKILL-APPLE-DESIGN]
|
||||
|
||||
호버·포커스로 나타나는 콘텐츠(위 `.tip` 같은 툴팁)가 해제 가능·호버 유지·지속이라는 WCAG 1.4.13 조건을 만족하는지는 [accessibility.md](accessibility.md)에서 확인한다. [WCAG-HOVER]
|
||||
|
||||
### 코드 E — 페이지·뷰 전환 (View Transitions API)
|
||||
|
||||
> 언제: 라우트 이동, 리스트 필터링, 썸네일 → 상세. 미지원 브라우저에서는 즉시 바뀐다(점진 향상).
|
||||
> 언제: 라우트 이동, 리스트 필터링, 썸네일 → 상세. 미지원 브라우저에서는 즉시 바뀐다(점진 향상). same-document View Transitions는 2025-10-14 Baseline newly가 됐다(§3 도입부) — 아직 widely는 아니므로 아래 `transition()` 래퍼의 폴백이 실제 경로다. [WEB-BASELINE]
|
||||
|
||||
```js
|
||||
/** DOM을 바꾸는 함수를 감싼다. 지원·모션감소 판정을 여기 한 곳에 모은다 */
|
||||
|
|
@ -332,7 +443,8 @@ filterBtn.addEventListener('click', () => transition(() => renderList(filterBtn.
|
|||
```
|
||||
|
||||
```css
|
||||
.split__line { display: block; overflow: hidden; } /* 마스크. clip-path를 애니메이션하지 않는다 */
|
||||
.split__line { display: block; overflow: hidden; } /* 마스크. 줄 리빌은 overflow 마스크 + translate가 기본이다.
|
||||
형태 자체를 잘라야 하는 경우만 코드 C-1(clip-path)을 쓴다 */
|
||||
.split__line > span { display: block; translate: 0 0; }
|
||||
|
||||
@supports (animation-timeline: view()) {
|
||||
|
|
@ -352,10 +464,12 @@ filterBtn.addEventListener('click', () => transition(() => renderList(filterBtn.
|
|||
|
||||
**자동 분해가 꼭 필요하면** GSAP SplitText를 쓴다(§4, 무료). `type: 'lines,words'`까지만, `mask: 'lines'`, `autoSplit: true`. 글자 단위가 브리프의 요구라면 원문을 `.sr-only` 사본으로 남기고 시각 요소에 `aria-hidden="true"`를 건다.
|
||||
|
||||
**관찰 후보 — 시작값. 단어 단위 스플릿도 옵션이다.** 줄 단위 대신 단어 단위(스태거 약 80ms 시작값)로 쪼개는 스타일도 있다 — 제목처럼 짧고 리듬을 강조하고 싶을 때다. `--stagger` 토큰(60ms, tokens.md §4)은 그대로 유지하고, 단어 개수가 많으면 위 §1의 스태거 예산식으로 총 소요 상한을 확인한다. [SKILL-BETTER-UI]
|
||||
|
||||
### 코드 G — 숫자 카운트업 (자릿수 스트립, transform만)
|
||||
|
||||
> 언제: 실적 숫자 하나. **사용자가 준 진짜 숫자에만 쓴다** — 지어낸 숫자는 게이트 #11이다.
|
||||
> `@property --count` 방식은 커스텀 속성을 매 프레임 바꿔 게이트 #8에 걸린다. 자릿수를 굴려라.
|
||||
> 언제: 실적 숫자 하나. **사용자가 준 진짜 숫자에만 쓴다** — 지어낸 숫자는 '사용자가 주지 않은 수치를 그럴듯한 값으로 넣지 않는다' 하드 게이트에 걸린다.
|
||||
> `@property --count` 방식은 커스텀 속성을 매 프레임 바꿔 저비용 속성(transform/opacity) 밖의 속성을 애니메이션하게 된다. 자릿수를 굴려라.
|
||||
|
||||
```html
|
||||
<p class="stat">
|
||||
|
|
@ -428,6 +542,29 @@ WCAG 2.3.3은 **인터랙션으로 촉발된 모션 애니메이션을 끌 수
|
|||
|
||||
JS 쪽 판정도 한 곳에 모은다. 설정이 도중에 바뀌면 반영한다: `matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => location.reload())`.
|
||||
|
||||
### 코드 I — 테마 전환 시 트랜지션 억제
|
||||
|
||||
> 언제: 라이트·다크를 런타임에 토글하는 프로젝트 전부. 넣지 않으면 테마가 바뀌는 순간 색·그림자·테두리 트랜지션이 동시에 발화해 화면 전체가 번져 보인다.
|
||||
|
||||
OS 설정이나 인앱 토글로 테마가 바뀔 때, 전역에 `transition: none`을 순간적으로 주입하고 강제 리플로우한 뒤 다음 프레임에 제거한다.
|
||||
|
||||
```js
|
||||
function setTheme(next) {
|
||||
const css = document.createElement('style');
|
||||
css.textContent = '*,*::before,*::after{transition:none!important}';
|
||||
document.head.appendChild(css);
|
||||
|
||||
document.documentElement.dataset.theme = next; // 실제 토큰 전환
|
||||
|
||||
document.body.offsetHeight; // 강제 리플로우 — 주입한 스타일을 확정시킨다
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => css.remove()));
|
||||
}
|
||||
```
|
||||
|
||||
`next-themes` 같은 라이브러리를 쓴다면 `disableTransitionOnChange` 옵션이 같은 일을 기본 제공한다.
|
||||
|
||||
**프로젝트 계약.** [SKILL-BETTER-UI]
|
||||
|
||||
---
|
||||
|
||||
## 패럴랙스는 섹션마다 다른 말을 해야 한다
|
||||
|
|
@ -717,6 +854,97 @@ const progress = clamp01((start - rect.top) / travel);
|
|||
|
||||
> 판단: GSAP은 더 이상 "돈 때문에 못 쓰는 라이브러리"가 아니다. 스크롤 시퀀스나 텍스트 분해가 필요하면 주저 없이 쓴다. 다만 **필요 없으면 여전히 깔지 않는다.**
|
||||
|
||||
### 정리 규율 — 라이브러리를 쓰면 반드시 해제한다
|
||||
|
||||
**프로젝트 계약.** 라우트를 떠나거나 컴포넌트가 언마운트될 때 인스턴스를 정리하지 않으면 리스너와 타임라인이 누적돼 메모리와 프레임이 새어나간다.
|
||||
|
||||
| 라이브러리 | 정리 호출 |
|
||||
|---|---|
|
||||
| GSAP 타임라인·ScrollTrigger | `gsap.context(fn, scope)`로 스코프를 잡고 언마운트 시 `ctx.revert()` |
|
||||
| GSAP SplitText | `split.revert()` — 원본 DOM 구조로 되돌린다 |
|
||||
| WAAPI | `animation.cancel()` |
|
||||
| IntersectionObserver | `observer.disconnect()`(§3 코드 C·G에 이미 적용) |
|
||||
| Lenis | `lenis.destroy()` |
|
||||
|
||||
[SKILL-INTERACTION-DESIGN][SKILL-EMIL-DESIGN-ENG]
|
||||
|
||||
### WAAPI — 번들 없이 명령형 제어
|
||||
|
||||
```js
|
||||
const anim = el.animate(
|
||||
[
|
||||
{ opacity: 0, translate: '0 var(--shift-lg)' },
|
||||
{ opacity: 1, translate: '0 0' },
|
||||
],
|
||||
{ duration: 350, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', fill: 'both' }
|
||||
);
|
||||
anim.finished.then(() => { /* 완료 후 처리 */ }).catch(() => {});
|
||||
|
||||
// 언마운트하거나 다시 요청받으면 반드시 취소한다
|
||||
anim.cancel();
|
||||
```
|
||||
|
||||
### GSAP 타임라인 + ScrollTrigger 최소 골격 (바닐라 JS)
|
||||
|
||||
React를 전제하지 않아 어떤 프레임워크에도 옮길 수 있는 최소 형태다. `gsap.context()`로 스코프를 잡고 정리 함수를 반환한다.
|
||||
|
||||
```js
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
function mountHero(root) {
|
||||
const ctx = gsap.context(() => {
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: { trigger: root, start: 'top 80%', once: true },
|
||||
});
|
||||
// GSAP은 CSS 변수를 직접 읽지 않으므로 토큰 값과 같은 숫자를 쓴다
|
||||
tl.from('.hero__title', { opacity: 0, y: 24, duration: 0.6, ease: 'power2.out' }) // 0.6 = --dur-slow, 'power2.out' = --ease-out에 대응하는 GSAP 이징
|
||||
.from('.hero__sub', { opacity: 0, y: 16, duration: 0.35, ease: 'power2.out' }, '-=0.3'); // 0.35 = --dur-normal
|
||||
}, root);
|
||||
|
||||
return () => ctx.revert(); // 언마운트 시 반드시 호출
|
||||
}
|
||||
```
|
||||
|
||||
[SKILL-INTERACTION-DESIGN]
|
||||
|
||||
### Motion(Framer) 쓸 때 주의 둘
|
||||
|
||||
- **관찰 후보 — `x`·`y`·`scale` 축약 속성은 그 자체로 하드웨어 가속이 아니다.** Motion은 메인 스레드의 `requestAnimationFrame`으로 이 값들을 계산해 매 프레임 `transform` 문자열로 합성한다. 실제 GPU 합성 여부는 최종적으로 만들어지는 `transform` 값과 `will-change`(§2 표) 조합에 달려 있지, 축약 속성 문법 자체가 보장하지 않는다. [SKILL-EMIL-DESIGN-ENG]
|
||||
- **관찰 후보 — `AnimatePresence`는 `initial={false}`로 첫 렌더 진입 애니메이션을 끈다.** 마운트마다 진입 연출이 발화하면 페이지를 새로고침할 때마다 불필요하게 움직인다. 단, 스태거드 히어로처럼 `initial` 자체가 최초 1회 연출을 담당하는 곳에는 적용하지 않는다 — 새로고침에서도 올바르게 보이는지 확인한다. [SKILL-BETTER-UI]
|
||||
|
||||
### 경량 CSS 3D — WebGL 게이트를 넘지 않는 작은 회전
|
||||
|
||||
로고 궤도, 카드 뒤집기처럼 작은 3D 연출은 `three.md`의 WebGL 채택 게이트를 넘지 않고도 만들 수 있다.
|
||||
|
||||
```css
|
||||
.flip-card { perspective: 800px; }
|
||||
.flip-card__inner {
|
||||
transform-style: preserve-3d;
|
||||
transition: transform var(--dur-normal) var(--ease-soft);
|
||||
}
|
||||
.flip-card[data-flipped] .flip-card__inner { transform: rotateY(180deg); }
|
||||
.flip-card__face--back { transform: rotateY(180deg); backface-visibility: hidden; }
|
||||
```
|
||||
|
||||
[SKILL-EMIL-DESIGN-ENG]
|
||||
|
||||
### 스크롤 핸들러는 passive + 일정화한다
|
||||
|
||||
`addEventListener('scroll', ...)`이 §3 코드 C처럼 정말 필요할 때(IntersectionObserver나 scroll-driven animation으로 안 풀릴 때)만 쓴다. 매 스크롤 이벤트가 아니라 다음 페인트 한 번으로 묶는다.
|
||||
|
||||
```js
|
||||
let ticking = false;
|
||||
window.addEventListener('scroll', () => {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
requestAnimationFrame(() => { handleScroll(); ticking = false; });
|
||||
}, { passive: true });
|
||||
```
|
||||
|
||||
**프로젝트 계약.** [SKILL-INTERACTION-DESIGN]
|
||||
|
||||
### Lenis (스무스 스크롤) — 논쟁이 있다. 양쪽을 알고 결정해라
|
||||
|
||||
**반대**: 스크롤은 사용자가 기대하는 기기 고유의 물리다. 바꾸는 건 시스템 관습 침해다. 관성이 붙으면 **정확한 위치에 멈추기 어렵고** 운동 장애가 있는 사용자에게 치명적이다. 지연은 모든 사용자에게 인지 비용이다.
|
||||
|
|
@ -747,6 +975,8 @@ const progress = clamp01((start - rect.top) / travel);
|
|||
- [ ] 첫 화면 콘텐츠가 애니메이션 없이 즉시 읽힌다
|
||||
- [ ] 퇴장 duration과 stagger 총 소요가 프로젝트 계약에 맞고, 입력 지연·감소 모션을 실제로 확인했다
|
||||
- [ ] 대표 과업에서 동시 모션이 시선·클릭·키 입력을 방해하지 않는지 실제로 확인했다
|
||||
- [ ] 모션 QA를 실제로 돌렸다 — duration을 2~5배로 늘리거나 DevTools Animations 패널에서 프레임 단위로 재생해 색 전환·이징·transform-origin·속성 동기화 4항목을 확인했다 [SKILL-APPLE-DESIGN][SKILL-EMIL-DESIGN-ENG][SKILL-IMPECCABLE]
|
||||
- [ ] 실기기 또는 CPU 스로틀링(DevTools Performance)에서 한 번은 확인했다. 증거 출처(에뮬레이션/실기기, 엔진명 — Chromium≠Safari)를 `design.md`에 남겼다 [SKILL-IMPECCABLE]
|
||||
|
||||
**접근성 — 타협 없음**
|
||||
- [ ] `prefers-reduced-motion: reduce`를 켜고 실제로 확인했다. **전부 꺼지지 않고** 위치 이동·시차·루프만 죽고 페이드·상태 변화·진행 표시는 남는다
|
||||
|
|
@ -756,10 +986,13 @@ const progress = clamp01((start - rect.top) / travel);
|
|||
- [ ] 카운트업의 최종값이 스크린리더에 노출된다
|
||||
- [ ] 포커스 링이 애니메이션되지 않고 즉시 보인다. Tab 순서가 시각 순서와 일치하고, 전환 후 포커스가 새 콘텐츠를 따라간다
|
||||
- [ ] 호버에만 있는 정보가 없다 (터치·키보드에서 접근 불가)
|
||||
- [ ] 자동 재생이든 인터랙션 트리거든 초당 3회 넘게 깜빡이지 않는다 (WCAG 2.3.1). 세부 판정은 [accessibility.md](accessibility.md) [WCAG-FLASH]
|
||||
|
||||
**성능**
|
||||
- [ ] 모션 라이브러리 추가분이 `tokens.md` §5 예산 안이다. 넘겼으면 올린 이유를 명시했다
|
||||
- [ ] 상시 `will-change` 요소의 레이어 메모리와 실제 기기 성능이 프로젝트 예산 안임을 확인했다
|
||||
- [ ] 상시 `will-change` 요소의 레이어 메모리와 실제 기기 성능이 프로젝트 예산 안임을 확인했다(§2 will-change 표)
|
||||
- [ ] 드래그·제스처가 있다면 입력 경로 지연을 감사했다 — 기준과 방법은 [interaction-feel.md](interaction-feel.md)
|
||||
- [ ] GSAP·Motion·Lenis·SplitText·IntersectionObserver 등 이 문서에서 쓴 라이브러리 인스턴스가 이탈 시 정리된다(§4 정리 규율 표)
|
||||
- [ ] Lenis를 썼다면 §4의 입력·키보드·포커스·앵커·감소 모션·예산 검증을 실제로 마쳤다
|
||||
- [ ] 6단계 `design.md`에 **채택한 모션 · 안 쓰기로 한 모션과 그 이유**를 적었다
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue