# 极简通知（Flat Toast Stack (sonner-style)）

- 类型 / 材质：`toast` / `minimal`（极简）
- 状态：done ｜ 最后提交：2026-10-31
- 灵感来源：sonner（Emil Kowalski）的堆叠公式 + Refactoring UI 的平面分层
- 依据标准：`/design#motion` ｜ 组件页：`/showcase/component/toast/minimal` ｜ 条目：`/r/toast--minimal.json`
- 必缺状态：焦点
- 整页场景：尚未接入

## 规格

- 宽 356px，牌堆每层缩 5%、抬 14px
- 停留计时 3.5s，悬停暂停、移出继续
- 进出场分离：进场 400ms / 退场 300ms
- 卡片白底 + 发丝边 + 软阴影，语气由色点 + 文字共同表达

## 参数（CSS 变量）

`--color-divider` `--color-info-fg` `--color-success-fg` `--color-surface` `--color-text` `--color-text-muted` `--color-text-subtle` `--color-warning-fg` `--dur-enter` `--dur-slow` `--ease-out` `--ease-standard` `--shadow-md` `--shadow-sm`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `toasts` | `ToastData[]` | 必填 | — |
| `onDismiss` | `(id: string) => void` | 必填 | — |

## 提示词（可直接粘给智能体）

```
复现「极简通知」— Flat Toast Stack (sonner-style)（通知 / Minimal）
灵感来源：sonner（Emil Kowalski）的堆叠公式 + Refactoring UI 的平面分层
规格：
- 宽 356px，牌堆每层缩 5%、抬 14px
- 停留计时 3.5s，悬停暂停、移出继续
- 进出场分离：进场 400ms / 退场 300ms
- 卡片白底 + 发丝边 + 软阴影，语气由色点 + 文字共同表达
参数（CSS 变量）：--color-divider --color-info-fg --color-success-fg --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-enter --dur-slow --ease-out --ease-standard --shadow-md --shadow-sm
必缺状态：焦点
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/toast/minimal · 标准 motion /design#motion · 调研笔记 /showcase/component/toast/minimal#notes
```

## 调研笔记

import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { motionMs } from '@/lib/ui-echo/motion';

export interface ToastData {
  id: string;
  title: string;
  desc?: string;
  /** 自动消失毫秒数，默认 3500 */
  duration?: number;
  /** 语气：决定左侧色点与语义色 */
  tone?: 'default' | 'success' | 'error' | 'info';
}

/** 语气 → 左侧圆点颜色（状态不能只靠颜色表达，所以永远配文字） */
const TONE_DOT: Record<string, string> = {
  default: 'bg-[var(--color-text-subtle)]',
  success: 'bg-[var(--color-success-fg)]',
  error: 'bg-[var(--color-warning-fg)]',
  info: 'bg-[var(--color-info-fg)]',
};

interface Props {
  toasts: ToastData[];
  onDismiss: (id: string) => void;
}

const GAP = 14;          // sonner 源码值
const SHRINK = 0.05;     // 每压一层缩 5%

/**
 * 极简通知栈：与玻璃通知同一套堆叠公式与时序，
 * 卡片换成白底 + 发丝边 + 软阴影，圆角收小一档。
 */
export function MinimalToastStack({ toasts, onDismiss }: Props) {
  const [entering, setEntering] = useState<Set<string>>(new Set());
  const [leaving, setLeaving] = useState<Set<string>>(new Set());
  const known = useRef<Set<string>>(new Set());
  const timers = useRef<Map<string, number>>(new Map());
  // 悬停时暂停计时：读通知的人不该被计时器赶走（剩下的时间记在这里）
  const paused = useRef<Map<string, { at: number; left: number }>>(new Map());

  // 新 toast：进场动画 + 自动消失计时
  useEffect(() => {
    for (const t of toasts) {
      if (known.current.has(t.id)) continue;
      known.current.add(t.id);
      setEntering((s) => new Set(s).add(t.id));
      // 双 rAF 保证初始样式先绘制，过渡才生效
      requestAnimationFrame(() =>
        requestAnimationFrame(() =>
          setEntering((s) => {
            const n = new Set(s);
            n.delete(t.id);
            return n;
          }),
        ),
      );
      const timer = window.setTimeout(() => dismiss(t.id), t.duration ?? 3500);
      timers.current.set(t.id, timer);
    }
  }, [toasts]);

  useEffect(() => {
    const map = timers.current;
    return () => {
      for (const t of map.values()) clearTimeout(t);
    };
  }, []);

  /** 悬停暂停 / 移出继续 */
  const pause = (id: string) => {
    const timer = timers.current.get(id);
    if (timer == null) return;
    clearTimeout(timer);
    timers.current.delete(id);
    const t = toasts.find((x) => x.id === id);
    const total = t?.duration ?? 3500;
    paused.current.set(id, { at: Date.now(), left: total });
  };

  const resume = (id: string) => {
    const p = paused.current.get(id);
    if (!p) return;
    paused.current.delete(id);
    const left = Math.max(400, p.left - (Date.now() - p.at));
    timers.current.set(id, window.setTimeout(() => dismiss(id), left));
  };

  const dismiss = (id: string) => {
    if (leaving.has(id)) return;
    const timer = timers.current.get(id);
    if (timer) clearTimeout(timer);
    timers.current.delete(id);
    paused.current.delete(id);
    setLeaving((s) => new Set(s).add(id));
    // 退场动画播完再真正移除，避免截断
    window.setTimeout(() => {
      known.current.delete(id);
      setLeaving((s) => {
        const n = new Set(s);
        n.delete(id);
        return n;
      });
      onDismiss(id);
    }, motionMs('--dur-slow', 300));
  };

  // 前排 = 最新（数组末尾）；k = 距前排的层数
  const visible = toasts.filter((t) => known.current.has(t.id) || !leaving.has(t.id));
  const frontFirst = [...visible].reverse();

  // portal 到 body：避开任何 transformed 祖先（动画 fill 会捕获 fixed 定位）
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;

  return createPortal(
    <div className="fixed bottom-6 left-0 right-0 z-50 flex justify-center pointer-events-none">
      <div className="relative w-[356px] max-w-[92vw]">
        {frontFirst.map((t, k) => {
          const isEntering = entering.has(t.id);
          const isLeaving = leaving.has(t.id);

          let transform: string;
          let opacity: number;
          if (isEntering) {
            transform = 'translateY(10px) scale(0.92)';
            opacity = 0;
          } else if (isLeaving) {
            transform = 'translateY(120%)';
            opacity = 0;
          } else {
            transform = `translateY(${-k * GAP}px) scale(${1 - k * SHRINK})`;
            opacity = 1;
          }

          const style: CSSProperties = {
            transform,
            opacity,
            zIndex: frontFirst.length - k,
            transformOrigin: 'bottom center',
            transition: `transform var(--dur-enter) var(--ease-out), opacity var(--dur-slow) var(--ease-standard)`,
          };

          return (
            <div
              key={t.id}
              role="status"
              onMouseEnter={() => pause(t.id)}
              onMouseLeave={() => resume(t.id)}
              className="
                absolute bottom-0 w-full pointer-events-auto
                rounded-lg bg-[var(--color-surface)]
                border border-[var(--color-divider)]
                shadow-[var(--shadow-md)]
                px-4 py-3 flex items-start gap-3
              "
              style={style}
            >
              <span
                className={`mt-1.5 size-2 rounded-full shrink-0 ${TONE_DOT[t.tone ?? 'default']}`}
                aria-hidden="true"
              />
              <div className="flex-1 min-w-0">
                <p className="text-sm font-medium truncate">{t.title}</p>
                {t.desc && (
                  <p className="text-xs text-[var(--color-text-muted)] mt-0.5">{t.desc}</p>
                )}
              </div>
              <button
                type="button"
                aria-label="关闭通知"
                onClick={() => dismiss(t.id)}
                className="
                  press-feedback shrink-0 size-6 rounded-sm -mr-1 -mt-0.5
                  flex items-center justify-center
                  text-[var(--color-text-subtle)] hover:text-[var(--color-text)]
                  transition-colors cursor-pointer
                "
              >
                ✕
              </button>
            </div>
          );
        })}
      </div>
    </div>,
    document.body,
  );
}
