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:
parent
9bcb45cbd3
commit
88c11682b1
51 changed files with 5834 additions and 2259 deletions
|
|
@ -11,11 +11,13 @@
|
|||
| duration·이징만 고르면 된다 | §1 결정 표. 여기서 끝내라 |
|
||||
| 하고 싶은 게 저비용 속성(transform/opacity) 밖이다 | §2 우회표 |
|
||||
| 스크롤에 뭔가 물려야 한다 | §3 코드 B·C — '애니메이션·스크롤 로직이 입력을 방해하지 않는다' 하드 게이트의 정답이 여기 있다 |
|
||||
| 모달·페이지 전환 / 텍스트·숫자 연출 | §3 코드 D·E / F·G |
|
||||
| 패럴랙스·고정 배경·sticky 캔버스 진행도 | §3-b → [motion-parallax.md](motion-parallax.md). 넣기로 정한 뒤에만 연다 |
|
||||
| 모달·팝오버(트리거 옆 위치 포함)·페이지 전환 / 텍스트·숫자 연출 | §3 코드 D·E / F·G |
|
||||
| 형태 자체가 잘려야 한다(clip-path) | §3 코드 C-1 |
|
||||
| 감소 모션에서 무엇을 끄고 남기나 | §3 코드 H(정본) |
|
||||
| 테마(라이트·다크) 전환이 번져 보인다 | §3 코드 I |
|
||||
| 라이브러리를 깔지 말지 / 정리 규율 | §4 |
|
||||
| 감사 직전 | §5 체크리스트 |
|
||||
| 구현을 마무리할 때 | §5 자기 점검. 납품 판정은 [preflight.md](preflight.md) X1 |
|
||||
| 드래그·제스처·촉감 피드백을 만들어야 한다 | 이 문서가 아니라 [interaction-feel.md](interaction-feel.md) — 입력 축은 전부 거기서 다룬다 |
|
||||
|
||||
---
|
||||
|
|
@ -50,7 +52,7 @@
|
|||
|
||||
**프로젝트 계약 — 흩어진 효과보다 오케스트레이션된 한 순간.** 섹션마다 다른 등장 효과를 걸거나 모든 카드에 같은 호버 트랜지션을 반복해 붙이는 것은 AI가 만든 티가 가장 잘 나는 패턴 중 하나다. 페이지 로드 시퀀스 하나, 리빌 하나로 정한 순간이 흩어진 열 개의 작은 효과보다 낫다. [SKILL-FRONTEND-DESIGN][SKILL-IMPECCABLE]
|
||||
|
||||
**프로젝트 계약 — 섹션마다 스크롤 리빌을 거는 것은 기본값이 아니다.** §3 코드 B는 "리빌을 쓰기로 했을 때"의 구현 기본값이지, 모든 섹션에 리빌을 걸라는 뜻이 아니다. 뒤의 "패럴랙스는 섹션마다 다른 말을 해야 한다"의 마지막 줄(움직이지 않는 것도 결정이다)과 같은 원칙이다.
|
||||
**프로젝트 계약 — 섹션마다 스크롤 리빌을 거는 것은 기본값이 아니다.** §3 코드 B는 "리빌을 쓰기로 했을 때"의 구현 기본값이지, 모든 섹션에 리빌을 걸라는 뜻이 아니다. [motion-parallax.md](motion-parallax.md) §1 "섹션마다 다른 말을 해야 한다"의 마지막 줄(움직이지 않는 것도 결정이다)과 같은 원칙이다.
|
||||
|
||||
다섯 번째 **"멋있어서"는 이유가 아니다.** 예외는 브랜드 표현이 브리프의 명시적 요구일 때뿐이고, 그때도 (a) 사용자가 스크롤·호버로 통제하거나 (b) 1회성이며 (c) `prefers-reduced-motion`에서 완전히 사라져야 한다.
|
||||
|
||||
|
|
@ -133,6 +135,7 @@
|
|||
1. **구조를 바꾼다.** 정말 인라인으로 자라야 하는가? 모달·팝오버로 만들면 높이 문제가 사라지고 스케일+페이드로 끝난다
|
||||
2. **높이는 즉시 확정하고 내용만 전환한다.** 래퍼를 `display: grid; grid-template-rows: 0fr` ↔ `1fr`로 만들고 자식의 `opacity`·`translate`를 전환하는 방식을 먼저 비교한다. 실제 레이아웃 이동과 입력 간섭을 확인한다
|
||||
3. `grid-template-rows` 전환은 레이아웃 비용이 생길 수 있다. 필요하면 다른 대안과 비교하고 비용·입력 간섭·감소 모션·예산을 실제로 측정해 `design.md`에 근거를 남긴다
|
||||
4. `height: auto`까지의 전환이 꼭 필요하면 `:root { interpolate-size: allow-keywords; }`를 점진 향상으로 둔다. Chromium 전용이라(확인 2026-10-06) 다른 브라우저에서는 전환 없이 즉시 바뀐다 — 그 상태가 계약에 맞는지 확인한다 [WEB-BASELINE]
|
||||
|
||||
**도구 한계와 저비용 선택**
|
||||
|
||||
|
|
@ -163,11 +166,12 @@
|
|||
|---|---|
|
||||
| 호버·포커스·프레스 상태 전환 | 속도를 이어받는 인터럽트(드래그 던지기) |
|
||||
| 모달·팝오버·툴팁 진입/퇴장 (`@starting-style`) | 복잡한 타임라인 시퀀싱 (A 끝나고 B, B 중간에 C) |
|
||||
| 팝오버·툴팁·메뉴를 트리거 옆에 붙이기 (anchor positioning, 코드 D) | 구형 브라우저까지 충돌 회피를 보장해야 하는 위치 계산 |
|
||||
| 스크롤 리빌·진행 바·시차·스티키 헤더 | 스크롤 **핀 고정 + 다단계 시퀀스** |
|
||||
| 페이지 전환 (View Transitions) | 레이아웃 변화 FLIP (그리드 → 리스트) |
|
||||
| 무한 루프, `linear()` 스프링 근사 | 텍스트 자동 분해, SVG 패스 모핑, 포인터 추종 |
|
||||
|
||||
**지원 현황(확인 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]
|
||||
**지원 현황(확인 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). Firefox가 켜는 날을 대비해 예시는 모두 길이 `1ms`를 적는다(코드 B 주석). cross-document View Transitions도 Baseline Limited(Firefox 미지원)다. 두 기능 모두 `@supports` 가드가 필수이고, 가드 **안의 초기 상태가 실제로 콘텐츠를 보이게 하는지**를 반드시 확인한다(아래 "가장 흔한 사고"). [WEB-BASELINE]
|
||||
|
||||
> **가장 흔한 사고**: 미지원 브라우저에서 `opacity: 0`이 남아 콘텐츠가 영영 안 보이는 것. **초기 상태를 `@supports` 블록 *안에* 넣어라.** 밖에 두면 Firefox에서 백지가 된다.
|
||||
|
||||
|
|
@ -179,7 +183,7 @@
|
|||
.btn {
|
||||
position: relative; isolation: isolate;
|
||||
background: var(--accent); color: var(--accent-ink);
|
||||
border: 0; padding: 0.75rem 1.5rem; border-radius: var(--radius-md);
|
||||
border: 0; padding: 0.75rem 1.5rem; border-radius: var(--radius-control);
|
||||
transition: translate var(--dur-instant) var(--ease-out),
|
||||
scale var(--dur-instant) var(--ease-out);
|
||||
}
|
||||
|
|
@ -218,11 +222,12 @@
|
|||
/* 초기 상태를 @supports 안에 둔다 — 미지원 브라우저에서는 그냥 보인다 */
|
||||
@supports (animation-timeline: view()) {
|
||||
.reveal {
|
||||
animation: reveal-in linear both; /* both 필수. 없으면 범위 밖에서 깜빡인다 */
|
||||
animation-timeline: view();
|
||||
animation-range: entry 15% cover 35%;
|
||||
animation-delay: calc(var(--i, 0) * var(--stagger));
|
||||
/* animation-duration은 auto가 기본. 초를 넣으면 무시되거나 오작동한다 */
|
||||
/* both 필수. 없으면 범위 밖에서 깜빡인다. 길이는 진행에 쓰이지 않지만 1ms를 적는다 —
|
||||
Firefox는 0이 아닌 duration이 있어야 애니메이션을 적용한다(MDN) */
|
||||
animation: reveal-in 1ms linear both;
|
||||
animation-timeline: view(); /* 축약형 뒤에 둔다. 앞에 두면 축약형이 auto로 되돌린다 */
|
||||
/* 스크롤 타임라인에서 animation-delay는 무시된다. 스태거는 구간을 밀어서 만든다 */
|
||||
animation-range: entry calc(15% + var(--i, 0) * 4%) cover calc(35% + var(--i, 0) * 4%);
|
||||
}
|
||||
@keyframes reveal-in {
|
||||
from { opacity: 0; translate: 0 var(--shift-lg); }
|
||||
|
|
@ -234,7 +239,7 @@
|
|||
.progress {
|
||||
position: fixed; inset-block-start: 0; inset-inline: 0; height: 3px;
|
||||
background: var(--accent); transform-origin: left; scale: 0 1;
|
||||
animation: progress-fill linear both;
|
||||
animation: progress-fill 1ms linear both;
|
||||
animation-timeline: scroll(root block);
|
||||
}
|
||||
@keyframes progress-fill { to { scale: 1 1; } }
|
||||
|
|
@ -332,7 +337,7 @@ if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|||
|
||||
```css
|
||||
.tip {
|
||||
margin: 0; padding: 0.75rem 1rem; border: 0; border-radius: var(--radius-md);
|
||||
padding: 0.75rem 1rem; border: 0; border-radius: var(--radius-surface);
|
||||
background: var(--surface-raised); color: var(--ink); max-width: 18rem;
|
||||
/* 닫힌 상태 = 퇴장의 종착점 */
|
||||
opacity: 0; translate: 0 var(--shift-sm); scale: 0.96;
|
||||
|
|
@ -340,7 +345,16 @@ if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|||
translate calc(var(--dur-quick) * 0.65) var(--ease-in),
|
||||
scale calc(var(--dur-quick) * 0.65) var(--ease-in),
|
||||
display calc(var(--dur-quick) * 0.65) allow-discrete,
|
||||
overlay calc(var(--dur-quick) * 0.65) allow-discrete;
|
||||
overlay calc(var(--dur-quick) * 0.65) allow-discrete; /* overlay 전환은 Chromium 전용. 다른 엔진의 퇴장은 렌더로 확인 */
|
||||
}
|
||||
/* 위치: 트리거 아래에 붙이고, 아래가 모자라면 위로 뒤집는다. popovertarget이 암시적 앵커를 만든다.
|
||||
미지원 브라우저에서는 UA 기본값(뷰포트 중앙)으로 뜬다 — 읽을 수 있다 */
|
||||
@supports (position-area: block-end) {
|
||||
.tip {
|
||||
margin: 0; margin-block-start: var(--space-2);
|
||||
position-area: block-end;
|
||||
position-try-fallbacks: flip-block;
|
||||
}
|
||||
}
|
||||
.tip:popover-open {
|
||||
opacity: 1; translate: 0 0; scale: 1;
|
||||
|
|
@ -358,6 +372,8 @@ if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|||
|
||||
**팝오버는 클릭한 곳에서 나와야 한다.** 화면 정중앙에서 페이드인하는 팝오버는 "어디서 왔는지"를 버리는 것이다. `transform-origin`을 트리거 위치로 잡아라. **모달은 예외다** — 뷰포트 중앙에 고정되므로 `transform-origin: center`를 유지한다. [SKILL-EMIL-DESIGN-ENG]
|
||||
|
||||
**위치는 CSS anchor positioning으로 잡는다.** 팝오버의 UA 기본 위치는 뷰포트 중앙이라, `margin: 0`만 주면 왼쪽 위 모서리에 붙는다. 핵심 속성(`position-area`·`position-try-fallbacks`·암시적 앵커)은 세 엔진이 모두 출하했지만(Chrome 133, Safari 26, 마지막인 Firefox 147이 2026-01-13 — 확인 2026-10-06) 아직 Widely가 아니므로 `@supports` 안에 두고, 밖의 기본 위치가 읽히는지 확인한다. 오래된 브라우저까지 트리거 옆에 붙여야 하는 계약이면 위치 계산 라이브러리를 쓴다. [WEB-BASELINE]
|
||||
|
||||
**관찰 후보 — 툴팁 그룹은 첫 번째만 지연한다.** 같은 그룹의 인접 트리거 사이를 연속으로 오갈 때마다 매번 딜레이·트랜지션을 다시 타면 굼떠 보인다. 첫 툴팁이 열린 뒤 일정 시간 안에 그룹 내 다른 트리거로 옮기면 지연·애니메이션 없이 즉시 연다.
|
||||
|
||||
```js
|
||||
|
|
@ -423,11 +439,13 @@ filterBtn.addEventListener('click', () => transition(() => renderList(filterBtn.
|
|||
}
|
||||
```
|
||||
|
||||
문서 간 전환(MPA)은 양쪽 문서에 `@view-transition { navigation: auto; }`를 선언하면 켜진다. Firefox 미지원이라 **점진 향상 전용**이다.
|
||||
**자리만 바꾸는 요소(필터·정렬)는 이름을 일일이 만들지 않는다.** `.card { view-transition-name: match-element; view-transition-class: card; }`로 두고 `::view-transition-group(.card)`에 타이밍을 준다(Chrome 137·Safari 18.4·Firefox 144 — 확인 2026-10-06). 목록 → 상세처럼 **다른 요소**끼리 잇는 전환은 위 코드처럼 양쪽에 같은 고유 이름이 필요하다. React 19.3 이상이면 `<ViewTransition>` 컴포넌트를 먼저 검토한다. [WEB-BASELINE]
|
||||
|
||||
문서 간 전환(MPA)은 양쪽 문서에 `@view-transition { navigation: auto; }`를 선언하면 켜진다. 같은 출처 이동에만 동작하고 Firefox 미지원이라 **점진 향상 전용**이다. Speculation Rules([tokens.md](tokens.md) §5)와 함께 쓰면 전환 직전의 로딩 공백이 줄어든다.
|
||||
|
||||
### 코드 F — 텍스트 등장 (줄 단위 스플릿·스태거)
|
||||
|
||||
> 언제: 히어로 헤드라인 하나. **페이지에 한 번만.** 두 곳에서 텍스트가 날아오면 둘 다 죽는다.
|
||||
> 언제: 히어로 헤드라인 하나. **페이지에 한 번만.** 두 곳에서 텍스트가 날아오면 둘 다 죽는다. 히어로 등장은 스크롤이 아니라 로드 시점의 일이므로 시간 기반으로 건다 — 첫 화면 요소는 `view()`의 `entry` 구간이 이미 지나 있다([motion-parallax.md](motion-parallax.md) §9).
|
||||
|
||||
**규칙 셋. 어기면 SEO와 스크린리더가 동시에 깨진다.**
|
||||
1. **원문 텍스트를 DOM에 그대로 남긴다.** 크롤러가 읽는 것은 마크업이지 렌더 결과가 아니다
|
||||
|
|
@ -447,24 +465,23 @@ filterBtn.addEventListener('click', () => transition(() => renderList(filterBtn.
|
|||
형태 자체를 잘라야 하는 경우만 코드 C-1(clip-path)을 쓴다 */
|
||||
.split__line > span { display: block; translate: 0 0; }
|
||||
|
||||
@supports (animation-timeline: view()) {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.split__line > span {
|
||||
animation: line-up linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 10% entry 70%;
|
||||
animation-delay: calc(var(--i) * var(--stagger));
|
||||
}
|
||||
@keyframes line-up {
|
||||
from { translate: 0 110%; opacity: 0; }
|
||||
to { translate: 0 0; opacity: 1; }
|
||||
animation: line-up var(--dur-slow) var(--ease-out) both;
|
||||
animation-delay: calc(var(--i) * var(--stagger)); /* 시간 기반이라 지연이 동작한다 */
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .split__line > span { animation: none; opacity: 1; } }
|
||||
@keyframes line-up {
|
||||
from { translate: 0 110%; opacity: 0; }
|
||||
to { translate: 0 0; opacity: 1; }
|
||||
}
|
||||
```
|
||||
|
||||
첫 화면 밖 헤드라인에 줄 리빌을 걸 때만 코드 B의 `view()` 방식을 쓴다. 그때 스태거는 `animation-delay`가 아니라 `animation-range`를 줄마다 밀어서 만든다.
|
||||
|
||||
**자동 분해가 꼭 필요하면** 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]
|
||||
**관찰 후보 — 시작값. 단어 단위 스플릿도 옵션이다.** 줄 단위 대신 단어 단위(스태거 약 80ms 시작값)로 쪼개는 스타일도 있다 — 제목처럼 짧고 리듬을 강조하고 싶을 때다. `--stagger` 토큰(60ms, 위 §1)은 그대로 유지하고, 단어 개수가 많으면 위 §1의 스태거 예산식으로 총 소요 상한을 확인한다. [SKILL-BETTER-UI]
|
||||
|
||||
### 코드 G — 숫자 카운트업 (자릿수 스트립, transform만)
|
||||
|
||||
|
|
@ -495,12 +512,23 @@ filterBtn.addEventListener('click', () => transition(() => renderList(filterBtn.
|
|||
```
|
||||
|
||||
```js
|
||||
const DIGITS = '0123456789'.split('').map((n) => `<span>${n}</span>`).join('');
|
||||
// 값은 CMS·사용자 입력에서 올 수 있다. 문자열 보간으로 innerHTML을 만들지 않고
|
||||
// 숫자만 통과시킨 뒤 DOM API로 조립한다(속성 탈출 XSS 차단)
|
||||
function span(cls) { const el = document.createElement('span'); if (cls) el.className = cls; return el; }
|
||||
|
||||
document.querySelectorAll('.odo').forEach((odo) => {
|
||||
odo.innerHTML = odo.dataset.value.split('').map((d, i) =>
|
||||
`<span class="odo__col"><span class="odo__strip" style="--d:${d};--i:${i}">${DIGITS}</span></span>`
|
||||
).join('');
|
||||
const value = odo.dataset.value ?? '';
|
||||
if (!/^\d{1,12}$/.test(value)) return; // 숫자가 아니면 연출하지 않는다 — .sr-only 값이 그대로 남는다
|
||||
const cols = [...value].map((d, i) => {
|
||||
const col = span('odo__col');
|
||||
const strip = span('odo__strip');
|
||||
strip.style.setProperty('--d', d);
|
||||
strip.style.setProperty('--i', String(i));
|
||||
for (let n = 0; n <= 9; n++) { const digit = span(); digit.textContent = String(n); strip.append(digit); }
|
||||
col.append(strip);
|
||||
return col;
|
||||
});
|
||||
odo.replaceChildren(...cols);
|
||||
|
||||
const io = new IntersectionObserver(([entry]) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
|
|
@ -524,7 +552,7 @@ document.querySelectorAll('.odo').forEach((odo) => {
|
|||
| 자동재생 루프, 마퀴 | 진행 표시·로딩(정보다) | 스크롤 리빌 → 즉시 표시 |
|
||||
| stagger(순차 지연) | 포커스 링 | 카운트업 → 최종값 즉시 |
|
||||
|
||||
WCAG 2.3.3은 **인터랙션으로 촉발된 모션 애니메이션을 끌 수 있어야** 한다고 요구한다. 색·투명도·블러만 바뀌는 것은 해당하지 않는다 — **위치·크기·형태가 바뀌는 것**이 대상이다. 자동 재생되는 것은 별도로 2.2.2(5초 넘게 움직이면 정지 수단 필요)에 걸린다. 근거는 취향이 아니라 **어지럼·구역질·두통이라는 실제 신체 반응**이다.
|
||||
WCAG 2.3.3 Animation from Interactions(**AAA**)는 **인터랙션으로 촉발된 모션 애니메이션을 끌 수 있어야** 한다고 요구한다. 색·투명도·블러만 바뀌는 것은 해당하지 않는다 — **위치·크기·형태가 바뀌는 것**이 대상이다. AAA라서 AA 준수 판정의 근거는 아니다. designpaca는 전정기관 장애(어지럼·구역질·두통이라는 실제 신체 반응) 근거로 감소 모션 대응을 **정책상 하드 게이트**로 올린다 — 보고서에는 "WCAG AA 위반"이 아니라 "designpaca 정책(WCAG 2.3.3 AAA 근거)"으로 표기한다. 자동 재생되는 것은 별도로 2.2.2 Pause, Stop, Hide(**A**, 5초 넘게 움직이면 정지 수단 필요)로 판정한다.
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
|
@ -540,7 +568,17 @@ WCAG 2.3.3은 **인터랙션으로 촉발된 모션 애니메이션을 끌 수
|
|||
}
|
||||
```
|
||||
|
||||
JS 쪽 판정도 한 곳에 모은다. 설정이 도중에 바뀌면 반영한다: `matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => location.reload())`.
|
||||
JS 쪽 판정도 한 곳에 모은다. 설정이 도중에 바뀌면 **새로고침하지 않고** 상태만 갱신한다 — `location.reload()`는 입력 중인 폼·스크롤 위치·열린 패널을 잃게 한다.
|
||||
|
||||
```js
|
||||
const mq = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const applyMotionPref = () => {
|
||||
document.documentElement.toggleAttribute('data-reduced-motion', mq.matches);
|
||||
// 진행 중인 JS 애니메이션(WAAPI·GSAP 등)은 여기서 끝값으로 보내거나 취소한다
|
||||
};
|
||||
applyMotionPref();
|
||||
mq.addEventListener('change', applyMotionPref);
|
||||
```
|
||||
|
||||
### 코드 I — 테마 전환 시 트랜지션 억제
|
||||
|
||||
|
|
@ -567,262 +605,9 @@ function setTheme(next) {
|
|||
|
||||
---
|
||||
|
||||
## 패럴랙스는 섹션마다 다른 말을 해야 한다
|
||||
## 3-b. 패럴랙스·스크롤 연동 깊이 → [motion-parallax.md](motion-parallax.md)
|
||||
|
||||
같은 패럴랙스를 모든 섹션에 똑같이 걸면 그건 문법이 아니라 벽지다.
|
||||
**그 섹션이 주장하는 것이 무엇인지 먼저 말하고, 그 주장을 움직임으로 옮겨라.**
|
||||
|
||||
실측 사례 — designpaca 소개 페이지의 섹션별 배정:
|
||||
|
||||
| 섹션 | 주장 | 움직임 | 왜 |
|
||||
|---|---|---|---|
|
||||
| 히어로 | "결정하지 않으면 기본값이 나온다" | 글자 ↑ / 배경 ↓ (**반대 방향**) | 결정이 떠오르고 기본값이 가라앉는다. 같은 방향이면 그냥 느린 스크롤로 읽힌다 |
|
||||
| 파이프라인 | 단계를 밟으면 결과가 나온다 | 단계명 **고정**, 산출물만 지연 | 옆의 것이 고정이어야 "저것 때문에 이것"이 보인다. 둘 다 움직이면 인과가 사라진다 |
|
||||
| 사례 | 서로 다른 두 결과물 | 두 판이 다른 속도로 어긋남 | 같은 스킬에서 다른 것이 나온다 |
|
||||
| 재료 | 표면은 한 겹이 아니다 | 타일이 서로 다른 깊이 | 주장 자체가 레이어다 |
|
||||
| 프리플라이트 | 시간순 기록 | 뒤 항목일수록 더 늦게 따라옴 | 로그는 흐른다 |
|
||||
| 수치 | 공개한 값은 검증 가능하다 | **없음** | 숫자가 흔들리면 값도 흔들려 보인다. 계기판의 바늘은 떨지 않는다 |
|
||||
|
||||
**마지막 줄이 가장 중요하다.** 움직이지 않는 것도 결정이다.
|
||||
안 움직이는 섹션이 하나도 없으면 움직임에 의미가 없다는 뜻이고,
|
||||
`design.md` 에 "여기는 의도적으로 정지"라고 적어두지 않으면 다음 사람이 "빠뜨린 것"으로 보고 채운다.
|
||||
|
||||
### 패럴랙스의 원형은 "고정"이다
|
||||
|
||||
**배경이 뷰포트에 멈춰 있고 콘텐츠만 그 위를 지나갈 때 원경이 된다.**
|
||||
느리게 같이 움직이는 것으로는 그 인상이 안 나온다 — 속도만 다를 뿐 방향이 같기 때문이다.
|
||||
"패럴랙스를 넣었는데 티가 안 난다"의 절반은 이것이다.
|
||||
|
||||
**`background-attachment: fixed` 는 쓰지 마라.** iOS Safari 가 GPU 메모리 때문에 그것을
|
||||
throttle 해서 스크롤 중 배경이 튀거나 리셋된다.
|
||||
|
||||
대신 **부모를 `position: fixed` 의 컨테이닝 블록으로 만든다**:
|
||||
|
||||
```css
|
||||
.section { position: relative; clip-path: inset(0); } /* 컨테이닝 블록 + 클립 */
|
||||
.section > .bg { position: fixed; inset: 0; z-index: -1; }
|
||||
.section > .bg img { width: 100%; height: 100%; object-fit: cover; }
|
||||
```
|
||||
|
||||
`clip-path` 는 두 가지를 동시에 한다 — 배경을 섹션 밖으로 못 나가게 자르고,
|
||||
fixed 자식의 기준을 뷰포트가 아니라 **자기 자신**으로 잡아준다.
|
||||
그래서 배경이 화면에 고정되면서도 그 섹션 안에서만 보인다.
|
||||
|
||||
**두 가지 대가가 있다. 알고 써라.**
|
||||
|
||||
1. **조상에 `transform`·`filter`·`mask` 를 추가하면 즉시 깨진다.** 그것들도 컨테이닝 블록을
|
||||
만들기 때문에 fixed 가 섹션 기준이 되어 고정이 사라진다. 위아래 페이드 마스크를 얹고 싶어도
|
||||
못 얹는다 — 경계는 clip 으로 딱 잘리는 것을 받아들여야 한다
|
||||
2. `prefers-reduced-motion` 에서는 `position: absolute` 로 되돌려라. 고정 배경은
|
||||
스크롤 중 시각 부하가 크다
|
||||
|
||||
**전부 고정하지는 마라.** 가까이 있어야 할 재질(종이·질감)까지 원경으로 두면 거짓말이 된다.
|
||||
원경은 `fixed`, 중경은 아래의 비율 이동, 근경은 콘텐츠와 함께.
|
||||
|
||||
### 이동량은 절대값이 아니라 컨테이너 대비 비율이다
|
||||
|
||||
**패럴랙스가 "안 보인다"의 대부분은 이동량이 모자란 것이다.**
|
||||
|
||||
공식은 하나다. **배경을 컨테이너보다 크게 만들고, 그 초과분 전체를 움직인다.**
|
||||
|
||||
```
|
||||
이미지 높이 = 컨테이너 × (1 + depth)
|
||||
이동량 = 컨테이너 × depth
|
||||
= 이미지 높이의 depth / (1 + depth)
|
||||
```
|
||||
|
||||
`depth: 0.45` 면 이미지는 145%, 이동은 이미지 자기 높이의 **31%**.
|
||||
섹션이 2000px 이면 **900px** 을 움직인다.
|
||||
|
||||
```css
|
||||
.bg img {
|
||||
height: 145%; /* 1 + depth */
|
||||
animation: bg-par linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: cover 0% cover 100%;
|
||||
}
|
||||
@keyframes bg-par {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(-31.03%); } /* depth / (1 + depth) */
|
||||
}
|
||||
```
|
||||
|
||||
`translateY(%)` 는 **자기 크기 기준**이라 컨테이너 높이를 몰라도 정확히 맞는다.
|
||||
|
||||
**실측 사례**: 여유를 28% 잡아놓고 `±46px`(총 92px)만 움직였더니 스크롤 1800px 구간에서
|
||||
이동 비율이 5% 였다. 아무도 시차를 느끼지 못했다. 같은 구간에서 **374px** 로 올리자 보였다.
|
||||
CSS-Tricks 의 예제도 `background-position: bottom 0px → bottom -400px`, 즉 400px 이다.
|
||||
|
||||
> 참고: `background-position` 을 애니메이션하는 방법도 있다(CSS-Tricks). 다만 그러면
|
||||
> `<img loading="lazy">` 의 지연 로딩과 반응형 `srcset` 을 잃는다. 무거운 사진이면 `<img>` 를
|
||||
> 절대배치하고 `transform` 을 쓰는 쪽이 낫다.
|
||||
|
||||
### `overflow: hidden` 이 view() 타임라인을 죽인다
|
||||
|
||||
**이것 하나에 패럴랙스가 통째로 멈춘다. 그리고 조용히 멈춘다.**
|
||||
|
||||
`overflow: hidden` 은 **스크롤 컨테이너를 만든다.** 그러면 그 안에 있는 자식의
|
||||
`animation-timeline: view()` 는 문서가 아니라 **그 상자**를 기준으로 잡힌다.
|
||||
자식은 상자 안에서 위치가 변하지 않으므로 진행도가 고정되고, 애니메이션은
|
||||
어중간한 한 프레임에서 굳는다.
|
||||
|
||||
- 콘솔 에러 없음. `animation-name`·`animation-timeline` computed 값도 전부 정상
|
||||
- `getComputedStyle(el).transform` 을 두 스크롤 위치에서 재봐야 알 수 있다. **실측에서 이동 0px**
|
||||
|
||||
**잘라내야 한다면 `overflow: clip` 을 쓰거나 조상 섹션에서 처리해라.**
|
||||
`clip` 은 스크롤 컨테이너를 만들지 않는다.
|
||||
|
||||
### 어두운 배경 위 광원 이미지는 opacity 로 누르지 마라
|
||||
|
||||
`opacity` 는 이미지 전체를 균일하게 죽인다 — 검정도, 빛도 같이. 그래서 어두운 광원 사진을
|
||||
`opacity: 0.5` 로 깔면 **있는지조차 안 보인다.**
|
||||
|
||||
```css
|
||||
.bg img { mix-blend-mode: screen; opacity: 0.9; }
|
||||
```
|
||||
|
||||
`screen` 은 **검정을 투명하게 만들고 밝은 곳만 더한다.** 그래서 불투명도를 높게 유지해도
|
||||
본문 대비를 해치지 않는다. 밝은 이미지(종이·질감)는 반대로 `normal` 이 맞다.
|
||||
|
||||
**생성형 이미지를 배경으로 쓸 때 특히 조심해라.** 프롬프트에 "본문 뒤에 깔리니 어둡게"를
|
||||
너무 강하게 넣으면 안전한 이미지가 아니라 **없는 이미지**가 나온다.
|
||||
대비는 합성 방식으로 풀고, 이미지 자체는 볼 수 있게 만들어라.
|
||||
|
||||
### sticky 캔버스의 진행도는 섹션 높이가 아니다
|
||||
|
||||
캔버스를 `position: sticky; top: 0; height: 100dvh` 로 붙여두고 섹션이 지나가게 하는 패턴에서,
|
||||
진행도를 **섹션 높이로 나누면 안 된다.**
|
||||
|
||||
캔버스가 실제로 화면에 머무는 거리는 **(섹션 높이 − 뷰포트 높이)** 다.
|
||||
섹션 높이를 쓰면 캔버스가 위로 사라진 뒤에도 진행이 남아 **끝에 영영 도달하지 못한다.**
|
||||
|
||||
```js
|
||||
const travel = Math.max(section.getBoundingClientRect().height - innerHeight, 1);
|
||||
const progress = clamp(-section.getBoundingClientRect().top / travel, 0, 1);
|
||||
```
|
||||
|
||||
실측: 섹션 1493px · 뷰포트 900px 에서 섹션 높이로 나눴더니 6단계 중 **3단계에서 멈췄다.**
|
||||
`height - innerHeight` 로 바꾸자 6단계를 다 돌았다.
|
||||
|
||||
그리고 진행도의 기준 요소는 **캔버스가 아니라 섹션**이다. sticky/fixed 캔버스는 rect 가
|
||||
변하지 않으므로 자기 rect 로 재면 진행도가 고정된다.
|
||||
|
||||
### 배경도 한 섹션에 하나다
|
||||
|
||||
CSS 격자와 사진을 같이 깔았다가 걷어냈다. 두 배경이 서로 경쟁했고,
|
||||
격자는 배경이라기보다 **표**로 읽혔다. 앞에서 "한 층은 고정"이라고 한 것과 같은 이야기다 —
|
||||
층을 늘리는 것과 층이 싸우게 두는 것은 다르다.
|
||||
|
||||
### 패럴랙스에는 기준면이 필요하다
|
||||
|
||||
**배경이 단색이면 패럴랙스는 성립하지 않는다.** 앞의 것이 몇 px 어긋나 봐야
|
||||
비교할 대상이 없어서 아무도 못 느낀다. 시차는 두 층 사이에서만 보인다.
|
||||
|
||||
그렇다고 배경 이미지를 사 오지 마라. **그 페이지가 이미 주장하는 것을 배경으로 그려라.**
|
||||
|
||||
| 페이지가 주장하는 것 | 배경으로 그릴 것 |
|
||||
|---|---|
|
||||
| 값이 토큰 체계에서 나온다 | **간격 눈금 격자**. 셀 크기를 실제 space 토큰으로 |
|
||||
| 재질·표면이 주제다 | 광원 블롭. 섹션마다 자리를 옮긴다 |
|
||||
| 데이터·계측이 주제다 | 눈금, 축, 등고선 |
|
||||
| 문서·아카이브다 | 괘선, 여백선 |
|
||||
|
||||
격자를 그릴 때 두 가지를 지켜라.
|
||||
|
||||
1. **가장자리를 마스크로 지운다.** 화면 끝까지 격자가 가면 배경이 아니라 **표**가 된다
|
||||
2. **배경이 가장 적게 움직인다.** 앞의 것보다 크게 움직이면 앞으로 튀어나와 보인다
|
||||
|
||||
```css
|
||||
.bg-grid::before {
|
||||
content: ""; position: absolute; inset: -12% 0; z-index: -1;
|
||||
background-image:
|
||||
repeating-linear-gradient(to right, var(--line-strong) 0 1px, transparent 1px var(--grid-cell)),
|
||||
repeating-linear-gradient(to bottom, var(--line-strong) 0 1px, transparent 1px var(--grid-cell));
|
||||
mask-image: radial-gradient(135% 95% at 50% 45%, black 22%, transparent 92%);
|
||||
}
|
||||
```
|
||||
|
||||
세 층이 되면 시차가 확실해진다 — 실측: 배경 격자 **+14.9px**, 산출물 **-14px**, 단계명 **0**.
|
||||
**한 층은 반드시 고정이어야 한다.** 전부 움직이면 기준이 없어 그냥 흔들리는 페이지다.
|
||||
|
||||
### 구현은 scroll-driven animation 으로
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@supports (animation-timeline: view()) {
|
||||
.par-slow, .par-fast, .par-lag {
|
||||
animation: par linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: cover 0% cover 100%;
|
||||
}
|
||||
@keyframes par {
|
||||
from { transform: translate3d(0, var(--par-from), 0); }
|
||||
to { transform: translate3d(0, var(--par-to), 0); }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**두 가지를 지켜라.**
|
||||
|
||||
1. **`transform` 만 애니메이션한다.** `opacity` 를 걸면 범위 끝에 도달하지 못한 요소가
|
||||
영영 투명하게 남는다 — 지원 안 되는 브라우저에서는 제자리에 그대로 있을 뿐이지만,
|
||||
`opacity` 는 콘텐츠를 지운다. 실측에서 6단계 중 3개가 그렇게 사라졌다.
|
||||
2. **페이지 최상단 요소는 `view()` 가 아니라 `scroll()` 을 써라.** 히어로는 처음부터 보이므로
|
||||
`entry` 구간이 이미 지나 있어 시작점이 확정되지 않는다.
|
||||
`animation-timeline: scroll(root block); animation-range: 0 92vh;`
|
||||
|
||||
새 클래스를 만들 때 **애니메이션 셀렉터에 추가하는 것을 잊지 마라.** 변수만 정의하면
|
||||
아무 일도 일어나지 않고, 조용히 실패해서 검출도 안 된다.
|
||||
|
||||
---
|
||||
|
||||
### 읽기 간격과 스크롤 구간은 같은 손잡이가 아니다
|
||||
|
||||
sticky 캔버스의 진행도는 `섹션 높이 − 뷰포트` 다. 그래서 "스크롤 구간이 짧다" 는
|
||||
문제를 만나면 **목록 항목의 간격을 벌려 섹션을 늘리고 싶어진다.** 실측에서 그렇게 했다.
|
||||
|
||||
- `--space-4`(24px): 섹션 1493px → sticky 이동 593px. 조금만 굴려도 여섯 단계를 다 지나침
|
||||
- `--space-7`(96px): 게이트당 300px 확보. 대신 항목 사이가 **224px** 로 벌어져
|
||||
여섯 개가 한 목록으로 안 읽힘. 사용자 지적: "각 아이템별로 마진 공간이 너무 크다"
|
||||
|
||||
**하나의 값으로 두 가지를 맞추려 하면 둘 다 나빠진다.** 나눠라.
|
||||
|
||||
```css
|
||||
.stop { padding-block: var(--stop-pad); } /* 읽기 리듬 — 눈으로 정한다 */
|
||||
.after { padding-bottom: 62dvh; } /* 스크롤 구간 — 계산으로 정한다 */
|
||||
```
|
||||
|
||||
판정 기준: **게이트 하나당 200px 이상**. 휠 한 번이 100~150px 이므로
|
||||
그 아래면 "조금만 스크롤해도 다 지나가는" 과민한 반응이 된다.
|
||||
|
||||
### 스크롤 구간이 모자라면 여백이 아니라 **진행도 공식**을 고쳐라
|
||||
|
||||
위 문제를 처음에는 목록 뒤에 여백을 넣어 풀었다. `padding-bottom: 62dvh`.
|
||||
계산은 맞았고 게이트는 끝까지 돌았는데, 화면 절반이 넘는 빈 공간이 남았다.
|
||||
사용자가 본 것은 그 빈칸이었다 — "어마어마하게 큰 이상한 게 있는데요?"
|
||||
|
||||
**그 여백은 콘텐츠가 아니라 스크롤 연료였다.** 원인은 진행도를 섹션 높이로
|
||||
잰 것이다. 그러면 진행 구간이 섹션 길이에 묶이고, 구간을 늘리는 유일한 방법이
|
||||
섹션을 늘리는 것뿐이다.
|
||||
|
||||
진행도를 **읽는 대상이 화면을 지나는 동안**으로 바꾸면 여백이 필요 없어진다.
|
||||
|
||||
```js
|
||||
// 섹션이 아니라 목록에서 읽는다. 게이트가 도는 구간과 읽는 구간이 같아진다.
|
||||
const rect = list.getBoundingClientRect();
|
||||
const start = innerHeight * 0.7; // 목록 위쪽이 여기 오면 진행 0
|
||||
const end = innerHeight * 0.3; // 목록 아래쪽이 여기를 빠져나가면 진행 1
|
||||
const travel = rect.height + (start - end);
|
||||
const progress = clamp01((start - rect.top) / travel);
|
||||
```
|
||||
|
||||
`(start - end)` 만큼이 공짜로 얻는 구간이다. 뷰포트의 40% 를 쓰면
|
||||
목록 963px 짜리가 1323px 구간이 되고, 게이트 6 개면 하나당 220px 이다.
|
||||
**여백 558px 을 지우고도 기준을 넘겼다.**
|
||||
|
||||
> 확인할 것: sticky 요소가 `progress === 1` 에 닿기 전에 사라지지 않아야 한다.
|
||||
> 목록 아래쪽이 화면 30% 에 있을 때 섹션 하단이 아직 뷰포트 안이면 안전하다.
|
||||
패럴랙스, 고정 배경, sticky 캔버스의 진행도 계산은 분량이 커서 [motion-parallax.md](motion-parallax.md)로 분리했다. **넣기로 정한 뒤에만** 연다. 섹션별 주장과 움직임 대응, 고정 원형, 이동량 공식, `overflow: hidden` 함정, 진행도 공식이 거기 있다.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -957,43 +742,37 @@ window.addEventListener('scroll', () => {
|
|||
|
||||
---
|
||||
|
||||
## 5. 체크리스트 — 5단계 프리플라이트에서 그대로 돌린다
|
||||
## 5. 체크리스트 (구현 중 자기 점검 — 납품 판정은 preflight.md X1)
|
||||
|
||||
**하나라도 실패하면 4-4 복귀다.**
|
||||
모션을 짜는 동안 확인한다. 납품 판정은 [preflight.md](preflight.md) X1이 하고, 이펙트를 끈 상태의 완성은 M-A, 감소 모션은 A7, 5초 넘는 자동 재생 정지는 A8, 호버 전용 정보는 W13, 깜빡임은 A19, 포커스 링·Tab 순서는 A1·A2, 모션 라이브러리 추가분 예산은 P2가 판정한다. 시각 회귀 캡처는 모션 완료를 관측한 뒤 찍는다([audit-gate.md](audit-gate.md) 렌더 측정 계약, [audit-gate-rules.md](audit-gate-rules.md) G10).
|
||||
|
||||
**게이트 직결**
|
||||
- [ ] `transition`·`@keyframes`를 전부 grep하고, 각 속성의 목적·비용 측정·입력 간섭·감소 모션 경로를 기록했다. `opacity`·`transform`·`translate`·`rotate`·`scale` 이외 속성은 프로젝트 예산과 실제 렌더 결과로 정당화했다
|
||||
- [ ] `background-color`·`box-shadow`·`filter`·`height`·`width`·`top`·`left`·`color`를 전환한다면 §2의 저비용 대안과 비교하고, 유지 이유와 측정 결과를 기록했다
|
||||
- [ ] `addEventListener('scroll'`을 썼다면 passive 처리와 일정화, 프레임 시간·입력 지연 측정, 감소 모션 경로를 기록했다
|
||||
- [ ] duration·이징이 전부 `var(--dur-*)`·`var(--ease-*)`다. 인라인 ms 값이 없다
|
||||
- [ ] 이펙트를 전부 끈 상태에서 페이지가 완성돼 있다
|
||||
- [ ] **등장 모션이 감사 도구의 대기시간보다 짧다** — 시각 회귀가 뷰 전환 뒤 N ms 에 스크린샷을 찍는다면 등장 애니메이션은 그보다 짧게(관례: ≤70ms). 아니면 회귀가 매번 다른 프레임을 찍어 흔들린다. 자세한 규칙은 `audit-gate.md` 하니스 10
|
||||
- `transition`·`@keyframes`를 전부 grep하고, 각 속성의 목적·비용 측정·입력 간섭·감소 모션 경로를 기록했다. `opacity`·`transform`·`translate`·`rotate`·`scale` 이외 속성은 프로젝트 예산과 실제 렌더 결과로 정당화했다
|
||||
- `background-color`·`box-shadow`·`filter`·`height`·`width`·`top`·`left`·`color`를 전환한다면 §2의 저비용 대안과 비교하고, 유지 이유와 측정 결과를 기록했다
|
||||
- `addEventListener('scroll'`을 썼다면 passive 처리와 일정화, 프레임 시간·입력 지연 측정, 감소 모션 경로를 기록했다
|
||||
- duration·이징이 전부 `var(--dur-*)`·`var(--ease-*)`다. 인라인 ms 값이 없다
|
||||
|
||||
**동작**
|
||||
- [ ] scroll-driven을 쓴 곳에 `@supports` 가드가 있고 **초기 상태가 그 블록 안에** 있다. Firefox에서 백지가 되지 않는다
|
||||
- [ ] 스크롤 리빌이 위아래로 오갈 때 재생되지 않는다 (`both` / `unobserve()`)
|
||||
- [ ] 첫 화면 콘텐츠가 애니메이션 없이 즉시 읽힌다
|
||||
- [ ] 퇴장 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]
|
||||
- scroll-driven을 쓴 곳에 `@supports` 가드가 있고 **초기 상태가 그 블록 안에** 있다. Firefox에서 백지가 되지 않는다
|
||||
- 스크롤 타임라인 애니메이션에 `animation-delay`로 스태거를 걸지 않았다(무시된다). 첫 화면 요소에 `view()`의 `entry` 구간을 쓰지 않았다
|
||||
- 스크롤 리빌이 위아래로 오갈 때 재생되지 않는다 (`both` / `unobserve()`)
|
||||
- 첫 화면 콘텐츠가 애니메이션 없이 즉시 읽힌다
|
||||
- 퇴장 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`를 켜고 실제로 확인했다. **전부 꺼지지 않고** 위치 이동·시차·루프만 죽고 페이드·상태 변화·진행 표시는 남는다
|
||||
- [ ] 5초 넘게 자동 재생되는 애니메이션에 정지 수단이 있다 (WCAG 2.2.2)
|
||||
- [ ] 큰 면적이 무한 회전·확대·시차 이동하지 않는다 (전정기관 장애)
|
||||
- [ ] 텍스트가 글자 단위로 쪼개져 있지 않다. 쪼갰다면 원문이 DOM에 온전히 있고 시각 사본에 `aria-hidden`이 있다
|
||||
- [ ] 카운트업의 최종값이 스크린리더에 노출된다
|
||||
- [ ] 포커스 링이 애니메이션되지 않고 즉시 보인다. Tab 순서가 시각 순서와 일치하고, 전환 후 포커스가 새 콘텐츠를 따라간다
|
||||
- [ ] 호버에만 있는 정보가 없다 (터치·키보드에서 접근 불가)
|
||||
- [ ] 자동 재생이든 인터랙션 트리거든 초당 3회 넘게 깜빡이지 않는다 (WCAG 2.3.1). 세부 판정은 [accessibility.md](accessibility.md) [WCAG-FLASH]
|
||||
- 큰 면적이 무한 회전·확대·시차 이동하지 않는다 (전정기관 장애)
|
||||
- 텍스트가 글자 단위로 쪼개져 있지 않다. 쪼갰다면 원문이 DOM에 온전히 있고 시각 사본에 `aria-hidden`이 있다
|
||||
- 카운트업의 최종값이 스크린리더에 노출된다
|
||||
- 포커스 링 자체를 애니메이션하지 않는다. 뷰 전환 뒤 포커스가 새 콘텐츠를 따라간다
|
||||
|
||||
**성능**
|
||||
- [ ] 모션 라이브러리 추가분이 `tokens.md` §5 예산 안이다. 넘겼으면 올린 이유를 명시했다
|
||||
- [ ] 상시 `will-change` 요소의 레이어 메모리와 실제 기기 성능이 프로젝트 예산 안임을 확인했다(§2 will-change 표)
|
||||
- [ ] 드래그·제스처가 있다면 입력 경로 지연을 감사했다 — 기준과 방법은 [interaction-feel.md](interaction-feel.md)
|
||||
- [ ] GSAP·Motion·Lenis·SplitText·IntersectionObserver 등 이 문서에서 쓴 라이브러리 인스턴스가 이탈 시 정리된다(§4 정리 규율 표)
|
||||
- [ ] Lenis를 썼다면 §4의 입력·키보드·포커스·앵커·감소 모션·예산 검증을 실제로 마쳤다
|
||||
- [ ] 6단계 `design.md`에 **채택한 모션 · 안 쓰기로 한 모션과 그 이유**를 적었다
|
||||
- 상시 `will-change` 요소의 레이어 메모리와 실제 기기 성능이 프로젝트 예산 안임을 확인했다(§2 will-change 표)
|
||||
- 드래그·제스처가 있다면 입력 경로 지연을 감사했다 — 기준과 방법은 [interaction-feel.md](interaction-feel.md)
|
||||
- GSAP·Motion·Lenis·SplitText·IntersectionObserver 등 이 문서에서 쓴 라이브러리 인스턴스가 이탈 시 정리된다(§4 정리 규율 표)
|
||||
- Lenis를 썼다면 §4의 입력·키보드·포커스·앵커·감소 모션·예산 검증을 실제로 마쳤다
|
||||
- 6단계 `design.md`에 **채택한 모션 · 안 쓰기로 한 모션과 그 이유**를 적었다
|
||||
|
||||
> 근거: research/motion/01~03 (조사일 2026-08-20)
|
||||
> 근거: designpaca 저장소의 모션 조사 노트(조사일 2026-08-20). 설치본에는 포함되지 않으며, 이 문서의 결론만으로 작업할 수 있게 썼다.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue