# AI 꾸러미 — 젤리 단추 (누름 깊이 · 출렁) — Jelly button (squash-and-stretch press)
> 단추를 그림자 판(아래) · 얼굴(위) 두 겹으로 두고, 누르면 얼굴이 쑥 내려가고 떼는 순간 튕겨 오르며 가로세로로 출렁이는 젤리 단추를 스프링 둘로 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i206

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

## 주문서

### 만들어 줘: 젤리 단추 (누름 깊이 · 출렁) — Jelly button (squash-and-stretch press)

#### 1. 목표
숫자 키패드 (1 · 2 · 3)을(를) 젤리 단추로 만들어 줘 — 그림자 판 위 얼굴이 누르면 쑥 들어가고 떼면 튕겨 출렁이게, 스프링 둘로. 분위기는 말랑한 사탕 색.

#### 2. 핵심 기술 용어
- **Jelly button (squash-and-stretch press)** — 누르면 들어가고 떼면 출렁이는 단추
- **Two-layer button (face + base)** — 얼굴 · 그림자 판 두 겹 — 누름 깊이가 보임
- **Spring (stiffness · damping)** — 단단함 · 감쇠 스프링으로 따라가기

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 두 겹 (그림자 판 + 얼굴) — 판은 얼굴보다 어두운 같은 색
- 깊이는 단단한 스프링, 출렁임은 무른 스프링 — 둘을 따로
- 떼는 순간 탄성 스프링 속도에 튕김(+1.6)을 더한다
- transform-origin 은 아래 가운데, 움직임은 transform 만
- pointerdown / pointerup / pointerleave + touch-action: none — 실제 손가락으로 눌러 확인

#### 5. 완성 기준 (이게 보이면 성공)
- 아래 젤리 단추를 누르면 얼굴이 그림자 판 위로 쑥 내려가며 살짝 납작해진다
- 떼면 위로 톡 튀며 가로세로로 두세 번 출렁이고 멈춘다
- 위 「끔」 단추는 색만 바뀌어 차이가 바로 보인다
- 「출렁임 단단함」 을 낮추면 더 말랑하게, 높이면 짧게 출렁인다

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

## 원리
- 단추 = 어두운 그림자 판(아래로 3.6 비켜 놓음) + 밝은 얼굴. 얼굴이 내려가면 판이 가려져 「눌렸다」 가 보인다.
- 깊이 스프링 d: 누르면 목표 3.6, 떼면 0 (단단함 1500 · 감쇠 45 — 빠르고 덜 출렁).
- 탄성 스프링 e: 누르면 목표 −0.09 (납작), 떼면 0 (단단함 900 · 감쇠 13 — 잘 출렁). 떼는 순간 속도에 +1.6 을 더해 튕겨 오른다.
- 얼굴 transform = translateY(d) scale(1 − 0.9e, 1 + e), 축은 아래 가운데 — 세로로 눌리면 가로로 퍼진다.

## 핵심 코드 — 깊이 · 탄성 스프링 둘 + 떼는 순간 튕김
(발췌: demos/demosJuice.ts spring() · D206 의 update 를 정리)
```ts
/** 목표를 따라가는 스프링 — 3조각으로 나눠 적분 */
function spring(s: { x: number; v: number }, target: number, k: number, c: number, dt: number): void {
  const h = dt / 3;
  for (let i = 0; i < 3; i++) { s.v += ((target - s.x) * k - s.v * c) * h; s.x += s.v * h; }
}
// CSS: .face { transform-origin: 50% 100% }  .base { top: 3.6cqmin; bottom: -3.6cqmin; background: 어두운 같은 색 }
const st = keys.map(() => ({ d: { x: 0, v: 0 }, e: { x: 0, v: 0 }, down: false }));
let K = 900; // 출렁임 단단함

function update(dt0: number) {
  const dt = Math.min(dt0, 0.04);
  keys.forEach((_, i) => {
    const s = st[i], down = pressed[i];          // pointerdown / up / leave 로 바뀜
    if (s.down && !down) s.e.v += 1.6;           // 떼는 순간 튀어 오름
    s.down = down;
    spring(s.d, down ? 3.6 : 0, 1500, 45, dt);   // 깊이: 단단하게
    spring(s.e, down ? -0.09 : 0, K, 13, dt);    // 탄성: 무르게 — 출렁임
    const e = s.e.x;
    faces[i].style.transform =
      'translateY(' + s.d.x.toFixed(2) + 'cqmin) scale(' + (1 - e * 0.9).toFixed(4) + ',' + (1 + e).toFixed(4) + ')';
  });
}

el.addEventListener('pointerdown', (e) => { e.preventDefault(); pressed[i] = true; });
el.addEventListener('pointerup', () => (pressed[i] = false));
el.addEventListener('pointerleave', () => (pressed[i] = false));
```

## 흔한 실수 · 확인 목록
- [ ] **스프링 하나로 깊이와 출렁임을 같이 하면 깊이까지 출렁여 흐물거린다** — 깊이는 단단한 스프링(1500 · 45), 모양 출렁임은 무른 스프링(900 · 13)으로 나눈다.
- [ ] **click 만 들으면 누르는 동안 들어가 있는 모습이 없다** — pointerdown 에 들어가고 pointerup · pointerleave 에 나오게. 폰에선 touch-action: none.
- [ ] **합성 이벤트로만 시험하면 실제 손가락에서 안 되는 것을 놓친다** — 실제 클릭 · 터치로 눌러 확인한다 (사이트에서 여러 번 오진).
- [ ] **웹 단추처럼 보이는 평평한 단추는 게임 화면에서 어색하다** — 판이 주인공인 게임 UI 는 두께 · 눌림이 있는 단추로 (사용자 지적).

## 완성 기준 체크리스트
- [ ] 아래 젤리 단추를 누르면 얼굴이 그림자 판 위로 쑥 내려가며 살짝 납작해진다
- [ ] 떼면 위로 톡 튀며 가로세로로 두세 번 출렁이고 멈춘다
- [ ] 위 「끔」 단추는 색만 바뀌어 차이가 바로 보인다
- [ ] 「출렁임 단단함」 을 낮추면 더 말랑하게, 높이면 짧게 출렁인다

## 이 기술 정보
- id: `i206` · 분류: 이펙트 · 연출 › 손맛 · 주스 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 단추마다 스프링 둘 · transform 하나. 합성만 하니 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i206
- 쓰면 좋을 때: 아이들이 누르는 큰 단추 · 키패드 / 누른 느낌이 중요한 게임 화면 단추 (웹 단추처럼 보이면 안 될 때)
- 쓰지 말 때: 작은 아이콘 단추 · 목록 줄 — 출렁임이 산만하다. 대신 색 · 눌림만 / 빠르게 연달아 누르는 곳 — 출렁임이 쌓여 흔들린다 (단단함을 높인다)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### D206 — `src/demos/demosJuice.ts:2298`
```ts
const D206: Demo = {
  kind: 'dom',
  caption: '숫자 키 — 위는 색만 바뀌는 단추, 아래는 그림자 위로 쑥 들어갔다 떼면 젤리처럼 출렁 (눌러 봐도 됨)',
  make(box) {
    const KEYS = ['1', '2', '3'];
    const COLS = ['#ff6b8a', '#ffa94d', '#4dabf7'];
    const btn = (on: boolean): string =>
      KEYS.map(
        (k, i) =>
          `<div class="b ${on ? 'jb' : 'pb'}" data-i="${i}" style="--c:${COLS[i]};--c2:${shade(COLS[i]!, -0.42)};--c3:${shade(COLS[i]!, 0.35)}">${on ? `<div class="base"></div><div class="face"><i></i><span>${k}</span></div>` : `<span>${k}</span>`}</div>`,
      ).join('');
    const root = domRoot(
      box,
      'jx-jelly',
      `.jx-jelly{background:radial-gradient(120% 90% at 50% 0%,#3d3a8f,#141235)}
       .jx-jelly .row{position:absolute;left:0;right:0;display:flex;align-items:center;justify-content:center;gap:6cqmin}
       .jx-jelly .r0{top:12%;height:36%}.jx-jelly .r1{top:54%;height:40%}
       .jx-jelly .tag{position:absolute;left:4cqmin;top:50%;transform:translateY(-50%);font-size:5.2cqmin;font-weight:800;padding:1cqmin 2.6cqmin;border-radius:9cqmin;background:rgba(10,12,40,.75);color:#a9b3dc}
       .jx-jelly .tag.on{background:#ffcd3c;color:#2a1a00}
       .jx-jelly .b{position:relative;width:24cqmin;height:22cqmin;cursor:pointer;touch-action:none}
       .jx-jelly .pb{border-radius:5cqmin;background:var(--c);display:grid;place-items:center;font-size:12cqmin;font-weight:900;color:#fff}
       .jx-jelly .pb.dn{background:var(--c2)}
       .jx-jelly .jb .base{position:absolute;left:0;right:0;top:3.6cqmin;bottom:-3.6cqmin;border-radius:6cqmin;background:var(--c2);box-shadow:0 2.4cqmin 3cqmin rgba(0,0,0,.45)}
       .jx-jelly .jb .face{position:absolute;inset:0;border-radius:6cqmin;background:linear-gradient(180deg,var(--c3),var(--c) 70%);display:grid;place-items:center;transform-origin:50% 100%;box-shadow:inset 0 -1cqmin 0 rgba(0,0,0,.12)}
       .jx-jelly .jb .face i{position:absolute;left:14%;right:14%;top:8%;height:24%;border-radius:9cqmin;background:rgba(255,255,255,.4)}
       .jx-jelly .jb span{position:relative;font-size:12cqmin;font-weight:900;color:#fff;-webkit-text-stroke:.7cqmin var(--c2);paint-order:stroke}
       .jx-jelly .tap{position:absolute;width:7cqmin;height:7cqmin;border-radius:50%;background:rgba(255,255,255,.75);box-shadow:0 0 0 1.4cqmin rgba(255,255,255,.25);pointer-events:none;transition:opacity .15s}`,
      `<div class="row r0"><span class="tag">끔</span>${btn(false)}<div class="tap"></div></div>
       <div class="row r1"><span class="tag on">켬</span>${btn(true)}<div class="tap"></div></div>`,
    );
    const flat = qa(root, '.pb');
    const jel = qa(root, '.jb');
    const faces = qa(root, '.jb .face');
    const taps = qa(root, '.tap');
    const st = jel.map(() => ({ d: { x: 0, v: 0 }, e: { x: 0, v: 0 }, down: false }));
    const manual = [false, false, false];
    let manualAt = -99;
    let now = 0;
    const press = (i: number, v: boolean): void => {
      manual[i] = v;
      manualAt = now;
    };
    const handlers: [HTMLElement, string, EventListener][] = [];
    [...flat, ...jel].forEach((el) => {
      const i = Number(el.dataset.i);
      const dn = (e: Event): void => {
        e.preventDefault();
        press(i, true);
      };
      const up = (): void => press(i, false);
      for (const [n, f] of [
        ['pointerdown', dn],
        ['pointerup', up],
        ['pointerleave', up],
      ] as [string, EventListener][]) {
        el.addEventListener(n, f);
        handlers.push([el, n, f]);
      }
      el.addEventListener('click', (e) => e.preventDefault());
    });
    let K = 900;
    return {
      update(t, dt0) {
        now = t;
        const dt = Math.min(dt0, 0.04);
        const auto = t - manualAt > 2;
        const ph = t % 2.4;
        let active = -1;
        for (let i = 0; i < 3; i++) {
          const a = ph - 0.2 - i * 0.7;
          const down = auto ? a >= 0 && a < 0.24 : manual[i]!;
          if (auto && a >= -0.3 && a < 0.5) active = i;
          flat[i]!.classList.toggle('dn', down);
          const s = st[i]!;
          if (s.down && !down) s.e.v += 1.6; // 떼는 순간 튀어 오름
          s.down = down;
          spring(s.d, down ? 3.6 : 0, 1500, 45, dt);
          spring(s.e, down ? -0.09 : 0, K, 13, dt);
          const e = s.e.x;
          faces[i]!.style.transform = `translateY(${s.d.x.toFixed(2)}cqmin) scale(${(1 - e * 0.9).toFixed(4)},${(1 + e).toFixed(4)})`;
        }
        taps.forEach((tp, r) => {
          if (active < 0 || !auto) {
            tp.style.opacity = '0';
            return;
          }
          const el = (r === 0 ? flat : jel)[active]!;
          const a = ph - 0.2 - active * 0.7;
          const dn = a >= 0 && a < 0.24;
          tp.style.opacity = '1';
          tp.style.left = `${el.offsetLeft + el.offsetWidth * 0.62}px`;
          tp.style.top = `${el.offsetTop + el.offsetHeight * 0.55}px`;
          tp.style.transform = `translate(-50%,-50%) scale(${dn ? 0.7 : 1})`;
        });
      },
      controls: [rng('출렁임 단단함', 300, 2000, 50, K, (v) => (K = v))],
      dispose() {
        for (const [el, n, f] of handlers) el.removeEventListener(n, f);
      },
    };
  },
};
```

### spring — `src/demos/demosJuice.ts:2288`
```ts
function spring(s: { x: number; v: number }, target: number, k: number, c: number, dt: number): void {
  const n = 3;
  const h = dt / n;
  for (let i = 0; i < n; i++) {
    s.v += ((target - s.x) * k - s.v * c) * h;
    s.x += s.v * h;
  }
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [스프링 · 예비 동작 · 찌그러짐](https://ai-techstudio.web.app/ai/t/i102.md) `i102`
- 다음에 해 볼 기술: [흔들흔들 거부 (고개 젓기)](https://ai-techstudio.web.app/ai/t/i207.md) `i207` · [동전 빨려 들기 (UI 로 날아가기)](https://ai-techstudio.web.app/ai/t/i208.md) `i208`
