# Semantic Motion

`oc-motion`은 duration/easing preset wrapper가 아니라 UI motion을 semantic contract로 선언하기 위한 helper입니다. 컴포넌트는 "0.24s ease"를 고르는 대신 무엇이 움직이고, 전환의 어느 순간이며, 같은 motion을 얼마나 빠르게 보정할지만 선언합니다.

```scss
oc-motion($properties, $intent, $phase, $pace)
```

- `$intent`: 무엇이 움직이는가. UI의 역할과 공간 모델을 고릅니다.
- `$phase`: 언제 움직이는가. 전환의 시점과 방향을 고릅니다.
- `$pace`: 같은 motion을 빠르게, 기본값으로, 느리게 보정합니다.
- escape hatch: semantic contract로 부족할 때만 국소 조정합니다.

실제 duration token, spring profile, reduced-motion 정책은 디자인 시스템이 중앙에서 관리합니다.

일반 컴포넌트와 consumer SCSS에서는 `oc-motion`을 우선 사용합니다. `oc-spring`은 직접 물리 spring을 설계해야 하는 고급 escape hatch로 남깁니다.

React runtime에서 height/expand처럼 CSS transition만으로 안정적으로 다루기 어려운 motion은 `@orioncactuscorp/ui/utils/motion`과 `@orioncactuscorp/ui/react/motion` subpath를 사용합니다. 이 runtime layer도 같은 intent, phase, pace, reduced-motion policy, `--oc-motion-duration-factor*` token vocabulary를 공유하고, 실제 프레임 계산은 low-level `ocSpring` engine에 위임합니다.

Runtime motion도 CSS helper와 동일하게 최종 계산 duration이 `0`이면 spring을 시작하지 않고 최종 상태로 즉시 완료합니다. instance factor, global factor token, intent duration, phase duration 중 어느 값으로 `0`이 만들어져도 같은 계약을 적용합니다. `resolveOcMotion`을 직접 사용하는 consumer는 `animate`가 `true`일 때만 반환된 `spring`을 scheduler에 전달해야 합니다.

Runtime duration, duration factor, pace factor는 모두 `0` 이상의 유한한 값이어야 합니다. CSS의 factor/pace token은 표준 CSS `<number>` 형태의 단위 없는 숫자만 허용하므로 `+1.2`, `5e-1` 같은 표기는 유효하지만, `1s`, `0px`, 숫자 뒤 임의 문자열처럼 CSS 계산과 runtime 해석이 달라질 수 있는 값은 설정 오류로 처리합니다. 여러 multiplier를 곱한 최종 duration이 overflow로 비유한 값이 되는 경우(예: 매우 큰 programmatic factor 조합)도 오류입니다.

## 빠른 시작

```scss
@use '@orioncactuscorp/ui/scss/mixins/motion' as *;

.button {
  @include oc-motion((background-color, color), feedback);
}

.notice {
  @include oc-motion(opacity, fade);
}

.popover {
  @include oc-motion((opacity, transform), disclosure, $phase: enter);
}

.popover[data-ending-style] {
  @include oc-motion((opacity, transform), disclosure, $phase: exit);
}

.bottom-sheet {
  @include oc-motion(transform, sheet, $phase: enter);
}

.bottom-sheet[data-drag-release] {
  @include oc-motion(transform, sheet, $phase: release);
}
```

`disclosure`, `surface`, `sheet`, `expand`처럼 lifecycle이 있는 motion은 들어올 때 `enter`, 나갈 때 `exit`를 명시하는 것을 기본으로 합니다. lifecycle selector가 없거나 양방향으로 같은 transition을 써도 되는 단순 케이스에서만 기본값인 `change`를 fallback으로 사용합니다.

`@include oc-motion(...)`은 기본 transition과 `prefers-reduced-motion: reduce` 대응을 함께 출력합니다. reduced-motion 대응이 필요 없는 순수 함수 값만 필요할 때는 `transition: oc-motion(...)`을 직접 사용할 수 있습니다.

```scss
.simple {
  transition: oc-motion(opacity, feedback);
}
```

React runtime에서는 같은 vocabulary를 TS로 해석할 수 있습니다.

```tsx
import { MotionExpand } from '@orioncactuscorp/ui/react/motion';
import { ocMotion } from '@orioncactuscorp/ui/utils/motion';

const config = ocMotion.resolve({
  intent: 'expand',
  phase: 'enter',
  pace: 'normal',
  reduced: 'auto',
});

export function Panel({ open, children }) {
  return (
    <MotionExpand open={open} intent='expand' forceMount>
      {children}
    </MotionExpand>
  );
}
```

## 왜 semantic contract로 작성하는가

motion을 컴포넌트마다 `0.2s ease`, `0.3s linear(...)`처럼 직접 쓰면 다음 문제가 생깁니다.

- 같은 종류의 움직임이 컴포넌트마다 조금씩 달라집니다.
- reduced-motion 정책을 컴포넌트마다 따로 관리합니다.
- fade, disclosure, surface, sheet, expand처럼 비슷해 보이지만 체감 기준이 다른 motion의 경계가 흐려집니다.
- enter, exit, release가 같은 duration/easing으로 묶여 닫힘이나 drag release가 어색해집니다.
- 장기적으로 duration/easing을 조정할 때 전체 surface를 다시 찾아야 합니다.

`oc-motion`은 motion을 `intent + phase + pace`로 표현합니다. 컴포넌트는 motion의 의미를 선언하고, 실제 값은 foundation token과 helper policy가 결정합니다.

## Mental model

| 축           | 답하는 질문                                      | 예시                                    |
| ------------ | ------------------------------------------------ | --------------------------------------- |
| `intent`     | 이 UI는 어떤 역할과 공간 모델로 움직이는가?      | `disclosure`, `surface`, `sheet`        |
| `phase`      | 전환의 어느 순간인가?                            | `enter`, `exit`, `release`              |
| `pace`       | 이 instance는 기본보다 빠른가, 느린가?           | `quick`, `normal`, `slow`               |
| escape hatch | semantic API로 부족한 한 컴포넌트 보정이 있는가? | `$duration-factor`, `$extra-bounce`     |
| amplitude    | motion의 거리나 scale 강도를 token으로 줄일까?   | `oc-motion-scale`, `oc-motion-distance` |

`phase`는 standalone animation이 아닙니다. `enter`만으로는 motion이 정해지지 않고, `disclosure enter`, `surface enter`, `sheet enter`, `expand enter`처럼 intent와 결합해야 체감 기준이 생깁니다.

## oc-motion contract

가장 일반적인 사용은 mixin입니다. base transition과 reduced-motion override를 함께 출력합니다.

### `@include oc-motion(...)`

```scss
@include oc-motion(
  $properties,
  $intent,
  $phase: change,
  $pace: normal,
  $duration-factor: 1,
  $reduced: auto,
  $duration-token: null,
  $extra-bounce: null
);
```

컴포넌트 스타일에서는 이 mixin을 기본으로 사용합니다.

### `oc-motion(...)`

```scss
transition: oc-motion(
  $properties,
  $intent,
  $phase: change,
  $pace: normal,
  $duration-factor: 1,
  $duration-token: null,
  $extra-bounce: null
);
```

transition item list만 반환합니다. `@media (prefers-reduced-motion: reduce)`는 출력하지 않습니다.

### `oc-motion-reduced(...)`

```scss
.label {
  transition: oc-motion(transform, move), oc-motion(color, feedback);

  @media (prefers-reduced-motion: reduce) {
    transition:
      oc-motion-reduced(transform, move), oc-motion-reduced(color, feedback);
  }
}
```

한 `transition`에 여러 intent를 섞으면 `@include oc-motion`을 intent마다 쓸 수 없으므로, reduced-motion 목록도 같은 모양으로 직접 만들어야 합니다. `oc-motion-reduced`는 `@include oc-motion`의 reduced block과 같은 정책으로 transition item을 돌려줍니다. 제거되는 motion도 `property 0s` item으로 남겨 목록의 property 순서가 base 목록과 같게 유지됩니다. 인자는 `oc-motion(...)`과 같고 `$reduced`를 추가로 받습니다.

### `oc-motion-duration(...)`

```scss
transition-duration: oc-motion-duration(
  $intent,
  $phase: change,
  $pace: normal,
  $duration-factor: 1,
  $duration-factor-token: var(--oc-motion-duration-factor),
  $duration-token: null
);
```

duration token, 전역 duration factor token, pace factor, 숫자 duration factor를 합친 `calc(...)` 값을 반환합니다.

### `oc-motion-timing(...)`

```scss
transition-timing-function: oc-motion-timing($intent, $phase: change);
```

intent와 phase에 맞는 token timing을 반환합니다.

### `oc-motion-scale(...)`

```scss
transform: scale(
  oc-motion-scale($scale, $intent: feedback, $scale-factor-token: null)
);
```

scale amplitude를 runtime token에 연결합니다. 기본 token 값 `1`에서는 입력한 scale을 그대로 유지하고, token을 `0`으로 두면 `scale(1)`이 되어 scale 효과가 사라집니다. `0.5`처럼 0과 1 사이의 값은 더 얕은 scale, `1`보다 큰 값은 더 강한 scale을 만듭니다. 전체 scale은 `--oc-motion-scale-factor`로 조정하고, feedback 계열은 `--oc-motion-scale-factor-feedback`, spatial 계열 intent는 `--oc-motion-scale-factor-spatial`로 override할 수 있습니다. `fade`도 intent vocabulary completeness를 위해 amplitude helper에서 사용할 수 있지만, 권장 contract는 opacity 중심입니다. scale/translate가 motion 의미를 설명하기 시작하면 `disclosure`, `surface`, `sheet`, `move` 같은 역할 intent를 먼저 검토합니다.

### `oc-motion-distance(...)`

```scss
transform: translateY(
  oc-motion-distance($distance, $intent, $distance-factor-token: null)
);
```

translate 또는 keyframe offset처럼 motion 거리 자체가 피드백 강도에 해당하는 값을 runtime token에 연결합니다. 기본 token 값 `1`에서는 입력한 거리를 그대로 유지하고, token을 `0`으로 두면 translate/reject shake 거리가 사라집니다. 전체 distance는 `--oc-motion-distance-factor`로 조정하고, `disclosure`/`surface`/`sheet`/`expand`/`move` 계열은 `--oc-motion-distance-factor-spatial`, `feedback`/`reject`/`gesture` 계열은 `--oc-motion-distance-factor-reject`로 override할 수 있습니다.

## 인자 역할

| 구분             | 인자                     | 값                                                                                                                             | 역할과 사용 기준                                                                                                         |
| ---------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| Required         | `$properties`            | `opacity`, `transform`, `(opacity, transform)`                                                                                 | transition 대상 property입니다. reduced-motion에서 property별 보존 여부를 판단하는 기준이 됩니다.                        |
| Required         | `$intent`                | `feedback`, `fade`, `reveal`, `reject`, `disclosure`, `surface`, `sheet`, `expand`, `move`, `gesture`, `ambient`, `continuous` | UI motion의 역할과 공간 모델입니다. duration, spring profile, 기본 reduced policy를 결정합니다.                          |
| Timing axis      | `$phase`                 | `change`, `enter`, `exit`, `release`, `loop`, `charge`                                                                         | 같은 intent 안에서 전환의 시점과 방향을 구분합니다. 생략하면 `change`입니다. `charge`는 `hold` 전용입니다.               |
| Timing axis      | `$pace`                  | `quick`, `normal`, `slow`                                                                                                      | 공유 vocabulary로 duration을 상대 보정합니다. 먼저 `$pace`를 고르고, 그래도 부족할 때만 `$duration-factor`를 사용합니다. |
| Reduced axis     | `$reduced`               | `auto`, `preserve`, `fade`, `static`, `feedback`                                                                               | reduced-motion에서 무엇을 남길지 정합니다. 기본은 intent의 policy를 따르는 `auto`입니다.                                 |
| Escape hatch     | `$duration-factor`       | unitless number                                                                                                                | 한 컴포넌트만 미세 조정하는 multiplier입니다. 음수는 금지하고, `0`은 instant completion 용도로만 사용합니다.             |
| Escape hatch     | `$duration-token`        | CSS duration token                                                                                                             | 기존 component-local duration CSS variable과 연결해야 할 때만 사용합니다. spring-backed motion에서는 사용할 수 없습니다. |
| Escape hatch     | `$extra-bounce`          | unitless number                                                                                                                | spring-backed motion의 bounce만 국소 override합니다. transform entrance가 너무 건조할 때처럼 제한적으로 사용합니다.      |
| Advanced token   | `$duration-factor-token` | CSS number token                                                                                                               | `oc-motion-duration(...)` 전용입니다. reduced-motion 내부 정책처럼 multiplier token 자체를 바꿔야 할 때만 사용합니다.    |
| Amplitude helper | `$scale-factor-token`    | CSS number token                                                                                                               | `oc-motion-scale(...)` 전용입니다. scale amplitude를 어떤 token으로 줄일지 바꿉니다.                                     |
| Amplitude helper | `$distance-factor-token` | CSS number token                                                                                                               | `oc-motion-distance(...)` 전용입니다. translate/keyframe distance amplitude를 어떤 token으로 줄일지 바꿉니다.            |

## Intent 선택

선택 기준은 컴포넌트 이름이 아니라 motion의 역할과 공간 모델입니다. 일반 content show/hide는 `fade`, Tooltip과 Menu는 `disclosure`, Popup Modal은 `surface`, Bottom Modal은 `sheet`, Accordion은 `expand`를 사용합니다.

| Intent       | 역할                                       | 대표 사용처                         | 자주 쓰는 phase              | 기본 reduced-motion 정책 |
| ------------ | ------------------------------------------ | ----------------------------------- | ---------------------------- | ------------------------ |
| `feedback`   | 즉각적인 시각 반응                         | hover, focus, active                | `change`                     | `feedback`               |
| `fade`       | 일반 content의 단순 표시/숨김              | Notice, helper text, loaded content | `change`                     | `fade`                   |
| `reveal`     | 준비된 콘텐츠의 짧은 공간적 등장           | Reveal slide, blur                  | `enter`                      | `fade`                   |
| `reject`     | 허용되지 않은 시도를 알려주는 문제 피드백  | blocked dismiss, invalid            | `change`, `enter`            | `feedback`               |
| `disclosure` | trigger에 붙은 작은 transient surface      | Menu, Tooltip, Popover              | `enter`, `exit`              | `fade`                   |
| `surface`    | 화면 맥락 위의 작업 focus surface          | Popup Modal                         | `enter`, `exit`              | `fade`                   |
| `sheet`      | 화면 edge에 고정된 spatial panel           | Bottom Modal, drawer                | `enter`, `exit`, `release`   | `static`                 |
| `expand`     | layout 흐름 안에서 펼쳐지는 content        | Accordion                           | `enter`, `exit`              | `static`                 |
| `move`       | 같은 control 안에서 위치가 바뀌는 요소     | Switch thumb, indicator             | `change`                     | `static`                 |
| `gesture`    | 사용자 조작 후 정착하는 자유 surface       | Popup drag, drag snap               | `release`                    | `feedback`               |
| `ambient`    | 은은한 대기 상태 반복 표현                 | Skeleton, status pulse              | `loop`                       | `static`                 |
| `continuous` | 일정 속도 반복 motion                      | Loading rotate, progress sweep      | `loop`                       | `static`                 |
| `hold`       | 누르거나 머무는 동안 예열 후 판정되는 입력 | 길게 누르기, hover 체류             | `charge`, `enter`, `release` | `preserve`               |

### Reject feedback

`reject`는 사용자의 시도가 처리되지 않았음을 즉시 알려야 할 때 사용합니다. 예를 들어 닫을 수 없는 Alert의 background를 눌렀거나, 잘못된 입력으로 다음 단계 이동이 막혔거나, 현재 상태에서 허용되지 않는 전환을 시도한 경우가 여기에 해당합니다.

단순 hover, press, selected 같은 반응은 `feedback`을 사용합니다. 시도가 실패했거나 의도적으로 차단되었음을 알려야 할 때만 `reject`를 사용합니다.

`reject` motion은 보통 surface 전체가 짧게 흔들리는 형태입니다. 좌우 이동 거리처럼 motion 강도 자체가 거리로 표현되는 값은 `oc-motion-distance(..., reject)`로 감싸고, component-local 기본 거리와 global distance scale이 함께 작동하게 둡니다.

```scss
.dialog {
  --dialog-reject-distance: #{oc-motion-distance(0.75rem, reject)};

  animation: oc-dialog-reject-shake oc-motion-duration(reject)
    oc-motion-timing(reject) both;
}
```

현재 Alert는 background dismissal이 차단된 상태에서 backdrop을 누르면 Modal surface에 transient `data-oc-modal-feedback='reject'` state를 부여합니다. 기본 흔들림 거리는 `--oc-modal-reject-distance`로 조정할 수 있고, 전체 reject 거리 강도는 `--oc-motion-distance-factor-reject`가 담당합니다.

### Hold

`hold`는 길게 누르기나 hover 체류처럼 일정 시간을 채워야 판정되는 입력의 motion입니다. iOS Haptic Touch 녹화 실측을 기준으로, 누르는 동안 조금씩 부풀다가(예열) 판정 순간 짧은 스프링으로 터지고(팝), 판정 전에 놓으면 그 자리에서 되돌아갑니다.

| Phase     | 의미                      | 출력                                                                                                 |
| --------- | ------------------------- | ---------------------------------------------------------------------------------------------------- |
| `charge`  | 누르거나 머무는 동안 예열 | `--oc-motion-duration-hold`(0.65초) × pace, `--oc-motion-timing-hold`(앞 82% 성장 후 최대 크기 유지) |
| `enter`   | 판정 순간 팝              | snappy spring + bounce `0.1` (합계 0.25), response `0.2s`                                            |
| `release` | 판정 전에 놓아 되돌아감   | snappy spring, response `0.2s`                                                                       |

`hold`에는 `change`와 `exit`이 없고, `charge`는 `hold`에서만 쓸 수 있습니다. 잘못된 조합은 Sass와 runtime 모두 오류를 냅니다.

```scss
.card {
  @include oc-motion((scale, opacity), hold, $phase: release);

  &[data-holding] {
    scale: oc-motion-scale(1.04, hold);
    @include oc-motion((scale, opacity), hold, $phase: charge);
  }

  &[data-held] {
    @include oc-motion((scale, opacity), hold, $phase: enter);
  }
}
```

판정 시간은 CSS token 하나가 기준입니다. 스크립트는 같은 token을 runtime API로 읽어 타이머로 쓰므로 예열이 끝나는 순간과 판정 순간이 어긋나지 않습니다.

```ts
const { durationSeconds } = resolveOcMotion({
  intent: 'hold',
  phase: 'charge',
  element,
});
```

#### 입력 계약 motion

`charge` 시간은 motion이 아니라 입력 판정 기준입니다. 그래서 다른 intent와 달리 `--oc-motion-duration-factor`와 `$duration-factor`을 적용하지 않고, reduced-motion에서도 바꾸지 않습니다. 판정 시간을 바꾸려면 `--oc-motion-duration-hold` token, `$pace`, 또는 component-local `$duration-token`을 사용합니다. 전역 조절을 무시하는 기준은 "물리 motion인가"가 아니라 "사용자 입력의 판정 시간이나 위치와 묶여 있는가"입니다. 판정 뒤에 나타나는 `enter`/`release` spring은 다른 spring처럼 전역 조절을 따릅니다.

#### 판정 시간과 크기

판정 시간은 요소 크기와 무게감에 맞춰 `$pace`로 고릅니다. 스크립트도 같은 pace로 읽어야 판정과 예열이 맞습니다.

| `$pace`  | 판정 시간 | 대표 사용처                   | 예열 크기 예       |
| -------- | --------- | ----------------------------- | ------------------ |
| `quick`  | 약 0.49초 | 페이지 점, 작은 아이콘 컨트롤 | 1.1 안팎 (작은 점) |
| `normal` | 0.65초    | 목록 행, 일반 항목 (iOS 실측) | 1.03~1.04          |
| `slow`   | 약 0.88초 | 카드, 큰 미리보기 표면        | 1.02~1.03          |

예열 크기는 요소가 클수록 작게 둡니다. 같은 배율이라도 큰 요소는 움직이는 거리가 커서 가볍고 들떠 보입니다.

`enter`(합계 0.25)와 `release`(0.15) spring의 bounce는 pace와 관계없이 고정입니다. pace는 시간 축만 바꾸고, 반동의 느낌은 판정 순간 예열하던 요소가 줄어드는 동안 결과 표면이 커지는 구성에서 나옵니다. 결과 표면을 새 요소로 페이드인하기보다 예열하던 표면에서 이어서 커지게 구성하세요. 특정 컴포넌트만 반동을 더 줘야 하면 `$extra-bounce`를 국소적으로 사용합니다.

#### 입력 처리

`hold`는 모션만 제공하고 누르기 판정은 컴포넌트가 소유합니다. 길게 누르기를 직접 구현할 때는 다음을 함께 처리합니다.

- 텍스트 선택과 iOS 길게 누르기 메뉴: 누르는 요소에 `@include prevent-drag`와 `-webkit-touch-callout: none`을 두고, Android가 길게 누르기 중에 보내는 `contextmenu`는 터치·펜으로 누르고 있는 동안에만 `preventDefault()`합니다. 마우스 오른쪽 클릭 메뉴는 막지 않습니다. Safari는 접두사 없는 `user-select`만으로는 선택을 막지 못합니다.
- 페이지 스크롤: `touch-action: none`으로 스크롤을 막지 않습니다. 판정 전에 손가락이 일정 거리(약 10px) 이상 움직이거나 `pointercancel`이 오면 hold를 취소해 스크롤에 넘깁니다.
- 대체 수단: 길게 누르기로만 쓸 수 있는 기능을 두지 않습니다. 키보드와 보조기술 사용자를 위해 같은 기능을 버튼이나 메뉴로도 제공합니다.
- 햅틱: iOS Safari는 웹 진동을 지원하지 않으므로 판정은 시각 모션으로만 전달됩니다.

#### 크기와 성능

예열 크기는 `oc-motion-scale(..., hold)`로 감싸 `--oc-motion-scale-factor-hold`가 조절하게 둡니다. reduced-motion에서는 이 계수에 `--oc-motion-scale-factor-hold-reduced`(기본 `0.25`)를 곱해 부풀기의 25%만 남깁니다. 공용 사용처에서는 `scale`, `opacity`처럼 compositor에서 처리되는 property를 직접 전환하는 것을 기본으로 합니다. layout을 바꾸는 값이나 등록된 custom property를 전환하면 매 frame style·layout 계산이 일어나므로 작은 요소에만 사용합니다.

## Phase 선택

`phase`는 standalone animation 이름이 아니라 intent에 붙는 시점입니다. 같은 `enter`라도 `disclosure enter`는 작은 surface가 민첩하게 열리는 motion이고, `sheet enter`는 edge-anchored panel이 공간을 설명하며 들어오는 motion입니다.

| Phase     | 답하는 질문                             | 대표 조합                          |
| --------- | --------------------------------------- | ---------------------------------- |
| `change`  | 값이나 상태가 바뀌었는가?               | `feedback change`, `move change`   |
| `enter`   | 화면에 나타나거나 열리는가?             | `disclosure enter`, `sheet enter`  |
| `exit`    | 화면에서 사라지거나 닫히는가?           | `disclosure exit`, `surface exit`  |
| `release` | 사용자가 drag를 놓은 뒤 정착하는가?     | `sheet release`, `gesture release` |
| `loop`    | 자동으로 반복되는가?                    | `ambient loop`, `continuous loop`  |
| `charge`  | 누르거나 머무는 동안 판정을 기다리는가? | `hold charge`                      |

`settle`은 public phase로 쓰지 않습니다. 사용자가 일으킨 단계는 `release`이고, 최종 상태에 수렴하는 물리 결과는 runtime이나 spring 내부 구현에서만 `settle`이라는 이름을 사용할 수 있습니다.

`fade`는 일반 transition처럼 `change` 기본값을 사용합니다. 같은 opacity transition이 `0 -> 1`, `1 -> 0` 양방향에 적용되므로, exit timing을 별도로 조정해야 할 때만 `$phase: exit` 같은 override를 추가합니다.

`change`는 lifecycle phase를 대체하는 이름이 아니라 fallback입니다. Base UI의 `data-starting-style`/`data-ending-style`, `data-state`, class 같은 lifecycle selector가 있으면 해당 selector에서 `enter`/`exit`를 명시하고, selector를 구분하기 어려운 단순 state transition에서만 `change`를 사용합니다. `oc-motion`은 lifecycle selector를 생성하지 않고, 어떤 selector가 어느 phase를 의미하는지는 컴포넌트나 primitive가 소유합니다.

`disclosure`, `surface`, `sheet`, `expand`는 역할별 profile이 다릅니다. `disclosure enter/change`의 transform은 작은 surface가 민첩하게 열리도록 낮은 snappy bounce를 허용하고, `disclosure exit`은 spring 없이 `0.14s`와 `cubic-bezier(0.8, 0, 1, 1)`로 초반 관성을 낮춘 뒤 빠르게 정리합니다. `surface`, `sheet`, `expand`는 기본적으로 smooth/no-bounce입니다. Popup Modal은 1보다 커지는 scale overshoot를 피하고, `surface exit`은 smooth `0.24`를 기준으로 닫힘을 정리합니다. Bottom Sheet는 iOS bottom sheet reference에 맞춰 `sheet enter` smooth `0.26`, `sheet exit/release` smooth `0.33`을 기준으로 둡니다. Accordion은 뜨는 surface가 아니라 문서 흐름이 펼쳐지는 motion이므로 `expand`를 사용합니다.

`reject`는 사용자의 시도가 거부되었음을 알려야 하는 surface shake motion에 사용하며, keyframes 자체가 좌우 반동을 만들기 때문에 timing은 smooth spring을 사용합니다. 좌우로 방향을 여러 번 바꿔야 하는 shake는 keyframes로 변위를 정의하고 `oc-motion-duration(reject)`와 `oc-motion-timing(reject)`를 animation duration/timing에 연결합니다. 단일 transform 복귀 transition만 필요한 경우에는 `oc-motion(transform, reject)`를 사용할 수 있습니다.

`move`는 Switch thumb, indicator, Accordion chevron처럼 같은 control 안에서 위치나 방향이 바뀌는 motion에 사용합니다. hover, press, selected 같은 즉각 반응은 `feedback change`를 유지합니다. `ambient loop`는 Skeleton pulse처럼 은은하게 반복되는 상태 표현에 사용하고, spinner 회전이나 progress sweep처럼 일정 속도 자체가 의미인 반복은 `continuous loop`를 사용합니다. 두 반복 intent는 spring-backed motion이 아니며 reduced-motion에서는 기본 static으로 둡니다. `gesture release`는 Popup drag snap처럼 작은 자유 surface가 손을 놓은 뒤 빠르게 정착해야 하는 경우에 사용하며, SCSS transition helper와 TS runtime animator 모두 snappy spring profile을 사용합니다.

```scss
.menu {
  @include oc-motion((opacity, transform), disclosure, $phase: enter);
}

.menu[data-ending-style] {
  @include oc-motion((opacity, transform), disclosure, $phase: exit);
}

.bottom-sheet {
  @include oc-motion(transform, sheet, $phase: enter);
}

.accordion-details {
  @include oc-motion(block-size, expand, $phase: enter);
}

.dialog[data-oc-feedback='reject'] {
  animation: oc-dialog-reject-shake oc-motion-duration(reject)
    oc-motion-timing(reject) both;
}

.chevron {
  @include oc-motion(transform, move, $pace: quick);
}
```

## Extra bounce override

`$extra-bounce`는 spring-backed transition의 반동 강도를 컴포넌트 한 곳에서만 조정해야 할 때 쓰는 escape hatch입니다. 기본 profile을 우선 사용하고, 같은 intent 안에서도 transform entrance가 너무 건조하거나 과하게 느껴지는 경우에만 사용합니다.

```scss
.small-menu {
  @include oc-motion(transform, disclosure, $phase: enter, $extra-bounce: 0.08);
}

.tooltip {
  @include oc-motion(transform, disclosure, $phase: enter, $extra-bounce: 0);
}
```

`$extra-bounce`는 `oc-spring`의 preset bounce에 더해지는 unitless 값입니다. `disclosure enter/change`의 `transform`은 기본적으로 약한 extra bounce를 갖고, override를 넘기면 property 기본값보다 명시 값이 우선합니다. 여러 property를 함께 넘기면 같은 override가 모든 spring-backed property에 적용되므로, opacity처럼 overshoot가 의미 없는 property와 섞기보다 transform transition에 국소적으로 쓰는 편이 좋습니다.

non-spring motion에서는 `$extra-bounce`가 의미 없으므로 helper가 에러를 냅니다. `feedback change`처럼 token timing path를 쓰는 motion은 `$pace`, `$duration-factor`, `oc-motion-scale`, `oc-motion-distance`로 조정합니다. Reduced-motion에서는 transform spring이 제거될 수 있으므로 extra bounce는 일반 motion profile의 국소 조정으로만 봅니다.

## Pace와 duration factor

`$pace`는 반복적으로 쓰는 semantic 선택지입니다.

```scss
.fast-feedback {
  @include oc-motion(opacity, feedback, $pace: quick);
}

.calm-surface {
  @include oc-motion((opacity, transform), surface, $pace: slow);
}
```

foundation의 `--oc-motion-pace-*` token은 unitless duration multiplier입니다.

```scss
--oc-motion-pace-quick: 0.75;
--oc-motion-pace-normal: 1;
--oc-motion-pace-slow: 1.35;
```

`$duration-factor`은 개별 컴포넌트에서 token 체계를 벗어나지 않고 미세 조정해야 할 때만 사용합니다.

```scss
.compact {
  @include oc-motion(opacity, feedback, $duration-factor: 0.85);
}
```

`$pace`와 `$duration-factor`은 같은 개념이 아닙니다. `$pace`는 공유 vocabulary이고, `$duration-factor`은 국소 조정을 위한 숫자 multiplier입니다. 장기 유지보수 관점에서는 `$pace`를 먼저 선택하고, 정말 필요한 경우에만 `$duration-factor`을 추가합니다.

spring-backed Sass transition에서는 두 종류의 시간이 보입니다. `sheet enter 0.26s`, `sheet release 0.33s` 같은 값은 spring curve를 샘플링하는 semantic 기준 duration이고, CSS에 출력되는 transition-duration은 그 spring이 안정적으로 settle되는 시간입니다. 그래서 compiled CSS나 Storybook preview에서는 `0.26s`가 더 긴 `calc(...)` 값으로 보일 수 있습니다. `$pace`와 `$duration-factor`는 이 출력 duration에 곱해지는 instance 보정입니다.

## Reduced Motion

`@include oc-motion(...)`은 항상 reduced-motion block을 함께 출력합니다.

```scss
.tooltip {
  @include oc-motion((opacity, transform), disclosure);
}
```

reduced-motion에서 `disclosure`와 `surface`는 opacity fade만 남기고 transform은 제거합니다. base transition이 spring-backed transition이어도 reduced block에서는 `transition-timing-function`을 `oc-motion-timing(...)` 기반 token timing으로 명시해 spring/overshoot timing이 남지 않게 합니다. `sheet`와 `expand`는 기본적으로 static입니다. Bottom Sheet처럼 공간 구조 이해에 꼭 필요한 translate는 컴포넌트가 별도 invariant로 보존할 수 있습니다. `hold`는 iOS처럼 `preserve`를 기본으로 둡니다. `charge`는 판정 시간 그대로 유지하고 예열 크기만 줄이며, `enter`/`release` spring은 bounce 없는 smooth spring으로 바뀝니다.

```css
@media (prefers-reduced-motion: reduce) {
  .tooltip {
    transition-property: opacity;
    transition-duration: calc(
      var(--oc-motion-duration-disclosure) *
        var(--oc-motion-duration-factor-reduced) *
        var(--oc-motion-pace-normal) * 1
    );
    transition-timing-function: var(--oc-motion-timing-disclosure);
    transform: none;
  }
}
```

### Reduced policy

| Policy     | 동작                                                                                                                                    |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `auto`     | intent의 기본 reduced-motion 정책을 사용합니다.                                                                                         |
| `fade`     | feedback-safe property만 transition하고 transform은 제거합니다.                                                                         |
| `feedback` | `fade`와 같은 방식으로 paint/opacity feedback만 남깁니다.                                                                               |
| `static`   | transition을 제거합니다. transform이 있으면 `transform: none`도 출력합니다.                                                             |
| `preserve` | motion을 유지하되 `--oc-motion-duration-factor-preserve`와 token timing을 사용합니다. 공간 구조 이해에 꼭 필요한 motion에만 사용합니다. |

feedback-safe property는 다음으로 제한합니다.

```scss
opacity
color
background-color
border-color
box-shadow
outline-color
fill
stroke
```

예를 들어 `@include oc-motion(transform, feedback)`처럼 feedback-safe property가 하나도 없으면 reduced-motion에서는 transition을 제거합니다.

`transform`을 `:active`, checked selector, `[data-state]` 같은 더 구체적인 state selector에서 바꾸는 경우에는 reduced-motion override도 같은 state selector에서 보완해야 합니다. `oc-motion`이 출력하는 `transform: none`은 helper가 include된 selector에만 적용되므로, 더 높은 specificity의 state transform까지 자동으로 이기지는 않습니다.

## Component-local duration token

기존 component contract를 유지해야 할 때 `$duration-token`을 사용할 수 있습니다.

```scss
.interaction {
  --oc-interaction-time: 0.2s;

  @include oc-motion(
    (opacity, transform),
    feedback,
    $duration-token: var(
        --oc-interaction-time,
        var(--oc-motion-duration-feedback)
      )
  );
}
```

이 옵션은 non-spring motion에서만 허용합니다. spring-backed motion은 Sass가 compile-time duration으로 spring curve를 샘플링해야 하므로 CSS custom property duration을 받을 수 없습니다.

```scss
// Invalid: surface enter/change is spring-backed.
.invalid {
  @include oc-motion(opacity, surface, $duration-token: var(--local-time));
}
```

## Runtime API

CSS transition만으로 안정적으로 다루기 어려운 height, measured layout, gesture release motion은 runtime API를 사용할 수 있습니다. runtime도 같은 `intent + phase + pace` vocabulary를 사용합니다.

```tsx
import { ocMotion } from '@orioncactuscorp/ui/utils/motion';

const config = ocMotion.resolve({
  intent: 'sheet',
  phase: 'release',
  pace: 'normal',
  reduced: 'auto',
  reducedMotion: prefersReducedMotion,
  property: 'transform',
  element: surfaceElement,
});
```

| Option           | 역할                                                                                                         |
| ---------------- | ------------------------------------------------------------------------------------------------------------ |
| `intent`         | UI motion의 역할과 공간 모델입니다. Sass `$intent`와 같은 vocabulary를 사용합니다.                           |
| `phase`          | 전환 시점입니다. 생략하면 `change`입니다.                                                                    |
| `pace`           | semantic speed modifier입니다. 생략하면 `normal`입니다.                                                      |
| `durationFactor` | 한 instance만 duration을 미세 조정하는 숫자 multiplier입니다.                                                |
| `extraBounce`    | spring-backed motion의 bounce를 국소 override합니다. non-spring motion에서는 에러를 냅니다.                  |
| `reduced`        | 어떤 reduced-motion policy를 적용할지 고릅니다. `auto`는 intent 기본 policy를 사용합니다.                    |
| `reducedMotion`  | 현재 환경이나 호출 맥락이 reduced-motion인지 전달합니다. policy 이름이 아니라 runtime 상태입니다.            |
| `property`       | reduced-motion에서 animate 여부와 property별 spring preset을 판단하는 기준입니다. 가능하면 명시합니다.       |
| `element`        | computed CSS custom property를 읽을 대상 element입니다. 전달하지 않으면 default token snapshot을 사용합니다. |
| `tokens`         | 테스트나 특수 runtime에서 element 대신 직접 넘기는 token snapshot override입니다.                            |

`reduced`와 `reducedMotion`은 다른 값입니다. `reduced`는 정책이고, `reducedMotion`은 현재 motion을 줄여야 하는 상태입니다. `property`도 중요합니다. 예를 들어 `feedback` reduced policy는 opacity, color, background-color 같은 feedback-safe property만 유지하고 `transform`은 제거할 수 있습니다.

`$duration-token`은 Sass-only escape hatch입니다. runtime에서는 `element`의 computed CSS custom property나 `tokens` override를 통해 duration token을 읽습니다.

## Token customization

consumer는 foundation CSS custom property를 override해 전체 motion 톤을 조정할 수 있습니다.

```scss
:root {
  --oc-motion-duration-factor: 0.9;
  --oc-motion-scale-factor: 0.8;
  --oc-motion-scale-factor-feedback: 0.6;
  --oc-motion-scale-factor-spatial: 0;
  --oc-motion-distance-factor: 0.75;
  --oc-motion-distance-factor-reject: 1;
  --oc-motion-distance-factor-spatial: 0.5;
  --oc-motion-duration-feedback: 0.16s;
  --oc-motion-pace-slow: 1.25;
}

@media (prefers-reduced-motion: reduce) {
  :root {
    --oc-motion-duration-factor-reduced: 0.65;
  }
}
```

amplitude token은 parent-child 구조를 따릅니다. 전체 톤을 조정할 때는 parent token을 먼저 바꾸고, 특정 intent만 다르게 가져가야 할 때 child token을 override합니다.

```scss
:root {
  --oc-motion-scale-factor: 0.75;
  --oc-motion-distance-factor: 0.75;
}

.dense-tool {
  --oc-motion-scale-factor-feedback: 0.5;
}

.calm-surface {
  --oc-motion-distance-factor-spatial: 0.25;
}
```

`--oc-motion-scale-factor-feedback`, `--oc-motion-scale-factor-spatial`, `--oc-motion-distance-factor-reject`, `--oc-motion-distance-factor-spatial`은 기본적으로 각 parent token을 참조합니다. 따라서 child token을 지정하지 않으면 전체 scale/distance factor 정책을 그대로 따릅니다.

주요 token은 다음과 같습니다.

```scss
--oc-motion-duration-factor
--oc-motion-duration-factor-reduced
--oc-motion-duration-factor-preserve
--oc-motion-scale-factor
--oc-motion-scale-factor-feedback
--oc-motion-scale-factor-spatial
--oc-motion-distance-factor
--oc-motion-distance-factor-reject
--oc-motion-distance-factor-spatial
--oc-motion-pace-quick
--oc-motion-pace-normal
--oc-motion-pace-slow
--oc-motion-duration-feedback
--oc-motion-duration-fade
--oc-motion-duration-reveal
--oc-motion-duration-reject
--oc-motion-duration-disclosure
--oc-motion-duration-disclosure-exit
--oc-motion-duration-surface
--oc-motion-duration-surface-exit
--oc-motion-duration-sheet
--oc-motion-duration-sheet-exit
--oc-motion-duration-sheet-release
--oc-motion-duration-expand
--oc-motion-duration-expand-exit
--oc-motion-duration-move
--oc-motion-duration-gesture
--oc-motion-duration-gesture-release
--oc-motion-duration-ambient
--oc-motion-duration-continuous
--oc-motion-timing-feedback
--oc-motion-timing-fade
--oc-motion-timing-reveal
--oc-motion-timing-reject
--oc-motion-timing-disclosure
--oc-motion-timing-disclosure-exit
--oc-motion-timing-surface
--oc-motion-timing-surface-exit
--oc-motion-timing-sheet
--oc-motion-timing-sheet-exit
--oc-motion-timing-sheet-release
--oc-motion-timing-expand
--oc-motion-timing-expand-exit
--oc-motion-timing-move
--oc-motion-timing-gesture
--oc-motion-timing-gesture-release
--oc-motion-timing-ambient
--oc-motion-timing-continuous
```

spring-backed Sass `@include oc-motion(...)`은 `--oc-motion-duration-sheet` 같은 intent duration token을 직접 읽어 curve를 다시 만들지 않습니다. spring timing curve는 Sass compile-time의 semantic duration으로 샘플링하고, 출력 transition-duration에는 settle duration, `--oc-motion-duration-factor`, `--oc-motion-pace-*`, `$duration-factor`만 남깁니다. 개별 `--oc-motion-duration-*` token은 non-spring path, reduced-motion fallback, `oc-motion-duration(...)`, TS runtime token snapshot에서 사용합니다.

TS runtime `ocMotion.resolve(...)`도 동일하게 element의 computed CSS custom property를 읽어 duration을 계산합니다. `extraBounce` option은 Sass `$extra-bounce`와 같은 정책을 따르며 spring-backed motion에서만 사용할 수 있습니다. reduced-motion 환경에서는 `preserve` 정책만 `--oc-motion-duration-factor-preserve`를 사용하고, `fade`와 `feedback` 정책은 `--oc-motion-duration-factor-reduced`를 사용합니다. 일반 content show/hide는 `fade`라 reduced-motion에서도 opacity fade만 남깁니다. Accordion처럼 `block-size` 자체를 움직이는 `expand` motion은 reduced-motion에서 즉시 끝냅니다. Menu/Tooltip은 `disclosure`, Popup Modal은 `surface`라 reduced-motion에서도 opacity fade만 남깁니다. Bottom Modal의 open/drag release처럼 sheet 위치 자체가 인터랙션의 의미인 경우에는 별도 product invariant로 사용자 설정과 무관하게 동일한 공간 이동을 유지합니다.

## `oc-motion`과 `oc-spring`

대부분의 컴포넌트 transition은 `oc-motion`을 사용합니다.

```scss
.content {
  @include oc-motion((opacity, transform), surface);
}
```

직접 spring의 preset, bounce, physical parameter를 설계해야 하는 낮은 수준의 motion은 `oc-spring`을 사용합니다.

```scss
@use '@orioncactuscorp/ui/scss/mixins/spring' as *;

.custom-sheet {
  transition: oc-spring(transform, 0.5s, bouncy, 0.1);
}
```

`oc-spring`을 사용할 때도 reduced-motion 정책은 직접 작성해야 합니다. public 컴포넌트에서는 특별한 이유가 없다면 `oc-motion`에 새 intent/profile을 추가하는 쪽이 장기적으로 더 유지보수하기 쉽습니다.

자세한 low-level spring 사용법은 [Spring Motion](./spring.md)을 참고하세요.

## 권장 패턴

```scss
// Good: intent와 reduced-motion 정책을 중앙에서 관리합니다.
.notice {
  @include oc-motion(opacity, fade);
}

// Good: 작은 floating surface는 disclosure intent를 사용합니다.
.menu {
  @include oc-motion((opacity, transform), disclosure);
}

// Good: disclosure translate 거리는 distance factor token으로 전역 조정할 수 있습니다.
.menu[data-starting-style] {
  transform: scale(oc-motion-scale(0.82, disclosure))
    translateY(oc-motion-distance(-1rem, disclosure));
}

// Good: exit은 phase로 표현합니다.
.menu[data-ending-style] {
  @include oc-motion((opacity, transform), disclosure, $phase: exit);
}

// Good: modal 형태별 motion intent를 나눕니다.
.popup-modal {
  @include oc-motion((opacity, transform), surface, $phase: enter);
}

.bottom-sheet {
  @include oc-motion(transform, sheet, $phase: enter);
}

.accordion-panel {
  @include oc-motion(block-size, expand, $phase: enter);
}

// Good: 단순 feedback은 feedback intent를 사용합니다.
.button {
  @include oc-motion((background-color, color, box-shadow), feedback);
}

// Good: 거부된 시도 후 surface shake는 reject duration/timing을 사용합니다.
.dialog {
  --dialog-reject-distance: #{oc-motion-distance(0.75rem, reject)};

  animation: oc-dialog-reject-shake oc-motion-duration(reject)
    oc-motion-timing(reject) both;
}
```

## 피해야 할 패턴

```scss
// Avoid: 같은 disclosure motion이 컴포넌트마다 다른 raw easing으로 흩어집니다.
.tooltip {
  transition:
    opacity 0.2s ease,
    transform 0.2s cubic-bezier(0.2, 0, 0, 1);
}

// Avoid: public 컴포넌트가 raw spring과 reduced-motion 정책을 따로 관리합니다.
.menu {
  transition: oc-spring((opacity, transform), 0.3s, snappy);
}

// Avoid: pace 대신 숫자 scale만 공유 vocabulary처럼 사용합니다.
.surface {
  @include oc-motion(opacity, surface, $duration-factor: 1.35);
}
```

## React에서 사용자 모션 설정 읽기

```tsx
import { usePrefersReducedMotion } from '@orioncactuscorp/ui/react/motion';

function Preview() {
  const reducedMotion = usePrefersReducedMotion();
  // Use this preference with the surface's motion intent policy.
  return <span>{reducedMotion ? '모션 최소화' : '일반 모션'}</span>;
}
```

`usePrefersReducedMotion()`은 boolean을 반환하고 OS/브라우저 설정 변경을 구독합니다. SSR와 최초 hydration은 `true`를 사용하며, hydration 이후 실제 설정으로 갱신합니다. `matchMedia`가 없는 환경도 `true`입니다. 설정 확인 전 선택적 모션을 시작하지 않는 보수적 초기값입니다.

이 훅은 선호 감지만 담당합니다. 기존 `ocMotion.resolve`에 `reducedMotion`을 전달해 intent별 정책을 해석하거나, 커스텀 표면이 실행 중인 모션을 정리하는 데 사용하세요. CSS의 reduced-motion 대응은 그대로 유지합니다.

Migration Notes: `MotionExpand`도 이 구독을 사용합니다. `matchMedia` 미지원 환경에서는 모션을 축소하며, 기존 공개 props는 바뀌지 않습니다.

## 테스트 팁

SCSS helper나 public component transition을 바꿀 때는 Sass compilation test로 다음을 확인합니다.

- 기본 transition이 의도한 intent/phase/pace token 또는 spring profile을 사용하는지
- function-only `oc-motion(...)` 호출이 reduced-motion media를 출력하지 않는지
- mixin `@include oc-motion(...)` 호출이 reduced-motion media를 출력하는지
- spring-backed disclosure/surface가 reduced-motion에서 token timing으로 바뀌는지
- spring-backed motion의 `$extra-bounce` override와 non-spring error가 의도대로 동작하는지
- transform이 reduced-motion에서 제거되어야 하는 policy인지
- invalid intent, phase, pace, reduced policy, negative duration factor이 compile error로 실패하는지

컴포넌트 테스트에서는 selector와 상태별 CSS 결과를 함께 확인합니다. Menu, Tooltip처럼 Base UI의 `data-starting-style`, `data-ending-style`, `data-side`에 의존하는 컴포넌트는 helper 출력과 handwritten override의 cascade 관계도 테스트 또는 주석으로 남깁니다.

## Reveal: 콘텐츠의 한 번 등장

```tsx
import { Reveal, RevealProvider } from '@orioncactuscorp/ui';

<RevealProvider trigger='in-view' variant='slide'>
  <Reveal>
    <Benefits />
  </Reveal>
  <Reveal variant='blur'>
    <Highlight />
  </Reveal>
</RevealProvider>;
```

Provider 없이도 사용할 수 있습니다. 기본값은 `trigger="mount"`, `variant="fade"`, `pace="normal"`, `disabled={false}`입니다. Provider는 지정한 기본값만 상속·재정의하며 DOM을 만들지 않습니다. 상위 `disabled`는 하위에서 해제할 수 없습니다.

로딩은 앱이 관리합니다. `isLoading ? <Skeleton /> : <Reveal><Content /></Reveal>`로 조합합니다. Reveal은 fetch, 이미지 decode, 폰트, 라우팅 완료를 감지하지 않습니다.

- `fade`: opacity, `reveal` intent
- `slide`: opacity + 작은 아래→위 이동, `reveal` intent
- `blur`: opacity + 더 짧은 이동 + 약한 blur 해제, `reveal` intent
- slide 거리는 `--oc-space-item-gap-tiny`, blur 거리는 `--oc-space-item-gap-micro`에 spatial distance factor를 곱합니다. blur 반경은 `--oc-atomic-blur-soft`의 1/8입니다.
- 세 효과는 `reveal` intent의 바운스 없는 `smooth` spring을 사용합니다. 기본 체감 시간은 600ms이며 `quick`은 450ms, `slow`는 810ms입니다. spring이 미세하게 안착하는 꼬리까지 포함한 실제 재생 시간은 더 깁니다. 곡선은 시작 시 샘플링해 WAAPI의 `linear()` easing으로 전달하고 JS 프레임 루프는 만들지 않습니다. `linear()`를 받지 못하는 환경은 동일 intent의 token easing으로 축소합니다. AnimatedNumber의 `move` intent와 글리프 렌더러는 바꾸지 않습니다.
- reduced-motion에서는 이동·blur 없이 기존 `fade` intent의 짧은 opacity 전환만 사용합니다.

SSR HTML은 보이는 상태입니다. hydration 때 이미 viewport에 있는 콘텐츠와 초기 서버 `mount` 콘텐츠는 다시 숨기지 않습니다. 따라서 첫 화면 효과를 위해 hydration이나 데이터 표시를 기다리지 않습니다. hydration 이후 새로 마운트되는 콘텐츠는 등장 효과를 실행합니다. viewport 밖의 `in-view` 콘텐츠만 브라우저에서 대기 상태로 준비합니다.

in-view의 기본 시작점은 대상 상단이 viewport 높이의 60% 지점을 통과할 때입니다. oc-ui ScrollArea 안에서는 해당 Container의 높이를 기준으로 자동 적용합니다. 짧은 문서나 문서 끝처럼 시작점까지 스크롤할 수 없는 경우에는 보이는 콘텐츠를 즉시 표시합니다. 알 수 없는 clipping·fixed·sticky 배치와 ScrollArea 밖으로 렌더된 포털도 콘텐츠 접근을 우선합니다. 이 보정은 in-view에 적용하며 mount 연출에는 진입 관찰을 연결하지 않습니다. iframe 안에서도 콘텐츠가 속한 문서의 viewport를 기준으로 판단합니다. 별도의 root 옵션이나 임의 DOM의 스크롤 영역 추론은 제공하지 않습니다.

명시한 `threshold`는 기존 면적 교차 비율(0~1) 모드를 선택하며, 60% 시작점과 중복 적용하지 않습니다. 화면보다 높거나 넓은 대상은 도달 불가능한 비율을 기다리지 않습니다. Provider의 threshold는 단독 Reveal에만 적용하는 기존 호환 옵션이며 deprecated입니다. 새 구성은 기본 진입 정책을 권장합니다. 등장 완료 후에는 재생하지 않으며 다시 실행하려면 소비자가 의도적으로 remount합니다. 중첩 Reveal은 효과를 중복하지 않습니다.

대기 상태에서도 DOM·레이아웃·접근성 트리는 유지합니다. 자식에 포커스가 들어오거나 hash 대상이 있으면 즉시 표시합니다. 인쇄에서는 모두 표시합니다. API 미지원·초기화 실패는 즉시 표시로 처리하고, 비활성화나 reduced-motion 변경 시 진행 중 효과를 정리합니다. 전역 factor와 pace token은 효과 시작 시 읽으며 순수 CSS token 변경만으로 진행 중 WAAPI를 재시작하지 않습니다.

Reveal은 native props와 React 19 ref를 전달하는 `div`입니다. flex/grid의 직접 자식 구조를 고려해 배치하고, `ul`·`table` 같은 구조 안에 부적절한 wrapper를 넣지 마세요. fixed 자손이나 overlay owner를 감싸지 말고 내용 영역에 적용하세요. 진행 중 transform/filter가 containing block과 stacking context에 영향을 줄 수 있습니다. 완료하면 효과를 제거해 원래 스타일로 돌아갑니다.

첫 화면의 핵심 제목·CTA·가입 폼·검증 오류는 즉시 표시하는 구성을 권장합니다. blur는 짧고 작은 강조 요소에 선택적으로 사용하세요. 넓은 본문·이미지 전체의 blur 성능은 대상 기기에서 확인해야 합니다. Reveal은 opacity·transform·filter를 효과 동안 소유하므로 같은 루트에 소비자 animation을 중첩하지 마세요.

### 표시 상태로 제어하기

```tsx
<Reveal visible={filtersOpen} variant='slide' onExitComplete={handleExit}>
  <Filters />
</Reveal>
```

`visible`을 지정하면 viewport/mount 조건 대신 표시 상태를 제어합니다. `trigger`·`threshold`와 함께 지정할 수 없으며 Provider의 trigger를 따르지 않습니다. 표시할 때 등장하고, 숨길 때 smooth 퇴장 후 `hidden`과 `display: none`을 적용합니다. 내부 콘텐츠는 계속 마운트되어 입력값과 React 상태를 보존합니다. DOM 제거가 필요하면 `onExitComplete`에서 소비자가 처리합니다. 최초 `visible={false}` 렌더에서는 완료 콜백을 호출하지 않습니다.

퇴장은 기본 체감 450ms의 `reveal exit` 정책을 사용하며, `pace`와 전역 배율을 따릅니다. 닫기 시작부터 루트에 `inert`·`aria-hidden`을 적용해 보이지 않는 입력을 막습니다. 다시 열면 현재 opacity·transform·filter에서 반전하며, 중단된 퇴장 완료 콜백은 실행하지 않습니다. nested Reveal도 controlled visibility는 존중하지만 중첩 motion은 생략합니다. `disabled`는 애니메이션만 생략하며 `visible={false}` 콘텐츠를 강제로 표시하지 않습니다.

Reveal은 트리거를 소유하지 않습니다. 닫는 패널 안에 포커스가 있으면 소비자가 `visible`을 바꾸기 전에 트리거로 포커스를 돌려주세요. `aria-expanded`·`aria-controls`도 해당 트리거에 지정합니다. 팝오버·메뉴는 기존 위젯의 개폐·포커스 계약을 사용하고, 문서 흐름의 높이 변화가 필요하면 `MotionExpand` 등 layout owner를 사용하세요. Reveal은 높이를 보간하지 않습니다. native `hidden` 대신 `visible`로 표시 상태를 제어합니다.

### 본문과 카드의 등장 흐름

```tsx
'use client';

import { RevealGroup } from '@orioncactuscorp/ui';

<RevealGroup variant='fade'>
  <RevealGroup.Content>
    <h2>여행의 준비를 한눈에</h2>
    <p>나에게 맞는 보장을 살펴보세요.</p>
  </RevealGroup.Content>
  <RevealGroup.Items className={styles.cards}>
    {cards.map(card => (
      <Card key={card.id} {...card} />
    ))}
  </RevealGroup.Items>
</RevealGroup>;
```

compound 표기(`RevealGroup.Content`·`RevealGroup.Items`)는 Client Component 안에서 사용합니다. Server Component에서 조합할 때는 같은 슬롯을 제공하는 `RevealGroupContent`·`RevealGroupItems`를 각각 import하세요.

RevealGroup은 공통 모션 정책과 본문·카드 영역의 등장 관계를 소유합니다. Content 하나와 Items 하나를 같은 Group 안에 배치하세요. 소비자는 본문과 항목의 역할만 선언하며, 지연값을 항목별로 계산하지 않습니다. variant·pace·disabled는 Provider 기본값을 따릅니다. trigger는 Group에서 기본 in-view이며 mount로 명시할 수 있습니다. 명시적인 trigger는 인스턴스, 가장 가까운 Provider, 역할별 기본값 순서로 결정합니다. Provider가 trigger를 생략하면 단독 Reveal은 mount, Group은 in-view를 유지합니다. Provider의 threshold는 Group에 적용하지 않습니다.

in-view에서는 Content와 Items의 상단이 각각 viewport 높이의 60% 지점에 진입하면 해당 영역을 시작합니다. Group 외곽은 별도 진입점을 만들지 않습니다. 별도의 scroll listener나 매 프레임 측정 없이 공유 IntersectionObserver를 사용하고, resize 때 높이 기준 감지 영역을 갱신합니다. Items 안의 카드들은 하나의 시계를 공유합니다. Content 없이 Items만 사용하면 카드 영역의 진입 조건만 적용합니다. Content가 비어 있거나 숨겨져 있거나 시각적으로 카드 아래에 배치된 경우에는 카드 접근을 막지 않고 표시를 우선합니다.

본문은 자기 진입선에서 시작합니다. 카드는 자기 영역의 진입 조건과 본문의 체감 등장 시간 이후 최소 여백을 모두 충족하면 시작합니다. 화면 아래에 있는 카드의 시차를 미리 소진하지 않으며, 본문 이후 시간이 충분히 지났다면 카드 영역 진입 즉시 첫 항목을 시작합니다. spring의 미세한 안착 꼬리를 기다리지 않습니다.

| pace   | 본문 이후 여백 | 항목 간격 | 최대 추가 시차 |
| ------ | -------------- | --------- | -------------- |
| quick  | 60ms           | 120ms     | 360ms          |
| normal | 80ms           | 160ms     | 480ms          |
| slow   | 108ms          | 216ms     | 648ms          |

필요한 값만 `timing`으로 재정의할 수 있습니다. 생략한 값은 기존 정책을 유지합니다.

```tsx
<RevealGroup
  pace='normal'
  timing={{ duration: 1100, contentGap: 500, stagger: 180 }}
>
  {/* Content와 Items */}
</RevealGroup>
```

세 값은 모두 유한한 0 이상 숫자(ms)입니다. duration은 smooth의 기본 체감 등장 시간을 대체하고 pace와 전역 duration factor를 곱합니다. 예를 들어 duration 1,100ms에 quick의 기본 배율을 적용하면 체감 825ms입니다. spring의 실제 안착 시간은 더 깁니다. contentGap과 stagger도 normal pace 기준값이며 pace와 전역 duration factor를 동일하게 곱합니다. 기본값은 각각 80ms와 160ms입니다. 위 표는 기본 pace token과 전역 factor 1 기준입니다. 0도 허용합니다. duration 0 또는 전역 factor 0이면 대기를 포함한 전체 Group 효과를 생략합니다. reduced-motion·포커스·빠른 스크롤의 즉시 표시 정책은 커스텀 값보다 우선합니다. 실행 중 timing·pace·variant를 바꿔도 이미 시작한 자동 타임라인은 재생하지 않으며 다음 실행은 remount로 시작합니다. 별도의 순서 옵션 없이 자식의 작성 순서를 따릅니다.

여백과 시차는 독서 완료 시간의 추정이 아닌 일정한 연출 정책입니다. Content와 Items가 함께 진입하면 normal의 첫 항목은 기본 모션에서 본문 시작 후 최소 680ms, 네 번째 항목은 1,160ms에 시작합니다. Items가 나중에 진입하면 이미 경과한 본문 대기를 다시 기다리지 않고, 그 시점부터 항목 간격만 적용합니다. 본문 모션 시간은 전역 duration factor와 pace token을 따르며, 여백과 시차도 같은 배율을 따릅니다. 다섯 번째 이후 항목은 네 번째와 함께 시작해 큰 목록의 누적 대기를 제한합니다. 실행 후 추가된 항목은 이미 경과한 시간을 다시 기다리지 않습니다. 필터 등 즉시 응답해야 하는 상태 전환은 Reveal visible을 직접 사용하세요.

SSR에서 실제 Content가 이미 보이는 Group은 숨기지 않습니다. 이 예외는 최초 hydration에만 적용하며 외곽 padding으로 판정하지 않습니다. 빠른 스크롤로 각 영역을 지나치면 해당 영역의 남은 대기를 생략합니다. 본문이 화면 위로 지나가도 아래 카드 영역을 미리 표시하지 않습니다. 내부로 포커스·hash 이동을 하면 Group 전체의 남은 대기를 생략합니다. 처음부터 reduced-motion이면 Group의 지연·이동·blur 없이 짧은 opacity fade만 사용합니다. 실행 중 reduced-motion 설정을 바꾸면 즉시 완료하고 다시 재생하지 않습니다. 인쇄·API 미지원·초기화 실패·disabled도 즉시 표시합니다. 한 번 시작한 Group은 역스크롤이나 일반 rerender로 재생하지 않으며, 다시 실행하려면 의도적으로 remount합니다. Reveal 안의 Group과 Group 안의 Group은 추가 순차 연출을 생략합니다.

Group, Content, Items와 각 항목의 motion wrapper는 div입니다. Items의 직접 자식 하나가 항목 하나이며 Fragment나 중간 wrapper 내부를 탐색하지 않습니다. 각 자식에 안정된 key를 지정하고 grid/flex는 Items에 적용하세요. ul·table 구조에는 삽입하지 말고 문서 구조에 맞는 외부 컨테이너에서 사용하세요. 내부 DOM이나 글자 수로 콘텐츠 역할과 독서 시간을 추측하지 않습니다.

개별 요소의 시차만 필요하면 Reveal의 delay(ms)를 사용할 수 있습니다. 유한한 0 이상 숫자만 받으며 퇴장·빠른 반전·reduced-motion에서는 무시합니다. delay에는 전역 duration factor를 곱하며 pace로 재조정하지 않습니다. Provider에는 delay를 두지 않습니다.
