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

@ -0,0 +1,282 @@
# motion-parallax — 패럴랙스·스크롤 연동 깊이
[motion.md](motion.md)에서 분리한 하위 문서다. 4-4에서 **패럴랙스나 sticky 캔버스처럼 스크롤 진행도에 깊이를 물리기로 이미 정했을 때만** 읽는다. 넣을지 말지는 motion.md §0에서 먼저 정한다 — 섹션마다 리빌·시차를 거는 것은 기본값이 아니다. duration·이징 토큰, 감소 모션 정본(코드 H), `@supports` 가드 원칙은 motion.md를 따르고 여기서 다시 정의하지 않는다.
## 이 문서를 읽는 법
| 상황 | 읽을 곳 |
|---|---|
| 섹션별로 무엇을 움직일지(또는 정지할지) 정한다 | §1 |
| 패럴랙스를 넣었는데 티가 안 난다 | §2 고정 원형 → §3 이동량 → §8 기준면 |
| 패럴랙스가 조용히 멈췄다 | §4 `overflow: hidden` |
| 어두운 배경에 사진·광원을 깐다 | §5 |
| sticky 캔버스·단계형 스크롤 진행도를 만든다 | §6 → §10 → §11 |
| 배경 층을 몇 개 둘지 정한다 | §7·§8 |
| CSS 구현 골격 | §9 |
| 구현을 마무리할 때 | §12 자기 점검 + motion.md §5. 납품 판정은 [preflight.md](preflight.md) X1·X2 |
## 1. 섹션마다 다른 말을 해야 한다
같은 패럴랙스를 모든 섹션에 똑같이 걸면 그건 문법이 아니라 벽지다.
**그 섹션이 주장하는 것이 무엇인지 먼저 말하고, 그 주장을 움직임으로 옮겨라.**
실측 사례 — designpaca 소개 페이지의 섹션별 배정:
| 섹션 | 주장 | 움직임 | 왜 |
|---|---|---|---|
| 히어로 | "결정하지 않으면 기본값이 나온다" | 글자 ↑ / 배경 ↓ (**반대 방향**) | 결정이 떠오르고 기본값이 가라앉는다. 같은 방향이면 그냥 느린 스크롤로 읽힌다 |
| 파이프라인 | 단계를 밟으면 결과가 나온다 | 단계명 **고정**, 산출물만 지연 | 옆의 것이 고정이어야 "저것 때문에 이것"이 보인다. 둘 다 움직이면 인과가 사라진다 |
| 사례 | 서로 다른 두 결과물 | 두 판이 다른 속도로 어긋남 | 같은 스킬에서 다른 것이 나온다 |
| 재료 | 표면은 한 겹이 아니다 | 타일이 서로 다른 깊이 | 주장 자체가 레이어다 |
| 프리플라이트 | 시간순 기록 | 뒤 항목일수록 더 늦게 따라옴 | 로그는 흐른다 |
| 수치 | 공개한 값은 검증 가능하다 | **없음** | 숫자가 흔들리면 값도 흔들려 보인다. 계기판의 바늘은 떨지 않는다 |
**마지막 줄이 가장 중요하다.** 움직이지 않는 것도 결정이다.
안 움직이는 섹션이 하나도 없으면 움직임에 의미가 없다는 뜻이고,
`design.md` 에 "여기는 의도적으로 정지"라고 적어두지 않으면 다음 사람이 "빠뜨린 것"으로 보고 채운다.
## 2. 원형은 "고정"이다
**배경이 뷰포트에 멈춰 있고 콘텐츠만 그 위를 지나갈 때 원경이 된다.**
느리게 같이 움직이는 것으로는 그 인상이 안 나온다 — 속도만 다를 뿐 방향이 같기 때문이다.
"패럴랙스를 넣었는데 티가 안 난다"의 절반은 이것이다.
**`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`, 중경은 아래의 비율 이동, 근경은 콘텐츠와 함께.
## 3. 이동량은 절대값이 아니라 컨테이너 대비 비율이다
**패럴랙스가 "안 보인다"의 대부분은 이동량이 모자란 것이다.**
공식은 하나다. **배경을 컨테이너보다 크게 만들고, 그 초과분 전체를 움직인다.**
```
이미지 높이 = 컨테이너 × (1 + depth)
이동량 = 컨테이너 × depth
= 이미지 높이의 depth / (1 + depth)
```
`depth: 0.45` 면 이미지는 145%, 이동은 이미지 자기 높이의 **31%**.
섹션이 2000px 이면 **900px** 을 움직인다.
```css
.bg img {
height: 145%; /* 1 + depth */
animation: bg-par 1ms linear both; /* 1ms: Firefox는 0이 아닌 duration을 요구한다(motion.md 코드 B) */
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` 을 쓰는 쪽이 낫다.
## 4. `overflow: hidden` 이 view() 타임라인을 죽인다
**이것 하나에 패럴랙스가 통째로 멈춘다. 그리고 조용히 멈춘다.**
`overflow: hidden` 은 **스크롤 컨테이너를 만든다.** 그러면 그 안에 있는 자식의
`animation-timeline: view()` 는 문서가 아니라 **그 상자**를 기준으로 잡힌다.
자식은 상자 안에서 위치가 변하지 않으므로 진행도가 고정되고, 애니메이션은
어중간한 한 프레임에서 굳는다.
- 콘솔 에러 없음. `animation-name`·`animation-timeline` computed 값도 전부 정상
- `getComputedStyle(el).transform` 을 두 스크롤 위치에서 재봐야 알 수 있다. **실측에서 이동 0px**
**잘라내야 한다면 `overflow: clip` 을 쓰거나 조상 섹션에서 처리해라.**
`clip` 은 스크롤 컨테이너를 만들지 않는다.
## 5. 어두운 배경 위 광원 이미지는 opacity 로 누르지 마라
`opacity` 는 이미지 전체를 균일하게 죽인다 — 검정도, 빛도 같이. 그래서 어두운 광원 사진을
`opacity: 0.5` 로 깔면 **있는지조차 안 보인다.**
```css
.bg img { mix-blend-mode: screen; opacity: 0.9; }
```
`screen` 은 **검정을 투명하게 만들고 밝은 곳만 더한다.** 그래서 불투명도를 높게 유지해도
본문 대비를 해치지 않는다. 밝은 이미지(종이·질감)는 반대로 `normal` 이 맞다.
**생성형 이미지를 배경으로 쓸 때 특히 조심해라.** 프롬프트에 "본문 뒤에 깔리니 어둡게"를
너무 강하게 넣으면 안전한 이미지가 아니라 **없는 이미지**가 나온다.
대비는 합성 방식으로 풀고, 이미지 자체는 볼 수 있게 만들어라.
## 6. 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 로 재면 진행도가 고정된다.
## 7. 배경도 한 섹션에 하나다
CSS 격자와 사진을 같이 깔았다가 걷어냈다. 두 배경이 서로 경쟁했고,
격자는 배경이라기보다 **표**로 읽혔다. 앞에서 "한 층은 고정"이라고 한 것과 같은 이야기다 —
층을 늘리는 것과 층이 싸우게 두는 것은 다르다.
## 8. 기준면이 필요하다
**배경이 단색이면 패럴랙스는 성립하지 않는다.** 앞의 것이 몇 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**.
**한 층은 반드시 고정이어야 한다.** 전부 움직이면 기준이 없어 그냥 흔들리는 페이지다.
## 9. 구현은 scroll-driven animation 으로
```css
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.par-slow, .par-fast, .par-lag {
animation: par 1ms 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;`
새 클래스를 만들 때 **애니메이션 셀렉터에 추가하는 것을 잊지 마라.** 변수만 정의하면
아무 일도 일어나지 않고, 조용히 실패해서 검출도 안 된다.
## 10. 읽기 간격과 스크롤 구간은 같은 손잡이가 아니다
sticky 캔버스의 진행도는 `섹션 높이 − 뷰포트` 다. 그래서 "스크롤 구간이 짧다" 는
문제를 만나면 **목록 항목의 간격을 벌려 섹션을 늘리고 싶어진다.** 실측에서 그렇게 했다.
- `--space-6`(24px): 섹션 1493px → sticky 이동 593px. 조금만 굴려도 여섯 단계를 다 지나침
- `--space-24`(96px): 게이트당 300px 확보. 대신 항목 사이가 **224px** 로 벌어져
여섯 개가 한 목록으로 안 읽힘. 사용자 지적: "각 아이템별로 마진 공간이 너무 크다"
**하나의 값으로 두 가지를 맞추려 하면 둘 다 나빠진다.** 나눠라.
```css
.stop { padding-block: var(--stop-pad); } /* 읽기 리듬 — 눈으로 정한다 */
.after { padding-bottom: 62dvh; } /* 스크롤 구간 — 계산으로 정한다 */
```
판정 기준: **게이트 하나당 200px 이상**. 휠 한 번이 100~150px 이므로
그 아래면 "조금만 스크롤해도 다 지나가는" 과민한 반응이 된다.
## 11. 스크롤 구간이 모자라면 여백이 아니라 **진행도 공식**을 고쳐라
위 문제를 처음에는 목록 뒤에 여백을 넣어 풀었다. `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% 에 있을 때 섹션 하단이 아직 뷰포트 안이면 안전하다.
## 12. 체크리스트 (구현 중 자기 점검 — 납품 판정은 preflight.md X2)
납품 판정은 [preflight.md](preflight.md) X2(감소 모션의 정지 배경 포함)와 X1(첫 화면 즉시 읽힘)이 한다.
- 섹션마다 "무엇을 주장하는지"와 움직임을 한 줄로 대응시켰고, 의도적으로 정지한 섹션을 `design.md`에 적었다(§1)
- 한 층 이상이 고정(기준면)이다. 전부 움직이지 않는다(§2·§8)
- `background-attachment: fixed`를 쓰지 않았다(§2)
- 패럴랙스 요소의 조상에 `overflow: hidden`이 없거나 `overflow: clip`으로 바꿨다. 두 스크롤 위치에서 transform 값이 실제로 달라지는 것을 재봤다(§4)
- 패럴랙스 애니메이션은 transform만 쓴다. opacity를 섞지 않았다(§9)
- sticky 진행도는 (섹션 높이 − 뷰포트) 또는 읽는 대상 기준으로 계산했고 끝 단계까지 도달하는 것을 확인했다(§6·§11)