{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-stack",
  "title": "Notification Stack",
  "description": "A persistent notification centre whose items collapse into a pile with uniform peeking edges and fan out on a pull.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "springs"
  ],
  "files": [
    {
      "path": "src/components/trovecn/feedback/notification-stack.tsx",
      "content": "\"use client\";\n\n/**\n * NotificationStack: a persistent notification centre. Items collapse into a\n * pile and fan out on demand. The iOS lock screen's job, which Sonner does not\n * do. Sonner owns transient toasts and stops there.\n *\n * The motion is the component, so per `docs/design-system.md` this states the\n * choreography instead of running the playbook. There is one focal movement,\n * the collapse and fan-out, and everything below is its physics.\n *\n * - Each rung down the pile sits `PEEK` lower, smaller, dimmer, and blurrier.\n *   Four properties at once. The blur ramp is what makes a pile read as depth\n *   instead of three offset rectangles, and it is the part cheaper versions\n *   skip.\n * - Buried cards clip to the front card's height and fade their content out,\n *   so every peek is the same size whatever the cards underneath hold. Uniform\n *   peeks are the whole illusion.\n * - Dragging down separates the cards with saturating resistance, the same\n *   rubber band `elastic-slider` uses, so the collection shares one physical\n *   vocabulary. Releasing past `PULL_TO_COMMIT` finishes the opening. Below it\n *   the pile springs shut.\n * - An arriving card drops from above, the pile compresses into the contact\n *   and rebounds out of it, and the rungs give way `ARRIVE_IMPACT` later. On\n *   contact rather than on the event, so the sequence reads as cause and\n *   effect. They overshoot, because that is the only thing that says one card\n *   just became the second one.\n * - Dismissal has two exits because it has two causes. A thrown card leaves\n *   sideways at the speed it was released at. A dismissed one falls backwards\n *   along the pile's depth axis, past where any rung sits, and blurs out there.\n * - Dismissal is three beats, never one. The card leaves, the gap closes\n *   `DEPART_LEAD_*` behind it, the rest re-take their depth `RESTACK_LEAD_*`\n *   behind that. In one window a close reads as a blink.\n * - Both events scale to the state they happen in. Collapsed, an arrival is\n *   otherwise a text swap plus twelve pixels, and a gesture tuned for the open\n *   list disappears.\n *\n * Four departures from the house rules, each named where the rules ask:\n *\n * 1. `filter`, `clipPath`, and the stack's `height` animate rather than\n *    `transform`. None has a transform equivalent. Blur is not expressible as\n *    one, a `scaleY` crop would squash the buried card's corner radii into\n *    ellipses, and the container has to reserve the room it opens into or it\n *    overlaps the content below. All three resolve instantly under\n *    `useReducedMotion()`. The values stay, since the depth ramp is state\n *    rather than decoration. Only the travel goes.\n * 2. `LAND` and `THROW` are bespoke curves rather than tiers, under the\n *    gesture-and-physics exemption in `@/lib/springs`. A landing squash and a\n *    throw carrying pointer velocity are what that exemption covers.\n * 3. `MAX_PULL` and `PULL_DECAY` are the same, for the same reason.\n * 4. A buried card animates `opacity` on a nested element while its parent\n *    animates `opacity` too. The slab dims as a whole and its content leaves,\n *    and separating them lets the content go first.\n *\n * Hover does not separate the pile. Sonner already owns that gesture on a stack\n * of cards, and borrowing it is what would make this read as a Sonner reskin\n * rather than the persistent centre Sonner is not. The pull is the affordance,\n * and it is the one this pattern has on the device it comes from.\n */\n\nimport { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  type MotionValue,\n  type PanInfo,\n  type Transition,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { easeOutStrong, spring } from \"@/lib/springs\";\n\n/** How far below the card in front each buried card peeks out. */\nconst PEEK = 12;\n/** Scale removed per rung down the pile. */\nconst DEPTH_SCALE = 0.045;\n/** Blur added per rung, in px. Under 1px on purpose. A peek is only `PEEK`\n *  tall, and a blur tuned against a whole card turns that strip into a smudge. */\nconst DEPTH_BLUR = 0.9;\n/** Row gap once the stack is open. */\nconst GAP = 8;\n/** Corner radius of a card. Synced with `rounded-xl` by hand, because\n *  `clipPath` cannot read a Tailwind radius. */\nconst RADIUS = 12;\n/** Bleed on the clip rect so an *unclipped* card keeps its shadow. `inset(0 …)`\n *  cuts it off at the border box. Buried cards get the opposite treatment and\n *  clip flush, since a shadow spreading sideways from behind the front card is\n *  what makes a pile read as a smudge. */\nconst CLIP_BLEED = 32;\n\n/** Per-card delay as the stack opens, front card leading. */\nconst STAGGER = 0.022;\n/** Closing is an exit, so it staggers at half the rate. */\nconst CLOSE_STAGGER = STAGGER / 2;\n/** How long the gap-close leads the restack by, after a dismissal. */\nconst RESTACK_LEAD_OPEN = 0.09;\nconst RESTACK_LEAD_PILE = 0.14;\n\n/**\n * Bespoke gesture curves, departures 2 and 3 above.\n *\n * `MAX_PULL` is normalised separation, where 1.0 is a fully previewed open. The\n * pull saturates towards it and never arrives, so every extra pixel returns\n * less than the last and the pile never feels like it stopped tracking the\n * finger. `PULL_DECAY` is the travel that buys the first half.\n */\nconst MAX_PULL = 1;\nconst PULL_DECAY = 110;\n/** Extra separation, in px per rung, at a full pull. */\nconst PULL_SPREAD = 26;\n/** Pointer travel that commits to the other state on release. */\nconst PULL_TO_COMMIT = 54;\n/**\n * An arrival is two different events depending on where it lands.\n *\n * Open, the cards below travel a full card height and the arrival reads on that\n * alone. Collapsed, it is a text swap in place plus `PEEK` of growth, which is\n * sub-perceptual and registers as a blink. So the collapsed case gets a bigger\n * gesture rather than a longer one. Further to fall, deeper to compress, a real\n * overshoot on the rungs.\n */\nconst ARRIVE_DROP_OPEN = 10;\nconst ARRIVE_DROP_PILE = 22;\n/** The card's own fall. Longer than any tier, on the playbook's reasoning for\n *  an arriving surface. This is occasional, not constant. */\nconst ARRIVE_PILE = { type: \"spring\", duration: 0.34, bounce: 0.2 } as const;\n/**\n * Opacity finishes long before the transform. Sharing one curve leaves the\n * arriving card semi-transparent over the outgoing card's text for its first\n * frames, and two texts in one place is the artefact masked transitions exist\n * to avoid.\n */\nconst ARRIVE_FADE = { duration: 0.12, ease: easeOutStrong } as const;\n/** How long after the card starts falling the pile feels it. The rungs give way\n *  on contact, not on the keystroke, which is what turns a simultaneous blur of\n *  movement into cause and effect. */\nconst ARRIVE_IMPACT = 0.11;\n/** The rungs giving way. Bouncier than any tier on purpose, because the\n *  overshoot is the demotion. Without it the front card slides its 12px and\n *  nothing says it just became the second one. */\nconst ARRIVE_SETTLE = { type: \"spring\", duration: 0.26, bounce: 0.32 } as const;\n/** Restacking content waits for its container to make room. The reverse is\n *  listed under Avoid in `docs/design-system.md`. */\nconst CONTENT_LEAD = 0.08;\n/** Landing squash. Compresses *into* the contact and rebounds out of it rather\n *  than snapping to compressed and springing back. Something is falling onto\n *  the pile, so the load arrives over time. */\nconst LAND_DURATION = 0.42;\nconst LAND: Transition = {\n  duration: LAND_DURATION,\n  times: [0, 0.5, 1],\n  ease: [\"easeIn\", easeOutStrong],\n};\n/** 2.5% of a collapsed pile is two pixels. The gesture scales to the object it\n *  happens to rather than sitting at one constant across both states. */\nconst SQUASH_OPEN = 0.975;\nconst SQUASH_PILE = 0.955;\n/** A throw leaves at the speed it was released at, so it takes a velocity\n *  rather than a duration. `THROW_FADE` is tuned close to it, so the card does\n *  not go invisible long before the list has finished noticing it left. */\nconst THROW = { type: \"spring\", bounce: 0, duration: 0.32 } as const;\nconst THROW_FADE = 0.26;\n/**\n * A card leaving under its own steam rather than under a thumb.\n *\n * Not `spring.quick.exit` at 100ms, the icon-crossfade tier: four sizes too\n * small for a whole surface, and a dismissal on it reads as a pop rather than\n * as something leaving. Still quicker than the arrival, which is the asymmetry\n * the tiers encode.\n */\nconst DEPART = { duration: 0.22, ease: easeOutStrong } as const;\n/**\n * How far back it falls. Scale and blur are already this component's depth axis,\n * so a receding card travels *along* it, past where rung two sits, and reads as\n * going backwards into the stack rather than shrinking in place.\n *\n * Deliberately no `y`. Three ways to leave need three directions. A throw goes\n * sideways, a dismissal goes backwards, an arrival comes down.\n */\nconst DEPART_SCALE = 0.86;\nconst DEPART_BLUR = 8;\n/**\n * The gap waits for the card to be on its way out. Closing it in the same frame\n * puts both inside one window and makes neither legible.\n *\n * Scaled to the state, for the reason `SQUASH_OPEN` gives. Open, rung one\n * climbs a whole card plus `GAP`. Collapsed it climbs `PEEK` plus the scale\n * gap, about 15px, and leads that separate three beats across 80px let them\n * fuse back into one at 15. The arrival branches this way in three places. The\n * departure was the last event still running open-state timing in both.\n */\nconst DEPART_LEAD_OPEN = 0.07;\nconst DEPART_LEAD_PILE = 0.1;\n/**\n * The promotion, collapsed only. Mirror of `ARRIVE_SETTLE`. If an overshoot is\n * what says one card just became the second one, the card becoming the *first*\n * one needs that signal at least as badly, and at 15px of travel it has nothing\n * else. Open, the rung climbs a whole card and reads on distance alone, so it\n * stays on the tier.\n */\nconst RESTACK_PILE = { type: \"spring\", duration: 0.24, bounce: 0.26 } as const;\n/** Horizontal travel that uncovers the rung behind the front card. Small, but\n *  not zero: a few pixels of slop while deciding to swipe should not flash the\n *  card underneath. */\nconst UNCOVER_TRAVEL = 6;\n/** Either of these dismisses: far enough, or fast enough. */\nconst THROW_FRACTION = 0.4;\nconst THROW_VELOCITY = 500;\n\n/** What the pile is recovering from. Arrivals and dismissals move the same\n *  slots and want different beats, and neither wants the beat a plain\n *  open/close would give them. */\ntype Settle = \"idle\" | \"arriving\" | \"restacking\";\n\ninterface NotificationItem {\n  id: string;\n  title: ReactNode;\n  body?: ReactNode;\n  /** Trailing timestamp or source, right-aligned against the title. */\n  meta?: ReactNode;\n  icon?: ReactNode;\n}\n\n/**\n * Saturating resistance, shared in shape with `elastic-slider`'s band.\n * `x / (x + decay)` approaches the ceiling without reaching it.\n */\nfunction resist(travel: number) {\n  const magnitude = Math.abs(travel);\n  return Math.sign(travel) * MAX_PULL * (magnitude / (magnitude + PULL_DECAY));\n}\n\n/**\n * Deliberately shallow. A buried card is the same material as the front one,\n * only further back, and scale, blur, and the peek carry the depth. Fading hard\n * washes the card tone towards the surface behind it — the same failure\n * `design-system.md` warns about under \"Surfaces must visibly step\", arrived at\n * from the inside. The pile stops reading as separate planes and becomes one\n * smudge.\n */\nfunction depthOpacity(depth: number) {\n  if (depth === 0) return 1;\n  return depth === 1 ? 0.9 : 0.72;\n}\n\ninterface StackCardProps {\n  item: NotificationItem;\n  index: number;\n  /** Rung in the collapsed pile, capped so a deep list doesn't keep receding. */\n  depth: number;\n  /** Beyond the cap: still mounted and still the same DOM, just not painted. */\n  buried: boolean;\n  expanded: boolean;\n  dismissible: boolean;\n  /** Normalised, resisted pull. Positive opens, negative closes. */\n  pull: MotionValue<number>;\n  /** Slot geometry, resolved by the parent from the measured heights. */\n  slotY: number;\n  clipBottom: number;\n  /** Buried cards clip flush; the front card and every open card do not. */\n  clipped: boolean;\n  /** What the pile is recovering from, if anything. */\n  settle: Settle;\n  /** This card is the one behind a card being swiped off. Collapsed only. */\n  uncovered: boolean;\n  measured: boolean;\n  reduceMotion: boolean;\n  onHeight: (id: string, height: number) => void;\n  onDismiss?: (id: string) => void;\n  onPull: (travel: number) => void;\n  onPullEnd: (travel: number) => void;\n  onUncover: (active: boolean) => void;\n  onActivate: () => void;\n}\n\nfunction StackCard({\n  item,\n  index,\n  depth,\n  buried,\n  expanded,\n  dismissible,\n  pull,\n  slotY,\n  clipBottom,\n  clipped,\n  settle,\n  uncovered,\n  measured,\n  reduceMotion,\n  onHeight,\n  onDismiss,\n  onPull,\n  onPullEnd,\n  onUncover,\n  onActivate,\n}: StackCardProps) {\n  const chromeRef = useRef<HTMLDivElement>(null);\n  const [thrown, setThrown] = useState(false);\n  const draggedRef = useRef(false);\n\n  // Gesture layer. `x` is the swipe, `throwOpacity` the fade that rides it out.\n  const x = useMotionValue(0);\n  const throwOpacity = useMotionValue(1);\n\n  // The pull previews the other state by spreading the rungs apart. Driven off\n  // a motion value pair so it re-derives when the spread factor changes, rather\n  // than capturing it in a closure that never updates.\n  const spread = useMotionValue(0);\n  useEffect(() => {\n    spread.set(expanded ? Math.min(index, 4) : depth);\n  }, [expanded, index, depth, spread]);\n  const pullY = useTransform(\n    [pull, spread],\n    ([amount, factor]) => (amount as number) * PULL_SPREAD * (factor as number),\n  );\n\n  useLayoutEffect(() => {\n    const chrome = chromeRef.current;\n    if (!chrome) return;\n    // Measure once, synchronously, before this card is painted. Every other\n    // card's slot is solved from the front card's height, and an arriving card\n    // lands at the front, so waiting on the observer's first async callback\n    // leaves one committed frame where the pile has no front card, collapses to\n    // a stub, and springs back out. `offsetHeight` rather than a rect, since an\n    // ancestor is mid-scale and a rect would inherit it.\n    onHeight(item.id, chrome.offsetHeight);\n    const observer = new ResizeObserver(([entry]) => {\n      if (entry) onHeight(item.id, entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height);\n    });\n    observer.observe(chrome);\n    return () => observer.disconnect();\n  }, [item.id, onHeight]);\n\n  const scale = expanded ? 1 : 1 - depth * DEPTH_SCALE;\n  const opacity = expanded ? 1 : buried ? 0 : depthOpacity(depth);\n  const blur = expanded ? 0 : depth * DEPTH_BLUR;\n  // The front card owns the chrome. A rung being uncovered reveals its text and\n  // nothing else, because it has not been promoted yet, only exposed.\n  const chromeVisible = expanded || depth === 0;\n  const contentVisible = chromeVisible || uncovered;\n\n  // Opening leads with the front card and each rung follows. Closing runs the\n  // other way at half the rate, because exits are faster than enters.\n  //\n  // A pile recovering from an arrival or a dismissal is doing neither. The exit\n  // *tween* the collapsed state would hand it is what made an arrival read as\n  // three unrelated animations: rungs on a flat curve, a card springing in over\n  // them, the whole pile bouncing. Recovery takes the same spring as everything\n  // else and no stagger, so the rungs give way as the card lands.\n  const settling = settle !== \"idle\";\n  const arriving = settle === \"arriving\";\n  // A collapsed restack is its own event, see `DEPART_LEAD_PILE`. It is also\n  // the only one where a card is *promoted* rather than moved, which is what\n  // the overshoot and the slower content are for.\n  const promoting = settle === \"restacking\" && !expanded;\n  const departLead = expanded ? DEPART_LEAD_OPEN : DEPART_LEAD_PILE;\n  const restackLead = expanded ? RESTACK_LEAD_OPEN : RESTACK_LEAD_PILE;\n  // An arrival's rungs wait for contact. Same curve, offset in time, which\n  // reads as cause and effect. Mixing curves here reads as one blur of movement.\n  const stagger = arriving\n    ? ARRIVE_IMPACT\n    : settle === \"restacking\"\n      ? departLead\n      : expanded\n        ? index * STAGGER\n        : depth * CLOSE_STAGGER;\n  const curve = arriving\n    ? ARRIVE_SETTLE\n    : promoting\n      ? RESTACK_PILE\n      : settling || expanded\n        ? spring.moderate.enter\n        : spring.moderate.exit;\n  // Reduced motion is Contract, not convention. `design-system.md`: travel and\n  // bounce are gone. Every slot *value* below still holds, since the depth ramp\n  // is state rather than decoration. Only the journey to it goes. One gate for\n  // the whole layer, so a curve added later cannot slip past.\n  const still = { duration: 0 };\n  const animates = measured && !reduceMotion;\n  const travel = animates ? { ...curve, delay: stagger } : still;\n  // Beat two, and only after a dismissal. An arrival waits for contact instead.\n  const depthDelay = settle === \"restacking\" ? departLead + restackLead : stagger;\n  const depthTransition = animates ? { ...curve, delay: depthDelay } : still;\n  // Blur and the clip rect take the scale's curve but never its bounce. A\n  // spring overshooting `blur(0px)` gives a negative radius, an invalid filter\n  // value, so the declaration drops and the card renders unblurred for those\n  // frames. Same duration, no overshoot, so the ramp lands with its transform.\n  const flatCurve = \"bounce\" in curve ? { ...curve, bounce: 0 } : curve;\n  const depthFlat = animates ? { ...flatCurve, delay: depthDelay } : still;\n  // The label follows its container rather than racing it. A promoted card's\n  // content is a whole surface arriving, not an icon crossfade, so it takes the\n  // tier above. Same correction `DEPART` records against `spring.quick`.\n  const contentDelay = depthDelay + CONTENT_LEAD;\n  const contentCurve = promoting ? spring.moderate.enter : spring.quick.enter;\n  // Leaving is unconditional and immediate. Content should be gone before its\n  // card is buried, never caught halfway under one. An uncovered rung is the\n  // other exception: it is tracking a finger, so it reveals without the lead.\n  const contentTransition = !contentVisible\n    ? reduceMotion\n      ? still\n      : spring.quick.exit\n    : uncovered\n      ? reduceMotion\n        ? still\n        : spring.quick.enter\n      : animates\n        ? { ...contentCurve, delay: contentDelay }\n        : still;\n\n  const canPull = index === 0;\n\n  const handleDragEnd = (_event: unknown, info: PanInfo) => {\n    const horizontal = Math.abs(info.offset.x) > Math.abs(info.offset.y);\n    if (horizontal && dismissible) {\n      const width = chromeRef.current?.offsetWidth ?? 320;\n      const far = Math.abs(info.offset.x) > width * THROW_FRACTION;\n      const fast = Math.abs(info.velocity.x) > THROW_VELOCITY;\n      if (far || fast) {\n        // The uncover flag deliberately survives the throw. Clearing it here\n        // would fade the rung's text out for the 320ms the card spends leaving\n        // and then straight back in once the list renumbers it to the front.\n        const direction = info.offset.x < 0 ? -1 : 1;\n        setThrown(true);\n        animate(throwOpacity, 0, { duration: THROW_FADE });\n        animate(x, direction * (width + 80), {\n          ...THROW,\n          velocity: info.velocity.x,\n          onComplete: () => onDismiss?.(item.id),\n        });\n        return;\n      }\n      // Snapped back, so the rung goes under cover again.\n      if (canPull) onUncover(false);\n      animate(x, 0, spring.moderate.enter);\n      return;\n    }\n    if (canPull) {\n      onUncover(false);\n      onPullEnd(info.offset.y);\n    }\n  };\n\n  return (\n    <motion.li\n      // Layer 1, the slot. Owns where this card sits in the pile and nothing\n      // else. Discrete, React-driven, the only layer that knows about depth.\n      className=\"absolute inset-x-0 top-0 list-none\"\n      style={{ zIndex: 100 - index, transformOrigin: \"top center\" }}\n      animate={{\n        y: slotY,\n        scale,\n        opacity,\n        filter: `blur(${blur}px)`,\n        clipPath: clipPathFor(clipBottom, clipped),\n      }}\n      transition={{\n        y: travel,\n        scale: depthTransition,\n        opacity: depthTransition,\n        filter: depthFlat,\n        clipPath: depthFlat,\n      }}\n    >\n      {/* Layer 2, the pull. A motion value the parent drives during a drag,\n          kept off layer 1 so a gesture never fights a slot animation. */}\n      <motion.div style={{ y: pullY }}>\n        {/* Layer 3, the gesture, plus this card's arrival and departure. */}\n        <motion.div\n          drag={canPull ? true : \"x\"}\n          dragDirectionLock\n          dragConstraints={{ top: 0, bottom: 0 }}\n          dragElastic={{ top: 0.4, bottom: 0.4, left: 1, right: 1 }}\n          dragMomentum={false}\n          onDragStart={() => {\n            draggedRef.current = true;\n          }}\n          onDrag={(_event, info) => {\n            if (!canPull) return;\n            if (Math.abs(info.offset.x) > Math.abs(info.offset.y)) {\n              if (dismissible) onUncover(Math.abs(info.offset.x) > UNCOVER_TRAVEL);\n              return;\n            }\n            onPull(info.offset.y);\n          }}\n          onDragEnd={handleDragEnd}\n          style={{ x }}\n          // Arrives from *above* and presses down onto the pile. The first\n          // version came up from below and fought both the squash and the rungs\n          // giving way beneath it. Two things travelling down, one travelling\n          // up, no read of what happened. 4px of blur rather than 8, because at\n          // 8 the card materialises out of fog instead of landing on something.\n          initial={\n            reduceMotion\n              ? { opacity: 0 }\n              : {\n                  opacity: 0,\n                  scale: 0.96,\n                  y: -(expanded ? ARRIVE_DROP_OPEN : ARRIVE_DROP_PILE),\n                  filter: \"blur(4px)\",\n                }\n          }\n          animate={{ opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" }}\n          exit={\n            thrown\n              ? // Already gone, under its own velocity.\n                { opacity: 0, transition: { duration: 0 } }\n              : reduceMotion\n                ? // The fade stays, since opacity survives and a card that just\n                  // vanished reads as a bug. The depth-axis travel is what goes.\n                  { opacity: 0, transition: DEPART }\n                : // The other cause. It recedes into the pile instead.\n                  {\n                    opacity: 0,\n                    scale: DEPART_SCALE,\n                    filter: `blur(${DEPART_BLUR}px)`,\n                    transition: DEPART,\n                  }\n          }\n          // A card arriving unbidden is the playbook's larger, rarer change. It\n          // settles alongside the pile's landing rather than well before it.\n          // Opacity comes off this curve entirely, see `ARRIVE_FADE`.\n          transition={\n            reduceMotion\n              ? { ...ARRIVE_FADE, opacity: ARRIVE_FADE }\n              : { ...(expanded ? spring.slow.enter : ARRIVE_PILE), opacity: ARRIVE_FADE }\n          }\n          className={cn(!expanded && depth > 0 && \"pointer-events-none\")}\n        >\n          {/* Layer 4, the chrome, plus the fade that rides a throw out. */}\n          <motion.div\n            ref={chromeRef}\n            data-slot=\"notification-stack-card\"\n            style={{ opacity: throwOpacity }}\n            onClick={() => {\n              if (draggedRef.current) {\n                draggedRef.current = false;\n                return;\n              }\n              onActivate();\n            }}\n            className={cn(\n              \"group/card relative touch-pan-y rounded-xl border border-border bg-card px-3.5 py-3 shadow-card select-none\",\n              !expanded && \"cursor-pointer\",\n            )}\n          >\n            <motion.div\n              animate={{ opacity: contentVisible ? 1 : 0 }}\n              transition={contentTransition}\n              className=\"flex gap-3\"\n            >\n              {item.icon ? (\n                <span\n                  aria-hidden\n                  className=\"mt-px grid size-7 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground [&_svg]:size-3.5\"\n                >\n                  {item.icon}\n                </span>\n              ) : null}\n              <div className=\"min-w-0 flex-1\">\n                <div className=\"flex items-baseline justify-between gap-3\">\n                  <span className=\"text-control truncate text-foreground\">{item.title}</span>\n                  {item.meta ? (\n                    <span className=\"text-meta shrink-0 tabular-nums text-muted-foreground\">\n                      {item.meta}\n                    </span>\n                  ) : null}\n                </div>\n                {item.body ? (\n                  <p className=\"text-caption mt-0.5 line-clamp-2 text-muted-foreground\">\n                    {item.body}\n                  </p>\n                ) : null}\n              </div>\n            </motion.div>\n            {dismissible && chromeVisible ? (\n              <button\n                type=\"button\"\n                aria-label=\"Dismiss notification\"\n                onClick={(event) => {\n                  event.stopPropagation();\n                  onDismiss?.(item.id);\n                }}\n                // The keyboard's route to the job the swipe does. Chrome, so\n                // opacity and nothing else.\n                className=\"absolute -top-1.5 -right-1.5 grid size-5 place-items-center rounded-full border border-border bg-card text-muted-foreground opacity-0 transition-opacity duration-quick group-hover/card:opacity-100 hover:text-foreground focus-visible:opacity-100 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none\"\n              >\n                <X className=\"size-2.5\" strokeWidth={2.5} />\n              </button>\n            ) : null}\n          </motion.div>\n        </motion.div>\n      </motion.div>\n    </motion.li>\n  );\n}\n\n/**\n * How long the pile stays in a recovery state, in ms.\n *\n * Derived rather than picked. It has to outlast every beat its branch\n * schedules, or `settle` returns to idle mid-flight and the pile swaps curves\n * under itself. The hand-tuned constant this replaced had no margin at all on\n * the arrival branch, and scaling the collapsed restack pushed that one 100ms\n * past it. One frame of slack on top.\n */\nfunction settleWindow(kind: Exclude<Settle, \"idle\">, expanded: boolean) {\n  if (kind === \"arriving\") {\n    const fall = expanded ? spring.slow.enter.duration : ARRIVE_PILE.duration;\n    const rungs = ARRIVE_IMPACT + ARRIVE_SETTLE.duration;\n    return Math.max(LAND_DURATION, fall, rungs) * 1000 + 16;\n  }\n  const lead =\n    (expanded ? DEPART_LEAD_OPEN : DEPART_LEAD_PILE) +\n    (expanded ? RESTACK_LEAD_OPEN : RESTACK_LEAD_PILE);\n  const depth = expanded ? spring.moderate.enter.duration : RESTACK_PILE.duration;\n  const content = CONTENT_LEAD + (expanded ? spring.quick.enter : spring.moderate.enter).duration;\n  return (lead + Math.max(depth, content)) * 1000 + 16;\n}\n\nfunction clipPathFor(bottom: number, clipped: boolean) {\n  // Clipped means buried. Cut flush on every side, so the strip that peeks out\n  // is the card's own edge and nothing else. Otherwise don't clip at all, which\n  // still needs a rect, bled outwards far enough to spare the shadow.\n  if (!clipped) {\n    const bleed = `${-CLIP_BLEED}px`;\n    return `inset(${bleed} ${bleed} ${bleed} ${bleed} round ${RADIUS}px)`;\n  }\n  return `inset(0px 0px ${bottom}px 0px round ${RADIUS}px)`;\n}\n\ninterface NotificationStackProps {\n  notifications: NotificationItem[];\n  onDismiss?: (id: string) => void;\n  /** Heading above the pile. */\n  label?: ReactNode;\n  expanded?: boolean;\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  /** Cards painted in the collapsed pile, including the front one. Three is the\n   *  iOS number and the one this is tuned for; more rungs stop reading as depth. */\n  maxCollapsed?: number;\n  dismissible?: boolean;\n  emptyState?: ReactNode;\n  className?: string;\n}\n\nfunction NotificationStack({\n  notifications,\n  onDismiss,\n  label = \"Notifications\",\n  expanded: expandedProp,\n  defaultExpanded = false,\n  onExpandedChange,\n  maxCollapsed = 3,\n  dismissible = true,\n  emptyState = \"Nothing new\",\n  className,\n}: NotificationStackProps) {\n  const [uncontrolledExpanded, setUncontrolledExpanded] = useState(defaultExpanded);\n  const expanded = expandedProp ?? uncontrolledExpanded;\n  const reduceMotion = useReducedMotion() ?? false;\n\n  const [heights, setHeights] = useState<Record<string, number>>({});\n  const [settle, setSettle] = useState<Settle>(\"idle\");\n  const [uncovering, setUncovering] = useState(false);\n  const pull = useMotionValue(0);\n  const squash = useMotionValue(1);\n\n  const maxDepth = Math.max(0, maxCollapsed - 1);\n  const count = notifications.length;\n\n  const setExpanded = useCallback(\n    (next: boolean) => {\n      if (expandedProp === undefined) setUncontrolledExpanded(next);\n      onExpandedChange?.(next);\n    },\n    [expandedProp, onExpandedChange],\n  );\n\n  const handleHeight = useCallback((id: string, height: number) => {\n    setHeights((current) => (current[id] === height ? current : { ...current, [id]: height }));\n  }, []);\n\n  const prevCount = useRef(count);\n  useEffect(() => {\n    const previous = prevCount.current;\n    prevCount.current = count;\n    if (count === previous) return;\n    // Beat two of a dismissal. The gap closes on the tier, then depth follows.\n    // An arrival gets no such lag, but takes the same spring, so the rungs give\n    // way in the motion the new card lands with.\n    const kind = count < previous ? \"restacking\" : \"arriving\";\n    setSettle(kind);\n    // The list has renumbered, so whatever was uncovered is now the front card\n    // and holds its own content. This is where a committed swipe's flag clears.\n    setUncovering(false);\n    if (count > previous && !reduceMotion) {\n      // Lands with weight, scaled to the state it lands in. Transform-only, so\n      // reduced motion drops it. The leading `null` reads the current value at\n      // start, so a second card landing inside the settle window compresses\n      // from wherever the pile is. An explicit `1` restarts the keyframe from\n      // the top, which popped.\n      animate(squash, [null, expanded ? SQUASH_OPEN : SQUASH_PILE, 1], LAND);\n    }\n    const timer = setTimeout(() => setSettle(\"idle\"), settleWindow(kind, expanded));\n    return () => clearTimeout(timer);\n  }, [count, expanded, reduceMotion, squash]);\n\n  // An emptied stack has nothing to be open about.\n  useEffect(() => {\n    if (count === 0 && expanded) setExpanded(false);\n  }, [count, expanded, setExpanded]);\n\n  // Belt and braces behind the synchronous measure above. If a front card does\n  // reach a paint unmeasured, fall back to the last known height rather than to\n  // zero. Solving against a front height of nothing flattens every slot at\n  // once, the most violent frame this component can produce. The render-time\n  // cache write is idempotent, and an effect would land a frame too late.\n  const lastFrontHeight = useRef(0);\n  const measuredFront = notifications[0] ? heights[notifications[0].id] : undefined;\n  if (measuredFront) lastFrontHeight.current = measuredFront;\n  const frontHeight = measuredFront ?? (lastFrontHeight.current || undefined);\n  const measured = count === 0 || (frontHeight !== undefined && frontHeight > 0);\n\n  const handlePull = useCallback(\n    (travel: number) => {\n      if (reduceMotion) return;\n      // Only the direction with somewhere to go. Closed pulls open, open pulls\n      // shut. The other way round is already at its stop.\n      const directional = expanded ? Math.min(0, travel) : Math.max(0, travel);\n      pull.set(resist(directional));\n    },\n    [expanded, pull, reduceMotion],\n  );\n\n  const handleUncover = useCallback((active: boolean) => setUncovering(active), []);\n\n  const handlePullEnd = useCallback(\n    (travel: number) => {\n      const directional = expanded ? Math.min(0, travel) : Math.max(0, travel);\n      if (Math.abs(directional) > PULL_TO_COMMIT) setExpanded(!expanded);\n      animate(pull, 0, spring.moderate.enter);\n    },\n    [expanded, pull, setExpanded],\n  );\n\n  // Slot geometry. Bottom edges are what has to come out evenly spaced, so each\n  // top edge is solved backwards from its bottom one. A shorter card sits lower\n  // rather than showing a shorter peek.\n  let runningY = 0;\n  const slots = notifications.map((item, index) => {\n    const depth = Math.min(index, maxDepth);\n    const height = heights[item.id] ?? 0;\n    const front = frontHeight ?? height;\n    const clipped = Math.min(height, front);\n    const scale = 1 - depth * DEPTH_SCALE;\n    const collapsedY = front + depth * PEEK - clipped * scale;\n    const expandedY = runningY;\n    runningY += height + GAP;\n    return {\n      item,\n      index,\n      depth,\n      buried: index > maxDepth,\n      slotY: expanded ? expandedY : collapsedY,\n      clipBottom: expanded ? 0 : Math.max(0, height - front),\n      clipped: !expanded && depth > 0,\n    };\n  });\n\n  const collapsedHeight = (frontHeight ?? 0) + Math.min(count - 1, maxDepth) * PEEK;\n  const expandedHeight = Math.max(0, runningY - GAP);\n  const stackHeight = count === 0 ? 0 : expanded ? expandedHeight : collapsedHeight;\n\n  const toggleLabel = expanded ? \"Collapse\" : \"Expand\";\n\n  return (\n    <section\n      data-slot=\"notification-stack\"\n      aria-label={typeof label === \"string\" ? label : undefined}\n      className={cn(\"flex w-full flex-col gap-2.5\", className)}\n    >\n      <div className=\"flex items-center justify-between gap-3 px-0.5\">\n        <span className=\"text-label text-muted-foreground uppercase\">{label}</span>\n        {count > 1 ? (\n          <motion.button\n            type=\"button\"\n            // `layout` animates the width as the label changes length, so the\n            // two labels crossfade rather than teleport. House technique, see\n            // `design-system.md`.\n            layout\n            transition={reduceMotion ? { duration: 0 } : spring.moderate.enter}\n            aria-expanded={expanded}\n            onClick={() => setExpanded(!expanded)}\n            className=\"text-caption relative grid place-items-center rounded-md px-2 py-1 text-muted-foreground transition-colors duration-quick hover:bg-hover hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none\"\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={toggleLabel}\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{\n                  opacity: 0,\n                  transition: reduceMotion ? { duration: 0 } : spring.quick.exit,\n                }}\n                transition={reduceMotion ? { duration: 0 } : spring.quick.enter}\n                className=\"whitespace-nowrap\"\n              >\n                {toggleLabel}\n              </motion.span>\n            </AnimatePresence>\n          </motion.button>\n        ) : null}\n      </div>\n\n      <motion.div\n        // The pile squashes as a whole when a card lands on it.\n        style={{ scaleY: squash, transformOrigin: \"top center\" }}\n        // Departure 1: the stack has to reserve the room it opens into, and\n        // there is no transform that does that. Instant under reduced motion.\n        animate={{ height: stackHeight }}\n        transition={\n          reduceMotion || !measured\n            ? { duration: 0 }\n            : expanded || settle !== \"idle\"\n              ? spring.moderate.enter\n              : spring.moderate.exit\n        }\n        className=\"relative\"\n      >\n        <ul aria-live=\"polite\" className=\"contents\">\n          <AnimatePresence initial={false}>\n            {slots.map((slot) => (\n              <StackCard\n                key={slot.item.id}\n                item={slot.item}\n                index={slot.index}\n                depth={slot.depth}\n                buried={slot.buried}\n                expanded={expanded}\n                dismissible={dismissible}\n                pull={pull}\n                slotY={slot.slotY}\n                clipBottom={slot.clipBottom}\n                clipped={slot.clipped}\n                settle={settle}\n                uncovered={uncovering && !expanded && slot.index === 1}\n                measured={measured}\n                reduceMotion={reduceMotion}\n                onHeight={handleHeight}\n                onDismiss={onDismiss}\n                onPull={handlePull}\n                onPullEnd={handlePullEnd}\n                onUncover={handleUncover}\n                onActivate={() => {\n                  if (!expanded) setExpanded(true);\n                }}\n              />\n            ))}\n          </AnimatePresence>\n        </ul>\n        {count === 0 && emptyState ? (\n          <motion.p\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            transition={spring.quick.enter}\n            className=\"text-caption text-muted-foreground\"\n          >\n            {emptyState}\n          </motion.p>\n        ) : null}\n      </motion.div>\n    </section>\n  );\n}\n\nexport { NotificationStack, type NotificationItem, type NotificationStackProps };\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}