# AI 꾸러미 — 알림 · 창 등장 묶음 (쌓이는 알림 · 뒤 흐림 창 · 차례 등장) — Toast notification stack
> 알림은 옆에서 밀고 들어와 먼저 온 것을 아래로 밀고, 창은 뒤를 흐리며 출렁 커진 뒤 내용이 차례로 나온다 — 나갈 땐 들어올 때보다 빠르게.  
> 견본: https://ai-techstudio.web.app/#t/i551

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

## 주문서

### 만들어 줘: 알림 · 창 등장 묶음 (쌓이는 알림 · 뒤 흐림 창 · 차례 등장) — Toast notification stack

#### 1. 목표
게임 플레이 화면 (레벨 업 · 보상) 화면에 쌓이는 알림과 보상 창 등장 연출을 만들어 줘 — 알림은 밀고 들어와 서로 밀어내고, 창은 뒤를 흐리며 출렁 커지고 내용이 차례로. 분위기는 밝은 게임 UI · 통통 튀게.

#### 2. 핵심 기술 용어
- **Toast notification stack** — 잠깐 떴다 사라지는 알림 — 쌓이면 서로 밀어냄
- **Modal dialog with backdrop blur (backdrop-filter)** — 뒤 화면을 흐리고 어둡게 덮는 창
- **Staggered entrance** — 내용을 0.05~0.1초씩 차례로 등장
- **Asymmetric enter / exit timing** — 들어올 땐 느긋하게 · 나갈 땐 빠르게

#### 3. 환경
- 플랫폼: TypeScript + HTML · CSS · SVG (DOM), 라이브러리 없이 — 움직임은 transform · opacity 로
- 화면: 브라우저 — PC 와 폰(390px 폭) 모두, 60fps 목표
- 뒤 흐림은 backdrop-filter: blur() + 반투명 바탕. 흐림 값을 매 프레임 바꾸지 말고 고정해 두고 opacity 로만 나타나게.

#### 4. 조건
- 알림 세로 자리를 「목록 순서 × 높이」로 뚝 바꾸지 말고, 들어오고 나가는 정도(이어진 값)로 계산해 미끄러지게
- 알림에 남은 시간 막대 + 저절로 사라짐. 같은 알림이 겹치면 하나로 묶기(×2)
- 창: 뒤 흐림층 → 창 출렁 → 내용 차례로 (간격 0.05~0.1초). 나갈 때는 0.2초 안에
- backdrop-filter 의 blur 값은 고정하고 opacity 만 애니
- prefers-reduced-motion 이면 출렁임 · 차례 등장을 끄고 흐려지기만

#### 5. 완성 기준 (이게 보이면 성공)
- 알림 셋이 차례로 들어오며 먼저 온 것이 부드럽게 아래로 밀리고, 오래된 것부터 옆으로 빠진다
- 알림 아래 막대가 줄어 사라질 때를 알 수 있다
- 창이 뜰 때 뒤 화면이 흐려지고, 보상 칸 셋이 차례로 통통 튀어나온다 — 간격 조절을 0 으로 하면 한꺼번에 나온다
- 「받기」를 누르면 창이 들어올 때보다 눈에 띄게 빨리 사라진다

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

## 원리
- 알림마다 「있음」 = 들어옴(0→1) × (1 − 나감). 내 세로 자리 = 위 여백 + 알림 높이 × (나보다 늦게 온 알림들의 있음 합) — 새 알림이 들어오는 만큼 부드럽게 밀려 내려간다.
- 알림 아래 막대가 줄어들어 「곧 사라짐」을 보여 준다. 오래된 것(맨 아래)부터 옆으로 빠져나간다.
- 창: 뒤 흐림층이 0.3초 나타나고, 창은 0.8 → 1 배로 출렁(outBack), 내용은 0.08초 간격으로 차례로 튀어나온다.
- 나갈 때는 0.18초로 들어올 때(0.45초)보다 빠르게 — 사용자가 닫기로 한 순간부터는 기다리게 하지 않는다.

## 핵심 코드 — 알림 쌓기 — 「있음」 합으로 세로 자리
(발췌: demos/demosMotionC.ts i551 의 update 를 정리)
```ts
const H = 26, TOP = 8, LIFE = 2.4;
const outCubic = (x: number) => 1 - Math.pow(1 - Math.min(1, Math.max(0, x)), 3);
const seg = (p: number, a: number, b: number) => Math.min(1, Math.max(0, (p - a) / (b - a)));

// toasts: { el, born } — born = 들어온 시각(초). now = 지금 시각
function layout(toasts: { el: HTMLElement; born: number }[], now: number) {
  const pres = toasts.map((t) => outCubic(seg(now, t.born, t.born + 0.4)) * (1 - seg(now, t.born + LIFE, t.born + LIFE + 0.3)));
  toasts.forEach((t, j) => {
    let below = 0;
    for (let k = j + 1; k < toasts.length; k++) below += pres[k];   // 나보다 늦게 온 것만큼 아래로
    const enter = outCubic(seg(now, t.born, t.born + 0.45));
    const leave = seg(now, t.born + LIFE, t.born + LIFE + 0.3);
    t.el.style.transform = 'translate(' + ((1 - enter) * 130 + leave * 130) + 'px,' + (TOP + H * below) + 'px)';
    t.el.style.opacity = String(1 - leave);
  });
}

// 창 내용 차례로: i 번째는 open + 0.3 + i × stagger 에 시작
items.forEach((el, i) => el.animate(
  [{ opacity: 0, transform: 'translateY(8px) scale(.6)' }, { opacity: 1, transform: 'none' }],
  { delay: 300 + i * 80, duration: 350, easing: 'cubic-bezier(.34,1.8,.64,1)', fill: 'backwards' }));
```

## 흔한 실수 · 확인 목록
- [ ] **새 알림이 오면 기존 알림이 뚝 한 칸 내려간다** — 순서 번호로 자리를 정하면 그렇다. 늦게 온 알림들의 「있음」(0~1 이어진 값)을 더해 자리로 쓰면 미끄러진다.
- [ ] **backdrop-filter 의 blur 를 애니하면 폰에서 버벅인다** — 흐림 반경이 바뀔 때마다 다시 계산한다. 반경은 고정, 층의 opacity 만 바꾼다.
- [ ] **들어올 때와 나갈 때를 같은 길이로 하면 굼뜨다** — 닫기는 사용자가 정한 일 — 0.15~0.2초로 빠르게, 출렁임 없이.
- [ ] **차례 등장이 길면 내용을 기다려야 한다** — 간격 × 개수가 0.4초를 넘지 않게. 항목이 많으면 간격을 줄이거나 앞 몇 개만.

## 완성 기준 체크리스트
- [ ] 알림 셋이 차례로 들어오며 먼저 온 것이 부드럽게 아래로 밀리고, 오래된 것부터 옆으로 빠진다
- [ ] 알림 아래 막대가 줄어 사라질 때를 알 수 있다
- [ ] 창이 뜰 때 뒤 화면이 흐려지고, 보상 칸 셋이 차례로 통통 튀어나온다 — 간격 조절을 0 으로 하면 한꺼번에 나온다
- [ ] 「받기」를 누르면 창이 들어올 때보다 눈에 띄게 빨리 사라진다

## 이 기술 정보
- id: `i551` · 분류: 이펙트 · 연출 › 모션 그래픽 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — backdrop-filter 는 덮는 면적만큼 비싸다(화면 전체 흐림 = 폰에서 몇 ms). 흐림 반경을 작게(3~6px) 두고 애니는 opacity 로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i551
- 쓰면 좋을 때: 레벨 업 · 보상 · 업적처럼 「축하」 순간 / 여러 알림이 짧은 사이에 몰려올 때
- 쓰지 말 때: 중요한 오류 · 확인이 필요한 일을 저절로 사라지는 알림으로 — 대신 닫기 단추 있는 창 / 매 판마다 같은 큰 창 — 반복되면 짜증. 대신 작은 알림

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i551 — `src/demos/demosMotionC.ts:436`
```ts
const i551 = {
  kind: 'dom' as const,
  caption: '알림은 옆에서 밀고 들어와 먼저 온 것을 아래로 밀고, 창은 뒤를 흐리며 통 커진 뒤 내용이 차례로 — 나갈 땐 더 빨리',
  make(box: HTMLElement) {
    const o = { s: 1, stag: 0.08 };
    const TOASTS = [['#ffd23f', '별 3개 얻음'], ['#5ad1ff', '새 기술: 이단 뛰기'], ['#7be08a', '친구가 들어왔어요']];
    const E = [0.3, 0.9, 1.5];
    const LIFE = 2.4;
    let th = '';
    TOASTS.forEach((t) => (th += `<div class="mc51-t"><i style="background:${t[0]}"></i><span>${t[1]}</span><b></b></div>`));
    const S = stage(
      box,
      `.mc51-sky{position:absolute;inset:0;background:linear-gradient(#7fc8ff,#d8f0ff)}
      .mc51-hill{position:absolute;left:-40px;right:-40px;top:118px;height:120px;border-radius:50%;background:#6cc96a}
      .mc51-hill.b{left:120px;right:-120px;top:104px;background:#58b85a}
      .mc51-hero{position:absolute;left:52px;top:96px;width:22px;height:26px;border-radius:8px 8px 6px 6px;background:#ff7a59;box-shadow:inset 0 -5px 0 rgba(0,0,0,.12)}
      .mc51-t{position:absolute;left:150px;top:0;width:122px;height:22px;border-radius:8px;background:rgba(20,28,60,.92);color:#fff;font:700 8.5px/22px ${F};overflow:hidden;opacity:0}
      .mc51-t i{position:absolute;left:7px;top:7px;width:8px;height:8px;border-radius:50%}
      .mc51-t span{position:absolute;left:20px;white-space:nowrap}
      .mc51-t b{position:absolute;left:0;bottom:0;height:2px;background:rgba(255,255,255,.55);transform-origin:0 0;width:100%}
      .mc51-bd{position:absolute;inset:0;background:rgba(10,16,40,.45);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);opacity:0}
      .mc51-m{position:absolute;left:60px;top:28px;width:160px;height:122px;border-radius:14px;background:#fff;box-shadow:0 10px 30px rgba(0,0,0,.35);opacity:0;font-family:${F}}
      .mc51-h{position:absolute;left:0;right:0;top:12px;text-align:center;font:900 15px ${F};color:#ff7a1a}
      .mc51-s{position:absolute;left:0;right:0;top:33px;text-align:center;font:700 8.5px ${F};color:#7a86a8}
      .mc51-r{position:absolute;top:52px;width:40px;height:34px;border-radius:9px;background:#f2f4fa;text-align:center;font:800 7.5px ${F};color:#1d2b4f;opacity:0}
      .mc51-r i{display:block;width:14px;height:14px;margin:4px auto 2px;border-radius:4px}
      .mc51-go{position:absolute;left:40px;top:94px;width:80px;height:20px;border-radius:10px;background:#3a6bff;color:#fff;font:800 9px/20px ${F};text-align:center;opacity:0}
      .mc51-tap{position:absolute;left:140px;top:132px;width:22px;height:22px;margin:-11px;border-radius:50%;border:2px solid #fff;background:rgba(255,255,255,.3);opacity:0;box-sizing:border-box}`,
      `<div class="mc51-sky"></div><div class="mc51-hill b"></div><div class="mc51-hill"></div><div class="mc51-hero"></div>${th}
      <div class="mc51-bd"></div><div class="mc51-m"><div class="mc51-h">레벨 업!</div><div class="mc51-s">3 → 4 단계</div>
      <div class="mc51-r" style="left:12px"><i style="background:#ffd23f"></i>별 ×3</div><div class="mc51-r" style="left:60px"><i style="background:#c08bff"></i>열쇠 ×1</div><div class="mc51-r" style="left:108px"><i style="background:#ff7a59"></i>새 모자</div>
      <div class="mc51-go">받기</div></div><div class="mc51-tap"></div>`,
      '#bfe6ff',
    );
    const ts = [...S.st.querySelectorAll<HTMLElement>('.mc51-t')];
    const $ = (s: string): HTMLElement => S.st.querySelector<HTMLElement>(s)!;
    const bd = $('.mc51-bd');
    const m = $('.mc51-m');
    const rw = [...S.st.querySelectorAll<HTMLElement>('.mc51-r')];
    const goBtn = $('.mc51-go');
    const tap = $('.mc51-tap');
    let tt = 0;
    return {
      controls: [speedCtl(o), { type: 'range', label: '차례 간격 (stagger, 초)', min: 0, max: 0.2, step: 0.01, value: 0.08, on: (v: number) => (o.stag = v) }] as Control[],
      update(_t: number, dt: number) {
        tt += Math.max(0, Math.min(dt, 0.1)) * o.s;
        const T = 7.6;
        const p = tt % T;
        // 알림 — 「있음」(들어옴 × 안 나감)을 이어진 값으로 두고, 나보다 늦게 온 것들의 있음 합만큼 아래로
        const pres = E.map((e) => outCubic(seg(p, e, e + 0.4)) * (1 - inOut(seg(p, e + LIFE, e + LIFE + 0.3))));
        ts.forEach((el, j) => {
          const e = E[j]!;
          const enter = outBack(seg(p, e, e + 0.45), 1.4);
          const leave = inOut(seg(p, e + LIFE, e + LIFE + 0.3));
          let below = 0;
          for (let k = j + 1; k < ts.length; k++) below += pres[k]!;
          const x = (1 - enter) * 130 + leave * 130;
          el.style.opacity = p >= e ? (1 - leave).toFixed(3) : '0';
          el.style.transform = `translate(${x.toFixed(2)}px,${(8 + 26 * below).toFixed(2)}px)`;
          (el.lastElementChild as HTMLElement).style.transform = `scaleX(${(1 - seg(p, e + 0.45, e + LIFE)).toFixed(4)})`;
        });
        // 창 — 들어올 땐 0.45초 출렁, 나갈 땐 0.18초 (나가는 것은 더 빨리)
        const M0 = 4.1;
        const C0 = 6.7;
        const close = seg(p, C0, C0 + 0.18);
        bd.style.opacity = (seg(p, M0, M0 + 0.3) * (1 - close)).toFixed(3);
        const mk = outBack(seg(p, M0 + 0.05, M0 + 0.5), 1.6);
        m.style.opacity = (seg(p, M0 + 0.05, M0 + 0.25) * (1 - close)).toFixed(3);
        m.style.transform = `scale(${((0.8 + 0.2 * mk) * (1 - 0.06 * close)).toFixed(4)})`;
        rw.forEach((r, j) => {
          const a0 = M0 + 0.3 + j * o.stag;
          const k = outBack(seg(p, a0, a0 + 0.35), 2.2);
          r.style.opacity = clamp01(k * 2).toFixed(3);
          r.style.transform = `translateY(${((1 - k) * 8).toFixed(2)}px) scale(${(0.6 + 0.4 * k).toFixed(4)})`;
        });
        const g0 = M0 + 0.4 + 3 * o.stag;
        const gk = outBack(seg(p, g0, g0 + 0.35));
        const pressed = p > 6.45 && p < 6.62;
        const pulse = p > g0 + 0.5 && p < 6.4 ? 1 + 0.04 * Math.sin((p - g0) * 7) : 1;
        goBtn.style.opacity = clamp01(gk * 2).toFixed(3);
        goBtn.style.transform = `scale(${((0.7 + 0.3 * gk) * pulse * (pressed ? 0.93 : 1)).toFixed(4)})`;
        tap.style.opacity = (seg(p, 6.1, 6.3) * (1 - seg(p, 6.6, 6.75))).toFixed(3);
        tap.style.transform = `scale(${pressed ? 0.75 : 1})`;
      },
      dispose() {
        S.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [스프링 · 예비 동작 · 찌그러짐](https://ai-techstudio.web.app/ai/t/i102.md) `i102` · [스프링 · 감쇠 움직임 (임계 감쇠)](https://ai-techstudio.web.app/ai/t/i293.md) `i293`
- 다음에 해 볼 기술: [동전 빨려 들기 (UI 로 날아가기)](https://ai-techstudio.web.app/ai/t/i208.md) `i208` · [박자 맞춰 연출 (비트 동기)](https://ai-techstudio.web.app/ai/t/i438.md) `i438`
- 참고 문서: [MDN — backdrop-filter](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter) · [MDN — prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)
