# 放大坞站（macOS-style Dock with Magnification）

- 类型 / 材质：`dock` / `glass-apple`（玻璃）
- 状态：done ｜ 最后提交：2026-10-31
- 灵感来源：macOS Dock 的 magnification
- 依据标准：`/design#motion` ｜ 组件页：`/showcase/component/dock/glass-apple` ｜ 条目：`/r/dock--glass-apple.json`
- 必缺状态：焦点
- 整页场景：尚未接入

## 规格

- 余弦平方衰减的放大：峰值在鼠标处，半径外平滑归零
- 放大逐帧改尺寸（邻居被推开），移出用 300ms 补间回位
- 玻璃托盘 + 边缘折射滤镜，不支持则静默降级
- 悬停显示标签 tooltip

## 参数（CSS 变量）

`--color-surface` `--color-text` `--color-text-muted` `--dur-fast` `--glass-bg` `--glass-border` `--glass-highlight` `--glass-inset` `--shadow-md` `--shadow-sm` `--text-xs`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `items` | `DockItem[]` | 必填 | — |
| `baseSize` | `number` | 可选 | — |
| `maxSize` | `number` | 可选 | — |
| `range` | `number` | 可选 | — |
| `className` | `string` | 可选 | — |

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

```
复现「放大坞站」— macOS-style Dock with Magnification（坞站 / Glass）
灵感来源：macOS Dock 的 magnification
规格：
- 余弦平方衰减的放大：峰值在鼠标处，半径外平滑归零
- 放大逐帧改尺寸（邻居被推开），移出用 300ms 补间回位
- 玻璃托盘 + 边缘折射滤镜，不支持则静默降级
- 悬停显示标签 tooltip
参数（CSS 变量）：--color-surface --color-text --color-text-muted --dur-fast --glass-bg --glass-border --glass-highlight --glass-inset --shadow-md --shadow-sm --text-xs
必缺状态：焦点
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/dock/glass-apple · 标准 motion /design#motion · 调研笔记 /showcase/component/dock/glass-apple#notes
```

## 调研笔记

import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { prefersReducedMotion } from '@/lib/ui-echo/motion';
import { applyRefraction } from './refraction';

export interface DockItem {
  id: string;
  label: string;
  icon: ReactNode;
  running?: boolean;
  onClick?: (id: string) => void;
}

interface Props {
  items: DockItem[];
  baseSize?: number;
  maxSize?: number;
  range?: number;
  className?: string;
}

export function GlassAppleDock({
  items,
  baseSize = 52,
  maxSize = 88,
  range,
  className = '',
}: Props) {
  const rangePx = range ?? baseSize * 2.4;
  const trayRef = useRef<HTMLDivElement>(null);
  const rafRef = useRef(0);
  const hoveringRef = useRef(false);
  const [bouncingId, setBouncingId] = useState<string | null>(null);

  const icons = useCallback(() => {
    const tray = trayRef.current;
    if (!tray) return [] as HTMLButtonElement[];
    return Array.from(tray.querySelectorAll<HTMLButtonElement>('[data-dock-icon]'));
  }, []);

  /** 尺寸写入：放大是逐帧改尺寸（macOS 式把邻居推开），不是 CSS 过渡——
      过渡动 width/height 会触发布局重算，还会被逐帧写入顶掉。 */
  const writeSizes = useCallback((sizes: number[]) => {
    icons().forEach((b, i) => {
      const size = sizes[i];
      if (size == null) return;
      b.style.width = `${size}px`;
      b.style.height = `${size}px`;
      const glyph = b.querySelector<HTMLElement>('[data-dock-glyph]');
      if (glyph) glyph.style.fontSize = `${size * 0.52}px`;
    });
  }, [icons]);

  // 余弦平方衰减：t = cos²(d/r · π/2)，峰在鼠标处，r 处平滑归零
  const applySizes = useCallback((mouseX: number | null) => {
    const buttons = icons();
    if (buttons.length === 0) return;
    const centers = buttons.map((b) => {
      const r = b.getBoundingClientRect();
      return r.left + r.width / 2;
    });
    const sizes = buttons.map((_, i) => {
      let t = 0;
      if (mouseX !== null) {
        const d = Math.abs(mouseX - centers[i]);
        if (d < rangePx) t = Math.cos((d / rangePx) * (Math.PI / 2)) ** 2;
      }
      return baseSize + (maxSize - baseSize) * t;
    });
    writeSizes(sizes);
  }, [baseSize, maxSize, rangePx, icons, writeSizes]);

  useEffect(() => () => cancelAnimationFrame(rafRef.current), []);

  // L3 边缘折射：玻璃托盘的 Liquid Glass 签名效果（不支持时静默降级）
  useEffect(() => {
    const tray = trayRef.current;
    if (!tray) return;
    return applyRefraction(tray, { depth: 10, strength: 70 });
  }, []);

  const onMouseMove = (e: React.MouseEvent) => {
    if (prefersReducedMotion()) return; // 降级：不做放大位移
    const x = e.clientX;
    hoveringRef.current = true;
    cancelAnimationFrame(rafRef.current);
    rafRef.current = requestAnimationFrame(() => applySizes(x));
  };

  /** 移出：JS 补间回位（--dur-slow），所以 CSS 里不需要动 width/height 的过渡 */
  const onMouseLeave = () => {
    hoveringRef.current = false;
    cancelAnimationFrame(rafRef.current);
    const from = icons().map((b) => b.getBoundingClientRect().width);
    if (from.length === 0) return;
    const start = performance.now();
    const step = (now: number) => {
      const p = Math.min(1, (now - start) / 300);
      const e = 1 - (1 - p) ** 3; // 三次缓出
      writeSizes(from.map((s) => s + (baseSize - s) * e));
      if (p < 1) rafRef.current = requestAnimationFrame(step);
    };
    rafRef.current = requestAnimationFrame(step);
  };

  const onClickItem = (item: DockItem) => {
    item.onClick?.(item.id);
    setBouncingId(item.id);
    window.setTimeout(
      () => setBouncingId((cur) => (cur === item.id ? null : cur)),
      780, // = --seq-dock
    );
  };

  return (
    <div
      ref={trayRef}
      onMouseMove={onMouseMove}
      onMouseLeave={onMouseLeave}
      className={`
        relative flex items-end gap-2
        px-3 pb-2.5 pt-3
        rounded-2xl
        bg-[var(--glass-bg)] border border-[var(--glass-border)] backdrop-blur-xl
        shadow-[var(--shadow-md),var(--glass-inset)]
        ${className}
      `}
    >
      {items.map((item) => (
        <button
          key={item.id}
          type="button"
          data-dock-icon
          onClick={() => onClickItem(item)}
          aria-label={item.label}
          style={{ width: baseSize, height: baseSize }}
          className={`
            group relative flex items-center justify-center
            rounded-[22%]
            bg-[var(--glass-highlight)] border border-[var(--glass-border)]
            shadow-[var(--shadow-sm)]
            cursor-pointer select-none
            ${bouncingId === item.id ? 'dock-bounce' : ''}
          `}
        >
          <span
            data-dock-glyph
            className="leading-none"
            style={{ fontSize: baseSize * 0.52 }}
          >
            {item.icon}
          </span>

          {/* tooltip：hover 显示标签 */}
          <span
            className="
              pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
              px-2.5 py-1 rounded-lg whitespace-nowrap
              text-[length:var(--text-xs)] text-[var(--color-text)]
              bg-[var(--color-surface)] border border-[var(--glass-border)]
              shadow-[var(--shadow-sm)]
              opacity-0 translate-y-1
              transition-[opacity,translate] duration-[var(--dur-fast)]
              group-hover:opacity-100 group-hover:translate-y-0
            "
          >
            {item.label}
          </span>

          {/* 运行中指示点 */}
          {item.running && (
            <span
              className="
                absolute -bottom-[7px] left-1/2 -translate-x-1/2
                size-1 rounded-full bg-[var(--color-text-muted)]
              "
            />
          )}
        </button>
      ))}
    </div>
  );
}
