{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast-gravity-stack",
  "title": "Toast Gravity Stack",
  "description": "Toast stack with real gravity: notifications thud into a jostling heap, errors sink under their own mass, and the pile resettles when one is dismissed.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/toast-gravity-stack/component.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  useState,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// SedimentStack — toasts fall under real gravity and pile into a jostling\n// heap at the base of a docked surface. Severity sets mass (errors thud in\n// heavy and migrate down, info lands light on top); dismissing mid-pile\n// removes the collision body and the sediment above resettles. DOM cards,\n// transforms written per-frame by a rigid-body-lite 2D sim — refs only on\n// the hot path, and the whole sim sleeps once the pile settles.\n// ---------------------------------------------------------------------------\n\nexport type SedimentSeverity = \"error\" | \"warning\" | \"info\";\n\nexport interface SedimentToastInput {\n  severity?: SedimentSeverity;\n  title: string;\n  message?: string;\n  /** ms before auto-dismiss; 0 disables; defaults to the stack `duration` */\n  duration?: number;\n}\n\nexport interface SedimentStackHandle {\n  /** drop a toast into the pile, returns its id */\n  push: (toast: SedimentToastInput) => string;\n  dismiss: (id: string) => void;\n  clear: () => void;\n}\n\ntype ToastRec = {\n  id: string;\n  severity: SedimentSeverity;\n  title: string;\n  message?: string;\n  duration: number;\n};\n\ntype Body = {\n  id: string;\n  el: HTMLDivElement;\n  x: number;\n  y: number;\n  a: number;\n  vx: number;\n  vy: number;\n  om: number;\n  hw: number;\n  hh: number;\n  /** collision circle radius (half card height → rounded-box proxy) */\n  r: number;\n  /** local x offsets of the collision circles along the card's midline */\n  lxs: number[];\n  bound: number;\n  invMass: number;\n  invI: number;\n  baseInvMass: number;\n  baseInvI: number;\n  born: number;\n  dead: boolean;\n  dragging: boolean;\n  dragPX: number;\n  dragX0: number;\n  dragLastX: number;\n  dragLastT: number;\n  dragVX: number;\n  spring: boolean;\n  anchorX: number;\n  hovered: boolean;\n  /** frozen into static geometry (invMass 0) after 12 calm frames */\n  asleep: boolean;\n  calm: number;\n  /** seconds awake since spawn/wake — drives the forced-settle rule */\n  age: number;\n};\n\ntype TimerEntry = {\n  handle: number | null;\n  deadline: number;\n  remaining: number;\n};\n\n// tray composition only (not a sim constant): keeps the physics floor a few\n// px above the frame's visible bottom edge so a rotated card's circle-proxy\n// corner overhang never visually crosses the clipped edge\nconst FLOOR_INSET = 6;\n\n// severity → left-rail color only; the card itself stays on neutral tokens\nconst RAIL: Record<SedimentSeverity, string> = {\n  error: \"var(--error, #ea001d)\",\n  warning: \"var(--warning, #f5a623)\",\n  info: \"var(--success, #47a447)\",\n};\n\nconst ICONS: Record<SedimentSeverity, ReactNode> = {\n  error: (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <circle cx=\"12\" cy=\"12\" r=\"9\" />\n      <path d=\"m15 9-6 6M9 9l6 6\" />\n    </svg>\n  ),\n  warning: (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <path d=\"M12 3 2.7 19a1.5 1.5 0 0 0 1.3 2.3h16a1.5 1.5 0 0 0 1.3-2.3L12 3Z\" />\n      <path d=\"M12 10v4\" />\n      <path d=\"M12 17.4h.01\" />\n    </svg>\n  ),\n  info: (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <circle cx=\"12\" cy=\"12\" r=\"9\" />\n      <path d=\"m8.5 12.5 2.5 2.5 4.5-5\" />\n    </svg>\n  ),\n};\n\nfunction CardInner({\n  toast,\n  onDismiss,\n}: {\n  toast: ToastRec;\n  onDismiss: () => void;\n}) {\n  return (\n    <div className=\"flex items-start gap-2.5 py-2 pl-3 pr-1.5\">\n      <span className=\"mt-0.5 shrink-0 text-ns-muted\">{ICONS[toast.severity]}</span>\n      <div className=\"min-w-0 flex-1\">\n        <p className=\"truncate text-[13px] font-medium leading-tight text-foreground\">\n          {toast.title}\n        </p>\n        {toast.message ? (\n          <p className=\"mt-0.5 truncate font-mono text-[11px] leading-tight text-ns-muted\">\n            {toast.message}\n          </p>\n        ) : null}\n      </div>\n      <button\n        type=\"button\"\n        aria-label={`Dismiss: ${toast.title}`}\n        onClick={onDismiss}\n        className=\"shrink-0 cursor-pointer rounded-sm p-1 text-ns-muted transition-colors duration-150 hover:bg-foreground/10 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ns-accent\"\n      >\n        <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" aria-hidden>\n          <path d=\"M18 6 6 18M6 6l12 12\" />\n        </svg>\n      </button>\n    </div>\n  );\n}\n\n// reduced-motion fallback card: classic list entry, 150ms opacity fade in/out\nfunction ReducedCard({\n  toast,\n  exiting,\n  onDismiss,\n  onEnter,\n  onLeave,\n}: {\n  toast: ToastRec;\n  exiting: boolean;\n  onDismiss: () => void;\n  onEnter: () => void;\n  onLeave: () => void;\n}) {\n  const [shown, setShown] = useState(false);\n  useEffect(() => {\n    const id = requestAnimationFrame(() => setShown(true));\n    return () => cancelAnimationFrame(id);\n  }, []);\n  return (\n    <div\n      role={toast.severity === \"error\" ? \"alert\" : \"status\"}\n      onPointerEnter={onEnter}\n      onPointerLeave={onLeave}\n      className={`w-full rounded-sm border border-border bg-surface transition-opacity duration-150 hover:border-foreground/20 ${\n        shown && !exiting ? \"opacity-100\" : \"opacity-0\"\n      }`}\n      style={{ borderLeftWidth: 3, borderLeftColor: RAIL[toast.severity] }}\n    >\n      <CardInner toast={toast} onDismiss={onDismiss} />\n    </div>\n  );\n}\n\nexport const SedimentStack = forwardRef<\n  SedimentStackHandle,\n  {\n    /** ms before a toast auto-dismisses (paused on hover); 0 disables */\n    duration?: number;\n    /** px/s^2 downward */\n    gravity?: number;\n    /** bounce energy kept on impact, 0..1 */\n    restitution?: number;\n    /** toasts present at mount */\n    initial?: SedimentToastInput[];\n    /** extra classes merged onto the rendered root element */\n    className?: string;\n    /** accessible name for the toast stack. Default \"Notifications\". */\n    \"aria-label\"?: string;\n  }\n>(function SedimentStack(\n  {\n    duration = 6000,\n    gravity = 1800,\n    restitution = 0.15,\n    initial,\n    className = \"h-96\",\n    \"aria-label\": ariaLabel = \"Notifications\",\n  },\n  ref\n) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const bodiesRef = useRef<Map<string, Body>>(new Map());\n  const timersRef = useRef<Map<string, TimerEntry>>(new Map());\n  const removalsRef = useRef<Set<number>>(new Set());\n  const dismissedRef = useRef<Set<string>>(new Set());\n  const wakeRef = useRef<() => void>(() => {});\n  const kickRef = useRef<() => void>(() => {});\n  const shadowRef = useRef(\"0 10px 24px -10px rgba(0,0,0,0.28)\");\n  const restShadowRef = useRef(\"0 3px 10px -6px rgba(0,0,0,0.3)\");\n  const seqRef = useRef(0);\n  const bornRef = useRef(0);\n  const spawnSlotRef = useRef(0);\n\n  const [toasts, setToasts] = useState<ToastRec[]>(() =>\n    (initial ?? []).map((t, i) => ({\n      id: `sed-init-${i}`,\n      severity: t.severity ?? \"info\",\n      title: t.title,\n      message: t.message,\n      duration: t.duration ?? duration,\n    }))\n  );\n  const [exiting, setExiting] = useState<ReadonlySet<string>>(new Set());\n  const [reduced, setReduced] = useState(false);\n  const toastsRef = useRef(toasts);\n  toastsRef.current = toasts;\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const update = () => setReduced(mq.matches);\n    update();\n    mq.addEventListener(\"change\", update);\n    return () => mq.removeEventListener(\"change\", update);\n  }, []);\n\n  // hover-lift + resting shadows are tinted from a fixed dark \"ink\" (always\n  // rgba(0,0,0,...)), not a theme token — a shadow needs to stay dark in\n  // both themes, unlike --foreground which flips to near-white in dark mode\n  // and would otherwise paint a light halo around every card. Resting shadow\n  // is a subtle version applied to every settled card so the pile reads as\n  // sitting under real weight, not flat cutouts. shadowRef/restShadowRef are\n  // already seeded with these constants at declaration, so no derive effect\n  // is needed here.\n\n  // oldest layers fade toward 0.55 as newer sediment lands on top\n  const recomputeDepth = useCallback(() => {\n    const alive: Body[] = [];\n    for (const b of bodiesRef.current.values()) if (!b.dead) alive.push(b);\n    alive.sort((a, b) => a.born - b.born);\n    const n = alive.length;\n    for (let i = 0; i < n; i++) {\n      alive[i].el.style.opacity = Math.max(0.55, 1 - (n - 1 - i) * 0.06).toFixed(2);\n    }\n  }, []);\n\n  const pauseTimer = useCallback((id: string) => {\n    const entry = timersRef.current.get(id);\n    if (!entry || entry.handle === null) return;\n    clearTimeout(entry.handle);\n    entry.handle = null;\n    entry.remaining = Math.max(0, entry.deadline - Date.now());\n  }, []);\n\n  const dismiss = useCallback(\n    (id: string) => {\n      const entry = timersRef.current.get(id);\n      if (entry) {\n        if (entry.handle !== null) clearTimeout(entry.handle);\n        timersRef.current.delete(id);\n      }\n      if (dismissedRef.current.has(id)) return;\n      dismissedRef.current.add(id);\n      const body = bodiesRef.current.get(id);\n      if (body && !body.dead) {\n        // collision body gone immediately — the pile above resettles while\n        // the card fades out over 150ms\n        body.dead = true;\n        body.dragging = false;\n        body.el.style.opacity = \"0\";\n      }\n      setExiting((prev) => {\n        const next = new Set(prev);\n        next.add(id);\n        return next;\n      });\n      const handle = window.setTimeout(() => {\n        removalsRef.current.delete(handle);\n        dismissedRef.current.delete(id);\n        bodiesRef.current.delete(id);\n        setToasts((prev) => prev.filter((t) => t.id !== id));\n        setExiting((prev) => {\n          if (!prev.has(id)) return prev;\n          const next = new Set(prev);\n          next.delete(id);\n          return next;\n        });\n        recomputeDepth();\n        wakeRef.current();\n      }, 180);\n      removalsRef.current.add(handle);\n      wakeRef.current();\n    },\n    [recomputeDepth]\n  );\n\n  const resumeTimer = useCallback(\n    (id: string) => {\n      const entry = timersRef.current.get(id);\n      if (!entry || entry.handle !== null) return;\n      const ms = Math.max(400, entry.remaining);\n      entry.deadline = Date.now() + ms;\n      entry.handle = window.setTimeout(() => dismiss(id), ms);\n    },\n    [dismiss]\n  );\n\n  // arm auto-dismiss timers for freshly pushed toasts\n  useEffect(() => {\n    const timers = timersRef.current;\n    for (const t of toasts) {\n      if (t.duration <= 0 || timers.has(t.id) || dismissedRef.current.has(t.id))\n        continue;\n      timers.set(t.id, {\n        handle: window.setTimeout(() => dismiss(t.id), t.duration),\n        deadline: Date.now() + t.duration,\n        remaining: t.duration,\n      });\n    }\n  }, [toasts, dismiss]);\n\n  // teardown: every pending timer and removal timeout dies with the component\n  useEffect(() => {\n    const timers = timersRef.current;\n    const removals = removalsRef.current;\n    return () => {\n      for (const entry of timers.values()) {\n        if (entry.handle !== null) clearTimeout(entry.handle);\n      }\n      timers.clear();\n      for (const handle of removals) clearTimeout(handle);\n      removals.clear();\n    };\n  }, []);\n\n  const push = useCallback(\n    (input: SedimentToastInput) => {\n      const id = `sed-${++seqRef.current}`;\n      setToasts((prev) => [\n        ...prev,\n        {\n          id,\n          severity: input.severity ?? \"info\",\n          title: input.title,\n          message: input.message,\n          duration: input.duration ?? duration,\n        },\n      ]);\n      return id;\n    },\n    [duration]\n  );\n\n  const clear = useCallback(() => {\n    for (const t of toastsRef.current) dismiss(t.id);\n  }, [dismiss]);\n\n  useImperativeHandle(ref, () => ({ push, dismiss, clear }), [push, dismiss, clear]);\n\n  // spawn a rigid body the moment a card element mounts: entry x round-robins\n  // across a handful of tray-width slots (jittered) so the pile heaps instead\n  // of sliding in from one edge, vx jitter ±40 px/s, rotation jitter ±4°,\n  // mass by severity (3/2/1)\n  const attach = useCallback(\n    (t: ToastRec, el: HTMLDivElement | null) => {\n      const bodies = bodiesRef.current;\n      const existing = bodies.get(t.id);\n      if (!el) {\n        // re-render ref churn detaches then reattaches the same node; only a\n        // dismissed body is truly gone\n        if (existing && existing.dead) bodies.delete(t.id);\n        return;\n      }\n      if (existing) {\n        // Only a genuinely new node (real remount) should wake the sim —\n        // the churn case above reattaches this exact same element on every\n        // unrelated re-render of the list, and waking on every one of those\n        // resets the settle deadline continuously, so the sim never parks.\n        const isNewNode = existing.el !== el;\n        existing.el = el;\n        if (isNewNode) wakeRef.current();\n        return;\n      }\n      const root = containerRef.current;\n      const cw = root ? root.clientWidth : 0;\n      const w = el.offsetWidth || 288;\n      const h = el.offsetHeight || 60;\n      const hw = w / 2;\n      const hh = h / 2;\n      const r = hh; // rounded-box proxy: circles of half-height radius\n      const span = Math.max(0, hw - r);\n      const count = Math.max(2, Math.ceil((span * 2) / (r * 1.2)) + 1);\n      const lxs: number[] = [];\n      for (let i = 0; i < count; i++)\n        lxs.push(-span + (span * 2 * i) / (count - 1));\n      const mass = t.severity === \"error\" ? 3 : t.severity === \"warning\" ? 2 : 1;\n      const inertia = (mass * (w * w + h * h)) / 12;\n      // distribute drop x across up to 4 tray slots (round-robin + jitter)\n      // instead of always the far-right edge, so the pile heaps rather than\n      // sliding in flat from one side\n      const SLOT_COUNT = 4;\n      const slotLeft = hw + 8;\n      const slotRight = cw - hw - 8;\n      const slotSpan = slotRight - slotLeft;\n      let x: number;\n      if (cw > w + 40 && slotSpan > 0) {\n        const n = Math.min(SLOT_COUNT, Math.max(1, Math.floor(slotSpan / 40) + 1));\n        const slot = spawnSlotRef.current % n;\n        spawnSlotRef.current++;\n        const frac = n === 1 ? 0.5 : slot / (n - 1);\n        const jitter = (Math.random() - 0.5) * Math.min(28, slotSpan / n);\n        x = Math.min(slotRight, Math.max(slotLeft, slotLeft + frac * slotSpan + jitter));\n      } else {\n        x = Math.max(hw + 2, cw - hw - 2);\n      }\n      const body: Body = {\n        id: t.id,\n        el,\n        x,\n        y: -hh - 6,\n        a: (Math.random() - 0.5) * ((8 * Math.PI) / 180),\n        vx: (Math.random() - 0.5) * 40,\n        vy: 0,\n        om: 0,\n        hw,\n        hh,\n        r,\n        lxs,\n        bound: Math.hypot(hw, hh) + 2,\n        invMass: 1 / mass,\n        invI: 1 / inertia,\n        baseInvMass: 1 / mass,\n        baseInvI: 1 / inertia,\n        born: ++bornRef.current,\n        dead: false,\n        dragging: false,\n        dragPX: 0,\n        dragX0: 0,\n        dragLastX: 0,\n        dragLastT: 0,\n        dragVX: 0,\n        spring: false,\n        anchorX: 0,\n        hovered: false,\n        asleep: false,\n        calm: 0,\n        age: 0,\n      };\n      el.style.transform = `translate3d(${(body.x - hw).toFixed(2)}px, ${(body.y - hh).toFixed(2)}px, 0) rotate(${body.a.toFixed(4)}rad)`;\n      el.style.boxShadow = restShadowRef.current;\n      bodies.set(t.id, body);\n      recomputeDepth();\n      wakeRef.current();\n    },\n    [recomputeDepth]\n  );\n\n  // -------------------------------------------------------------------------\n  // the sim — fixed 120Hz substeps, impulse contacts, sleeps when settled\n  // -------------------------------------------------------------------------\n  useEffect(() => {\n    if (reduced) return;\n    const root = containerRef.current;\n    if (!root) return;\n\n    let W = root.clientWidth;\n    let H = root.clientHeight;\n    let raf = 0;\n    let last = 0;\n    let acc = 0;\n    // seconds of live sim since the last real wake — the global settle deadline\n    let active = 0;\n\n    const FIXED = 1 / 120;\n    // ponytail: superphysical stiction (cone past 45°) — at 0.85 any contact\n    // steeper than ~40° slides under gravity and the pile reorganizes for\n    // minutes, never reaching the sleep epsilon; cards in a tray should lock\n    const MU = 1.2;\n    const SPRING_K = 180; // swipe spring-back stiffness (s^-2)\n    const SPRING_C = 2 * 0.9 * Math.sqrt(SPRING_K); // zeta = 0.9\n\n    const pa: number[] = new Array(64).fill(0);\n    const pb: number[] = new Array(64).fill(0);\n\n    const worldPts = (b: Body, out: number[]) => {\n      const cos = Math.cos(b.a);\n      const sin = Math.sin(b.a);\n      for (let i = 0; i < b.lxs.length; i++) {\n        const lx = b.lxs[i];\n        out[i * 2] = b.x + lx * cos;\n        out[i * 2 + 1] = b.y + lx * sin;\n      }\n    };\n\n    // impulse + Baumgarte projection at one contact point; B null = static\n    const applyContact = (\n      A: Body,\n      B: Body | null,\n      px: number,\n      py: number,\n      nx: number,\n      ny: number,\n      pen: number\n    ) => {\n      const rax = px - A.x;\n      const ray = py - A.y;\n      let rbx = 0;\n      let rby = 0;\n      let invMB = 0;\n      let invIB = 0;\n      let vbx = 0;\n      let vby = 0;\n      let wb = 0;\n      if (B) {\n        rbx = px - B.x;\n        rby = py - B.y;\n        invMB = B.invMass;\n        invIB = B.invI;\n        vbx = B.vx;\n        vby = B.vy;\n        wb = B.om;\n      }\n      const invSum = A.invMass + invMB;\n      if (invSum > 0 && pen > 0.5) {\n        const corr = ((pen - 0.5) / invSum) * 0.3;\n        A.x -= corr * A.invMass * nx;\n        A.y -= corr * A.invMass * ny;\n        if (B) {\n          B.x += corr * B.invMass * nx;\n          B.y += corr * B.invMass * ny;\n        }\n      }\n      const rvx = vbx - wb * rby - (A.vx - A.om * ray);\n      const rvy = vby + wb * rbx - (A.vy + A.om * rax);\n      const vn = rvx * nx + rvy * ny;\n      if (vn >= 0) return;\n      const raXn = rax * ny - ray * nx;\n      const rbXn = rbx * ny - rby * nx;\n      const kn = invSum + raXn * raXn * A.invI + rbXn * rbXn * invIB;\n      if (kn <= 0) return;\n      const e = vn < -80 ? restitution : 0;\n      const j = (-(1 + e) * vn) / kn;\n      A.vx -= j * nx * A.invMass;\n      A.vy -= j * ny * A.invMass;\n      A.om -= j * raXn * A.invI;\n      if (B) {\n        B.vx += j * nx * B.invMass;\n        B.vy += j * ny * B.invMass;\n        B.om += j * rbXn * B.invI;\n      }\n      let tx = rvx - vn * nx;\n      let ty = rvy - vn * ny;\n      const tl = Math.hypot(tx, ty);\n      if (tl > 1e-6) {\n        tx /= tl;\n        ty /= tl;\n        const raXt = rax * ty - ray * tx;\n        const rbXt = rbx * ty - rby * tx;\n        const kt = invSum + raXt * raXt * A.invI + rbXt * rbXt * invIB;\n        if (kt > 0) {\n          let jt = -(rvx * tx + rvy * ty) / kt;\n          const maxF = MU * j;\n          if (jt > maxF) jt = maxF;\n          else if (jt < -maxF) jt = -maxF;\n          A.vx -= jt * tx * A.invMass;\n          A.vy -= jt * ty * A.invMass;\n          A.om -= jt * raXt * A.invI;\n          if (B) {\n            B.vx += jt * tx * B.invMass;\n            B.vy += jt * ty * B.invMass;\n            B.om += jt * rbXt * B.invI;\n          }\n        }\n      }\n    };\n\n    const step = (dt: number, bodies: Body[]) => {\n      for (const b of bodies) {\n        if (b.invMass === 0) continue; // dragged bodies are kinematic\n        b.vy += gravity * dt;\n        b.age += dt;\n        if (b.spring) {\n          b.vx += (-SPRING_K * (b.x - b.anchorX) - SPRING_C * b.vx) * dt;\n          if (Math.abs(b.x - b.anchorX) < 0.5 && Math.abs(b.vx) < 3)\n            b.spring = false;\n        }\n      }\n      for (let iter = 0; iter < 3; iter++) {\n        for (let i = 0; i < bodies.length; i++) {\n          const A = bodies[i];\n          worldPts(A, pa);\n          for (let ci = 0; ci < A.lxs.length; ci++) {\n            const cxw = pa[ci * 2];\n            const cyw = pa[ci * 2 + 1];\n            const overFloor = cyw + A.r - H;\n            if (overFloor > 0) applyContact(A, null, cxw, H, 0, 1, overFloor);\n            const overLeft = A.r - cxw;\n            if (overLeft > 0) applyContact(A, null, 0, cyw, -1, 0, overLeft);\n            const overRight = cxw + A.r - W;\n            if (overRight > 0) applyContact(A, null, W, cyw, 1, 0, overRight);\n          }\n          for (let jn = i + 1; jn < bodies.length; jn++) {\n            const B = bodies[jn];\n            if (A.asleep && B.asleep) continue; // both frozen — nothing to solve\n            const dx = B.x - A.x;\n            const dy = B.y - A.y;\n            const rr = A.bound + B.bound;\n            if (dx * dx + dy * dy > rr * rr) continue;\n            // an energetic awake body crashing into frozen geometry wakes it\n            if (A.asleep !== B.asleep) {\n              const mover = A.asleep ? B : A;\n              const frozen = A.asleep ? A : B;\n              if (!frozen.dragging && Math.hypot(mover.vx, mover.vy) > 60) {\n                frozen.asleep = false;\n                frozen.calm = 0;\n                frozen.age = 0;\n                frozen.invMass = frozen.baseInvMass;\n                frozen.invI = frozen.baseInvI;\n              }\n            }\n            worldPts(B, pb);\n            const rsum = A.r + B.r;\n            for (let pi = 0; pi < A.lxs.length; pi++) {\n              for (let qi = 0; qi < B.lxs.length; qi++) {\n                const ddx = pb[qi * 2] - pa[pi * 2];\n                const ddy = pb[qi * 2 + 1] - pa[pi * 2 + 1];\n                const d2 = ddx * ddx + ddy * ddy;\n                if (d2 >= rsum * rsum) continue;\n                const d = Math.sqrt(d2);\n                // coincident centers: fall back to a vertical normal —\n                // never divide by a zero-length vector\n                let nx = 0;\n                let ny = -1;\n                if (d > 1e-6) {\n                  nx = ddx / d;\n                  ny = ddy / d;\n                }\n                const pen = rsum - d;\n                applyContact(\n                  A,\n                  B,\n                  pa[pi * 2] + nx * (A.r - pen / 2),\n                  pa[pi * 2 + 1] + ny * (A.r - pen / 2),\n                  nx,\n                  ny,\n                  pen\n                );\n              }\n            }\n          }\n        }\n      }\n      for (const b of bodies) {\n        if (b.invMass === 0) continue;\n        const sp = Math.hypot(b.vx, b.vy);\n        if (sp > 1400) {\n          const s = 1400 / sp;\n          b.vx *= s;\n          b.vy *= s;\n        }\n        b.x += b.vx * dt;\n        b.y += b.vy * dt;\n        b.a += b.om * dt;\n        // framerate-normalized angular damping (0.92 per 60Hz frame)\n        b.om *= Math.pow(0.92, dt * 60);\n        // chatter damping: a body wedged between frozen (infinite-mass)\n        // neighbors ping-pongs impulses at 10-20px/s forever — drain any\n        // sub-30px/s motion hard so it dips under the sleep epsilon\n        if (Math.abs(b.vx) < 30 && Math.abs(b.vy) < 30) {\n          b.vx *= 0.85;\n          b.vy *= 0.85;\n          b.om *= 0.85;\n        }\n      }\n    };\n\n    const loop = (now: number) => {\n      raf = 0;\n      const dtF = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);\n      last = now;\n      const bodies: Body[] = [];\n      for (const b of bodiesRef.current.values()) if (!b.dead) bodies.push(b);\n\n      // zero-size guard: bodies wait until the container has real bounds\n      if (W > 4 && H > 4 && bodies.length > 0) {\n        active += dtF;\n        acc = Math.min(acc + dtF, FIXED * 6);\n        while (acc >= FIXED) {\n          step(FIXED, bodies);\n          acc -= FIXED;\n        }\n        for (const b of bodies) {\n          const tx = b.x - b.hw;\n          const ty = b.y - b.hh + (b.hovered ? -2 : 0);\n          b.el.style.transform = `translate3d(${tx.toFixed(2)}px, ${ty.toFixed(2)}px, 0) rotate(${b.a.toFixed(4)}rad)`;\n        }\n      }\n\n      // per-body sleep: a body calm (|v| < 3 px/s, |ω| < 0.02 rad/s, no\n      // spring/drag) for 12 consecutive frames freezes into static geometry\n      // (invMass 0) so the pile locks progressively instead of reorganizing\n      // forever. Loop parks when everything is frozen; spawn/dismiss/resize\n      // full-wake, an energetic contact wakes one frozen body.\n      // global settle deadline: 4s after the last real wake, everything\n      // freezes unconditionally — no per-body wake/reset path can evade it.\n      // The pile must reach exact rest deterministically; the sim is for the\n      // landing choreography, not for eternal micro-motion.\n      const deadline = active > 4;\n      let allAsleep = true;\n      for (const b of bodies) {\n        if (b.dragging) {\n          allAsleep = false;\n          continue;\n        }\n        if (b.asleep) continue;\n        // forced settle: a UI pile must reach rest deterministically and a\n        // free impulse sim never quite does (wedged bodies chatter between\n        // frozen infinite-mass neighbors). Any body awake > 2.5s and moving\n        // under 40px/s parks on the spot — imperceptible at that speed.\n        // ponytail: fiat rest; revisit only if piles photograph mid-topple\n        // hard stop at 4s: a card can toboggan down a frozen neighbor's\n        // scalloped (circle-proxy) edge at 40-60px/s indefinitely — nothing\n        // in a toast pile legitimately animates that long, and every\n        // spawn/drag/dismiss resets the clock\n        if (\n          deadline ||\n          b.age > 4 ||\n          (!b.spring &&\n            b.age > 2.5 &&\n            Math.hypot(b.vx, b.vy) < 40 &&\n            Math.abs(b.om) < 0.3)\n        ) {\n          b.spring = false;\n          b.asleep = true;\n          b.vx = 0;\n          b.vy = 0;\n          b.om = 0;\n          b.invMass = 0;\n          b.invI = 0;\n          continue;\n        }\n        // 8px/s: strict 3 left grinding bodies oscillating just above it —\n        // sub-8px/s residuals zero out invisibly on freeze, and free fall\n        // crosses this band in <1 substep so nothing parks mid-air\n        if (!b.spring && Math.hypot(b.vx, b.vy) < 8 && Math.abs(b.om) < 0.05) {\n          b.calm++;\n          if (b.calm >= 12) {\n            b.asleep = true;\n            b.vx = 0;\n            b.vy = 0;\n            b.om = 0;\n            b.invMass = 0;\n            b.invI = 0;\n            continue;\n          }\n        } else {\n          b.calm = 0;\n        }\n        allAsleep = false;\n      }\n      if (allAsleep) return; // parked — kick()/wake() restart the loop\n      raf = requestAnimationFrame(loop);\n    };\n\n    // repaint/restart only — sleeping bodies stay frozen (hover lift, drag)\n    const kick = () => {\n      if (raf === 0) {\n        last = 0;\n        active = 0; // restarting from park = a fresh activity window\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    // full physics wake — support or geometry changed (spawn/dismiss/resize)\n    const wake = () => {\n      // eslint-disable-next-line no-console\n      console.log(\"[sediment-debug] wake() called\");\n      for (const b of bodiesRef.current.values()) {\n        if (b.dead || b.dragging) continue;\n        if (b.asleep) {\n          b.asleep = false;\n          b.invMass = b.baseInvMass;\n          b.invI = b.baseInvI;\n        }\n        b.calm = 0;\n        b.age = 0;\n      }\n      active = 0;\n      kick();\n    };\n    wakeRef.current = wake;\n    kickRef.current = kick;\n\n    const ro = new ResizeObserver(() => {\n      const rect = root.getBoundingClientRect();\n      W = rect.width;\n      H = rect.height;\n      wake();\n    });\n    ro.observe(root);\n    wake();\n\n    return () => {\n      cancelAnimationFrame(raf);\n      raf = -1; // wake() after teardown must not restart the loop\n      ro.disconnect();\n      wakeRef.current = () => {};\n      kickRef.current = () => {};\n    };\n  }, [reduced, gravity, restitution]);\n\n  // -------------------------------------------------------------------------\n  // interaction — hover lift + timer pause, swipe-right dismissal\n  // -------------------------------------------------------------------------\n  const hoverIn = useCallback(\n    (id: string) => {\n      pauseTimer(id);\n      const b = bodiesRef.current.get(id);\n      if (b && !b.dead) {\n        b.hovered = true;\n        b.el.style.boxShadow = shadowRef.current;\n        kickRef.current();\n      }\n    },\n    [pauseTimer]\n  );\n\n  const hoverOut = useCallback(\n    (id: string) => {\n      resumeTimer(id);\n      const b = bodiesRef.current.get(id);\n      if (b && !b.dead) {\n        b.hovered = false;\n        b.el.style.boxShadow = restShadowRef.current;\n        kickRef.current();\n      }\n    },\n    [resumeTimer]\n  );\n\n  const beginDrag = useCallback(\n    (id: string, e: ReactPointerEvent<HTMLDivElement>) => {\n      if ((e.target as HTMLElement).closest(\"button\")) return;\n      const b = bodiesRef.current.get(id);\n      if (!b || b.dead) return;\n      e.currentTarget.setPointerCapture(e.pointerId);\n      b.dragging = true;\n      b.asleep = false;\n      b.calm = 0;\n      b.spring = false;\n      b.dragPX = e.clientX;\n      b.dragX0 = b.x;\n      b.dragLastX = e.clientX;\n      b.dragLastT = performance.now();\n      b.dragVX = 0;\n      b.invMass = 0; // kinematic while held — the pile leans on it\n      b.invI = 0;\n      b.vx = 0;\n      b.vy = 0;\n      b.om = 0;\n      kickRef.current();\n    },\n    []\n  );\n\n  const moveDrag = useCallback((id: string, e: ReactPointerEvent<HTMLDivElement>) => {\n    const b = bodiesRef.current.get(id);\n    if (!b || !b.dragging || b.dead) return;\n    b.x = b.dragX0 + Math.max(-24, e.clientX - b.dragPX);\n    const now = performance.now();\n    const dt = (now - b.dragLastT) / 1000;\n    if (dt > 0.001) b.dragVX = ((e.clientX - b.dragLastX) / dt) * 0.6;\n    b.dragLastX = e.clientX;\n    b.dragLastT = now;\n    kickRef.current();\n  }, []);\n\n  const endDrag = useCallback(\n    (id: string, cancelled: boolean) => {\n      const b = bodiesRef.current.get(id);\n      if (!b || !b.dragging) return;\n      b.dragging = false;\n      b.invMass = b.baseInvMass;\n      b.invI = b.baseInvI;\n      const dx = b.x - b.dragX0;\n      if (!cancelled && dx > 80) {\n        dismiss(id);\n      } else {\n        // under threshold: spring back to the grab origin (k=180, ζ=0.9)\n        b.spring = true;\n        b.anchorX = b.dragX0;\n        b.vx = b.dragVX * 0.3;\n      }\n      // full wake: the spring-back needs a fresh activity window, and the\n      // pile may have to reflow around wherever the card was released\n      wakeRef.current();\n    },\n    [dismiss]\n  );\n\n  if (reduced) {\n    return (\n      <div\n        ref={containerRef}\n        role=\"region\"\n        aria-label={ariaLabel}\n        className={`relative overflow-y-auto ${className}`}\n      >\n        <div className=\"flex flex-col gap-2 p-3\">\n          {[...toasts].reverse().map((t) => (\n            <ReducedCard\n              key={t.id}\n              toast={t}\n              exiting={exiting.has(t.id)}\n              onDismiss={() => dismiss(t.id)}\n              onEnter={() => pauseTimer(t.id)}\n              onLeave={() => resumeTimer(t.id)}\n            />\n          ))}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      role=\"region\"\n      aria-label={ariaLabel}\n      className={`relative overflow-hidden ${className}`}\n    >\n      {/* physics frame is FLOOR_INSET px shorter than the visible box — the\n          sim's floor sits above the clip line instead of exactly on it, so\n          resting cards never read as clipped */}\n      <div ref={containerRef} className=\"absolute inset-x-0 top-0\" style={{ height: `calc(100% - ${FLOOR_INSET}px)` }}>\n        {toasts.map((t) => (\n          <div\n            key={t.id}\n            ref={(el) => attach(t, el)}\n            role={t.severity === \"error\" ? \"alert\" : \"status\"}\n            onPointerDown={(e) => beginDrag(t.id, e)}\n            onPointerMove={(e) => moveDrag(t.id, e)}\n            onPointerUp={() => endDrag(t.id, false)}\n            onPointerCancel={() => endDrag(t.id, true)}\n            onPointerEnter={() => hoverIn(t.id)}\n            onPointerLeave={() => hoverOut(t.id)}\n            className=\"absolute left-0 top-0 w-64 max-w-[calc(100%-16px)] cursor-grab touch-pan-y select-none rounded-sm border border-border bg-surface transition-opacity duration-150 will-change-transform hover:border-foreground/20\"\n            style={{\n              borderLeftWidth: 3,\n              borderLeftColor: RAIL[t.severity],\n              // constant in props → React never rewrites it over the sim's\n              // per-frame writes; attach() replaces it before first paint\n              transform: \"translate3d(-200%, -200%, 0)\",\n            }}\n          >\n            <CardInner toast={t} onDismiss={() => dismiss(t.id)} />\n          </div>\n        ))}\n      </div>\n      {/* soft vignette reads the inset floor gap as intentional depth, not a\n          rendering gap */}\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-background/60 to-transparent\"\n      />\n    </div>\n  );\n});\n",
      "type": "registry:ui",
      "target": "components/ui/toast-gravity-stack.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa",
      "error": "#ea001d",
      "warning": "#7a5200",
      "success": "#2d7a2d"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717",
      "error": "#ff6369",
      "warning": "#f5a623",
      "success": "#47a447"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "toast",
      "notification",
      "physics",
      "gravity",
      "rigid-body",
      "micro-interaction",
      "feedback"
    ],
    "instruction": "A toast system where notifications fall under real gravity and pile into a jostling heap at the base of a docked surface. RENDERING: real DOM toast cards (severity icon, title, mono message, dismiss button, role=status/alert) whose translate/rotate transforms are written per-frame by a rigid-body-lite 2D sim — refs only on the hot path, no React state, no canvas. Each card's collision proxy is a rounded box: a row of circles of half-card-height radius along the card midline (corner circles plus midline fill), so corners round off and nothing tunnels through edge gaps; container floor and walls are static planes. MOTION: gravity 1800 px/s^2 at fixed 120Hz substeps; restitution 0.15 applied only above an 80 px/s impact threshold; tangential Coulomb friction mu 1.2 at every contact (raised from an earlier 0.85, which let cards slide under >40° contacts forever); angular damping 0.92/frame framerate-normalized via pow(0.92, dt*60); mass by severity error=3 / warning=2 / info=1 so heavier bodies displace lighter ones on impact and errors migrate toward the pile floor. Toasts spawn from the top, x round-robined across up to 4 jittered tray-width slots (vs. a single fixed edge) so the pile heaps rather than sliding in flat from one side, with vx jitter +/-40 px/s and rotation jitter +/-4 deg; each arrival's impact impulse briefly jostles the pile. The physics floor sits a few px above the tray's visible bottom edge (a shorter inner sim frame inside the clipped outer box) so a rotated card's corner overhang never reads as clipped. SLEEP: the whole sim parks when every body holds |v| < 8 px/s and |omega| < 0.05 rad/s for 12 consecutive frames; spawn, dismiss, hover, drag, and resize wake it. Auto-dismiss default 6s per toast, paused on hover; oldest layers fade to opacity 0.55 as newer sediment lands. Dismissing removes the collision body instantly (150ms opacity fade on the card) and the sediment above resettles under gravity. INTERACTION: hover lifts the card 2px and applies a shadow tinted from a fixed dark ink (rgba(0,0,0,...), not a theme token, so it never inverts to a light halo in dark mode); drag right past 80px dismisses, under-threshold release springs back with k=180, zeta=0.9 while the held card goes kinematic so the pile leans on it. Severity color appears only as a 3px left rail using var(--error)/var(--warning)/var(--success); everything else is neutral tokens (bg-surface, border-border, hover:border-foreground/20, accent focus ring on dismiss buttons). REDUCED MOTION: classic static vertical list, newest on top, instant add/remove with 150ms opacity fades, no physics. Imperative handle exposes push/dismiss/clear. ResizeObserver re-derives floor and walls (zero-size container guarded, coincident-center collision normals fall back to vertical); every rAF, observer, listener, and timer is torn down on unmount."
  },
  "type": "registry:ui"
}