# AI 꾸러미 — 폰 틀 목업 (기울어진 폰 · 화면 속 스크롤 · 손가락 표시) — Device mockup (perspective · rotateY)
> 3D 로 살짝 기운 폰 틀 속에서 화면이 손가락을 따라 밀리고 놓으면 관성으로 흐르며, 맨 위에서 당기면 고무줄처럼 덜 따라오다 새로고침 — 유리 반사 · 그림자가 기울기를 따라간다.  
> 견본: https://ai-techstudio.web.app/#t/i559

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 폰 틀 목업 (기울어진 폰 · 화면 속 스크롤 · 손가락 표시) — Device mockup (perspective · rotateY)

#### 1. 목표
게임 소개 영상의 폰 화면을 보여 주는 폰 틀 목업 애니메이션을 만들어 줘 — 기울어진 폰 화면에서 손가락이 밀면 관성으로 흐르고, 눌러 고르고, 위에서 당기면 고무줄처럼 늘어나 새로고침. 분위기는 어두운 보라 바탕 · 밝은 앱.

#### 2. 핵심 기술 용어
- **Device mockup (perspective · rotateY)** — CSS 3D 로 기울인 폰 틀 — 소개 영상 · 스토어 그림의 기본
- **Momentum scrolling (exponential decay)** — 놓은 속도 × τ × (1 − e^(−t/τ)) 만큼 더 흐르다 멈춤
- **Rubber-band overscroll** — 끝을 넘겨 당기면 점점 덜 따라오는 넘침 — (1 − 1/(x·0.55/d + 1))·d
- **Pull to refresh** — 위에서 끌어내리면 도는 표시가 나오고 놓으면 새로고침
- **Touch indicator** — 시연 영상에서 손가락 자리를 보여 주는 반투명 동그라미

#### 3. 환경
- 플랫폼: TypeScript + HTML · CSS · SVG (DOM), 라이브러리 없이 — 움직임은 transform · opacity 로
- 화면: 브라우저 — PC 와 폰(390px 폭) 모두, 60fps 목표
- 틀은 perspective 를 준 부모 안에서 rotateY · rotateX, 화면 속 내용은 translateY 만. 대본(시각 → 스크롤 · 손가락)을 함수 하나로 두면 영상 굽기(i82)와 맞는다.

#### 4. 조건
- 폰 기울기는 작게 (rotateY 30° 안) — 화면 내용이 읽혀야 한다
- 화면 속 움직임은 translateY 로만, 내용은 overflow:hidden 인 둥근 화면 안에
- 관성은 지수 감쇠, 맨 위 넘침은 고무줄 식 — 직선 감속 · 딱 멈춤 쓰지 않기
- 손가락 표시: 누르는 동안 작아지고, 고를 땐 물결 + 고른 칸 강조
- 진행 단계(밀기 · 누르기 · 맨 위로 · 당기기)를 옆 글로 함께 밝히기

#### 5. 완성 기준 (이게 보이면 성공)
- 폰이 천천히 기울며 흔들리고, 그림자와 유리 반사 띠가 기울기를 따라 움직인다
- 손가락이 밀어 올렸다 놓으면 화면이 스르르 더 흐르다 멈춘다
- 보이는 카드를 누르면 물결이 퍼지고 그 카드가 잠깐 파랗게 강조된다
- 맨 위에서 당기면 손가락보다 덜 내려오며 새로고침 표시가 돌고, 놓으면 제자리로 튕긴다
- 「고무줄 넘침」을 끄면 당겨도 화면이 꿈쩍 안 한다 (차이 비교)

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 틀: perspective 를 준 부모 안에서 폰을 rotateY −16° ± 10°, rotateX 6° 로 천천히 흔든다. 그림자는 기울기 반대로 밀고 가로로 조금 줄여 바닥에 붙은 느낌.
- 반사: 화면 위 투명 판에 대각 밝은 띠(그라디언트)를 두고, 기울기에 따라 background-position 을 옮긴다 — 유리가 빛을 받는 듯.
- 관성: 손가락이 D 만큼 끌다 놓은 순간 속도 V 로, 스크롤 = 놓은 자리 + V·τ·(1 − e^(−t/τ)). τ 0.3 ~ 0.5초면 폰처럼 스르르 멈춘다. 끝을 넘으면 막는다.
- 고무줄: 맨 위에서 x 만큼 당기면 화면은 (1 − 1/(x·0.55/d + 1))·d 만 내려온다 (d = 화면 높이). 놓으면 새로고침 표시 높이에서 돌다가 스프링으로 제자리.
- 모든 움직임을 「대본 시각 p」에서 바로 계산한다 — 손가락 자리 · 스크롤 · 표시 단계가 늘 맞고, 되감기 · 영상 굽기가 된다.

## 핵심 코드 — 관성 · 고무줄 식 + 폰 기울기
(발췌: demos/demosMotionE.ts i559 의 at() · update 를 정리)
```ts
// 놓은 뒤 t 초 동안 더 흐른 거리 (지수 감쇠)
const fling = (v: number, t: number, tau = 0.45) => v * tau * (1 - Math.exp(-t / tau));

// 끝을 넘겨 x 만큼 당겼을 때 실제로 내려오는 거리 (d = 화면 높이)
const rubber = (x: number, d: number) => (1 - 1 / ((x * 0.55) / d + 1)) * d;

function frame(clock: number) {
  const s = script(clock % CYCLE);              // 대본 시각 → 스크롤 · 손가락 · 단계
  feed.style.transform = 'translateY(' + (-s.scroll) + 'px)';
  const ry = -16 + Math.sin(clock * 0.55) * 10; // 천천히 흔들리는 기울기
  phone.style.transform = 'rotateY(' + ry + 'deg) rotateX(6deg)';
  shadow.style.transform = 'translateX(' + (-ry * 0.35) + 'px)';
  glare.style.backgroundPosition = (50 + ry * 3.2) + '% 0'; // 반사 띠가 미끄러짐
  finger.style.opacity = String(s.fingerAlpha);
  finger.style.transform = 'scale(' + (s.pressed ? 0.82 : 1) + ')';
}
```

## 흔한 실수 · 확인 목록
- [ ] **폰 위쪽이 화면 밖으로 잘린다** — rotateX 로 기울이면 원근 때문에 위가 커진다. 폰 둘레에 여백(위아래 5% 넘게)을 둔다.
- [ ] **누르는 카드가 이미 화면 밖이다** — 스크롤된 뒤의 자리를 계산하지 않고 원래 자리를 누르면 손가락이 허공을 누른다. 손가락 y = 카드 자리 − 그 순간 스크롤.
- [ ] **관성이 끝에서 딱 끊긴다** — 직선으로 줄이면 멈추는 순간이 티 난다. 지수 감쇠는 끝으로 갈수록 저절로 느려진다.
- [ ] **3D 기울기에서 글자가 흐려진다** — 변형 중엔 래스터 한 장을 늘려 그리기도 한다. 멈춘 장면을 오래 보여 줄 땐 기울기를 0 으로 풀거나 will-change 를 끈다.

## 완성 기준 체크리스트
- [ ] 폰이 천천히 기울며 흔들리고, 그림자와 유리 반사 띠가 기울기를 따라 움직인다
- [ ] 손가락이 밀어 올렸다 놓으면 화면이 스르르 더 흐르다 멈춘다
- [ ] 보이는 카드를 누르면 물결이 퍼지고 그 카드가 잠깐 파랗게 강조된다
- [ ] 맨 위에서 당기면 손가락보다 덜 내려오며 새로고침 표시가 돌고, 놓으면 제자리로 튕긴다
- [ ] 「고무줄 넘침」을 끄면 당겨도 화면이 꿈쩍 안 한다 (차이 비교)

## 이 기술 정보
- id: `i559` · 분류: 이펙트 · 연출 › 모션 그래픽 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — transform 몇 개만 바뀐다. 3D 기울기는 합성 단계에서 처리돼 폰에서도 가볍다. 화면 속 내용이 무거우면 이미지 한 장으로 구워 넣는다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i559
- 쓰면 좋을 때: 게임 · 앱 소개 영상, 스토어 미리 보기, 사용법 안내 / 폰 화면 녹화 대신 깔끔하게 다시 그린 시연이 필요할 때
- 쓰지 말 때: 실제 기기 녹화가 더 믿음직한 곳 (리뷰 · 버그 재현) / 진짜 상표 폰 모양을 그대로 베끼기 — 둥근 상자 + 섬 모양 정도로 일반적으로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### phone559 — `src/demos/demosMotionE.ts:669`
```ts
function phone559(box: HTMLElement): ReturnType<DemoDom['make']> {
  const o = { s: 1, tilt: 1, finger: true, glare: true, rubber: true };
  const COLORS = ['#ff9f6b', '#6bc6ff', '#a98bff', '#5fd39a', '#ffd166', '#ff7aa8', '#7fd1d1'];
  const cards = COLORS.map((c, i) => `<div class="pm-card" style="position:relative"><div class="pm-thumb" style="background:linear-gradient(135deg,${c},${COLORS[(i + 2) % COLORS.length]})"></div><div class="pm-line"></div><div class="pm-line s"></div>${i === 3 ? '<div class="pm-hl"></div>' : ''}</div>`).join('');
  const html = `
<div class="pm-wrap"><div class="pm-shadow"></div><div class="pm-phone">
  <div class="pm-screen"><div class="pm-spin"></div><div class="pm-feed">${cards}</div><div class="pm-status"><span>9:41</span><span>▮▮ ■</span></div><div class="pm-island"></div><div class="pm-glare"></div>
  <div class="pm-finger"></div><div class="pm-ripple"></div></div>
</div></div>
<div class="pm-side"><h4>폰 화면 시연</h4>
  <div class="pm-step"><i></i>밀어 올리기 → 관성</div>
  <div class="pm-step"><i></i>눌러 고르기</div>
  <div class="pm-step"><i></i>맨 위로 돌아가기</div>
  <div class="pm-step"><i></i>당겨서 새로고침</div>
</div>`;
  const S = stage(box, CSS559, html, 'linear-gradient(135deg,#1d2140,#3b2a55)');
  const q = (s: string): HTMLElement => S.st.querySelector(s) as HTMLElement;
  const phone = q('.pm-phone');
  const shadow = q('.pm-shadow');
  const feed = q('.pm-feed');
  const finger = q('.pm-finger');
  const ripple = q('.pm-ripple');
  const spin = q('.pm-spin');
  const glare = q('.pm-glare');
  const hl = q('.pm-hl');
  const steps = Array.from(S.st.querySelectorAll('.pm-step')) as HTMLElement[];
  const SCR_H = 144; // 화면 높이 (무대 단위)
  const MAXS = 7 * 49 + 14 - SCR_H; // 내용 높이 − 화면 높이
  const CYCLE = 7.4;
  let clock = 0;

  /** 지금 시각의 스크롤 · 손가락 · 단계를 대본에서 바로 계산 */
  const at = (p: number) => {
    let scroll = 0;
    let fx = 37;
    let fy = 110;
    let fa = 0;
    let press = 0;
    let rip = -1;
    let pull = 0;
    let spinA = 0;
    let step = -1;
    let sel = 0;
    // ① 밀어 올리기 0.5~1.0 → 놓으면 관성 (속도 × τ × (1 − e^(−t/τ)))
    const D1 = 60;
    const V = D1 / 0.35;
    const TAU = 0.45;
    const sFling = Math.min(MAXS, D1 + V * TAU * (1 - Math.exp(-Math.max(0, p - 1.0) / TAU)));
    if (p < 0.5) scroll = 0;
    else if (p < 1.0) scroll = D1 * seg(p, 0.65, 1.0);
    else scroll = sFling;
    if (p >= 0.4 && p < 1.15) {
      step = 0;
      fa = p < 0.5 ? seg(p, 0.4, 0.5) : p < 1.0 ? 1 : 1 - seg(p, 1.0, 1.15);
      fy = 110 - D1 * seg(p, 0.65, 1.0);
      press = p > 0.55 && p < 1.0 ? 1 : 0;
    }
    if (p >= 1.15 && p < 2.3) step = 0;
    // ② 눌러 고르기 2.4~3.2 (관성이 멈춘 뒤 화면에 보이는 넷째 카드)
    const sAfter = sFling;
    if (p >= 2.3 && p < 3.3) {
      step = 1;
      fx = 40;
      fy = 14 + 50 * 3 + 18 - sAfter;
      fa = seg(p, 2.3, 2.45) * (1 - seg(p, 3.0, 3.2));
      press = p > 2.6 && p < 2.75 ? 1 : 0;
      rip = p > 2.6 ? seg(p, 2.6, 3.0) : -1;
      sel = p > 2.62 ? 1 - seg(p, 3.0, 3.3) : 0;
    }
    // ③ 맨 위로 3.4~4.4 (상태줄 누르기처럼)
    if (p >= 3.3) scroll = sAfter * (1 - inOut(seg(p, 3.4, 4.4)));
    if (p >= 3.3 && p < 4.5) step = 2;
    // ④ 당겨서 새로고침: 4.6~5.3 손가락이 끌어내림 → 고무줄 → 5.3 놓음 → 돌다가 6.4~6.9 제자리
    if (p >= 4.5) {
      step = 3;
      const drag = 70 * seg(p, 4.75, 5.3);
      const pulled = o.rubber ? rubber(drag, SCR_H) : 0;
      if (p < 5.3) pull = pulled;
      else {
        const hold = o.rubber ? Math.min(pulled, 24) : 0;
        const back = 1 - outBack(seg(p, 6.4, 6.9), 1.2);
        pull = p < 5.5 ? lerp(pulled, hold, seg(p, 5.3, 5.5)) : hold * (p < 6.4 ? 1 : back);
      }
      scroll = -pull;
      fx = 44;
      fy = 50 + drag;
      fa = seg(p, 4.5, 4.65) * (1 - seg(p, 5.3, 5.45));
      press = p > 4.7 && p < 5.3 ? 1 : 0;
      spinA = clamp01(pull / 22);
      if (p > 7.0) step = -1;
    }
    return { scroll, fx, fy, fa, press, rip, pull, spinA, step, sel, spin: p >= 5.3 && p < 6.5 };
  };

  return {
    update(_t, dt) {
      clock += Math.min(dt, 0.1) * o.s;
      const p = clock % CYCLE;
      const s = at(p);
      feed.style.transform = `translateY(${(-s.scroll).toFixed(2)}px)`;
      // 폰은 천천히 기울어 흔들림 — 반사 띠는 기울기 따라 미끄러짐
      const ry = (-16 + Math.sin(clock * 0.55) * 10) * o.tilt;
      const rx = (6 + Math.sin(clock * 0.37) * 3) * o.tilt;
      phone.style.transform = `rotateY(${ry.toFixed(2)}deg) rotateX(${rx.toFixed(2)}deg)`;
      shadow.style.transform = `translateX(${(-ry * 0.35).toFixed(2)}px) scaleX(${(1 - Math.abs(ry) / 120).toFixed(3)})`;
      glare.style.opacity = o.glare ? '1' : '0';
      glare.style.backgroundPosition = `${(50 + ry * 3.2).toFixed(1)}% 0`;
      finger.style.opacity = o.finger ? s.fa.toFixed(3) : '0';
      finger.style.left = `${s.fx}px`;
      finger.style.top = `${s.fy}px`;
      finger.style.transform = `scale(${s.press ? 0.82 : 1})`;
      ripple.style.left = `${s.fx}px`;
      ripple.style.top = `${s.fy}px`;
      ripple.style.opacity = s.rip >= 0 ? (1 - s.rip).toFixed(3) : '0';
      ripple.style.transform = `scale(${s.rip >= 0 ? (0.3 + s.rip).toFixed(3) : 0.3})`;
      hl.style.opacity = s.sel.toFixed(3);
      spin.style.opacity = s.spinA.toFixed(3);
      spin.style.transform = `rotate(${(s.spin ? clock * 720 : s.pull * 12).toFixed(1)}deg)`;
      steps.forEach((el, i) => el.classList.toggle('on', i === s.step));
    },
    controls: [
      speedCtl(o),
      { type: 'range', label: '폰 기울기', min: 0, max: 1.6, step: 0.05, value: o.tilt, on: (v) => (o.tilt = v) },
      { type: 'toggle', label: '손가락 표시', value: o.finger, on: (v) => (o.finger = v) },
      { type: 'toggle', label: '유리 반사', value: o.glare, on: (v) => (o.glare = v) },
      { type: 'toggle', label: '고무줄 넘침 (끄면 딱 멈춤)', value: o.rubber, on: (v) => (o.rubber = v) },
    ],
    dispose: S.dispose,
  };
}
```

### i559 견본 항목 — `src/demos/demosMotionE.ts:804`
```ts
  i559: { kind: 'dom', caption: '기울어진 폰 틀 속 화면 — 밀면 관성, 누르면 물결, 위에서 당기면 고무줄 · 새로고침', make: phone559 }
```

## 관련 기술
- 먼저 알면 좋은 기술: [가짜 커서 UI 시연 (커서 대본 · 클릭 물결 · 따라가는 확대)](https://ai-techstudio.web.app/ai/t/i549.md) `i549` · [공유 요소 전환 (FLIP · 목록 → 상세)](https://ai-techstudio.web.app/ai/t/i548.md) `i548`
- 다음에 해 볼 기술: [영상으로 굽기 (헤드리스 브라우저 → mp4)](https://ai-techstudio.web.app/ai/t/i82.md) `i82` · [타임라인 연출 + 프레임 효과음](https://ai-techstudio.web.app/ai/t/i127.md) `i127`
- 참고 문서: [MDN — perspective (CSS)](https://developer.mozilla.org/en-US/docs/Web/CSS/perspective) · [Ariya Hidayat — Kinetic scrolling (지수 감쇠)](https://ariya.io/2013/11/javascript-kinetic-scrolling-part-2)
