{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"toast-stack","type":"registry:ui","title":"Toast Stack","description":"A notification stack where every card lands on the air trapped underneath it. The squeeze is algebraic rather than exponential, so half the settle is spent in the last pixel and a half — and dismissing one from the middle ripples up at each card's own rate, with no stagger written anywhere.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/toast-stack.tsx","target":"components/ui/toast-stack.tsx","content":"'use client';\n\nimport './toast-stack.css';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nimport {\n  useCanvasScene,\n  useReducedMotion,\n  type SceneDrawContext,\n  type SceneSetupContext,\n} from '@/hooks/use-canvas-scene';\n\n/**\n * A toast stack whose settle is squeeze-film air damping.\n *\n * A card coming down onto the card below it has to get the air out from between the\n * two faces first, and that air has one way out: sideways, through a gap that is\n * closing on it. Reynolds' lubrication equation for that gap — one-dimensional\n * across the card, vented at the two edges you can see — gives a resisting force\n *\n *     F = C·(−ḣ)/h³,     C ≈ μ·L·W³/2\n *\n * and every card here is `m ÿ = −m g + F_below − F_above` with a unilateral contact\n * term underneath it, integrated at a fixed step. There is no stagger, no spring, no\n * cubic-bezier and no per-card delay anywhere in this file; search it.\n *\n * What the equation buys is the one thing an eased entrance cannot fake: the settle\n * is algebraic, not exponential. Once the film is carrying the weight the gap closes\n * at ḣ = −m g h³/C, so the time from h₀ to h₁ is (C/2mg)(1/h₁² − 1/h₀²) and the tail\n * dominates outright. Over the thirty-four pixel drop below, half the settling time\n * is spent inside the last one and a half pixels. A bezier cannot be made to do that\n * at any control points, because its exit velocity is a fixed fraction of its entry\n * velocity whatever distance it covered; ease-out spends its time evenly by\n * construction, and a landing card does not.\n *\n * Two more consequences, both free. The crawl time is set by the gap and not by the\n * drop — the same expression, evaluated from wherever the film takes over — so a card\n * released from twice the height arrives about a seventh later rather than twice as\n * fast, and the stack self-levels. And dismissing one card out of the middle does not\n * animate the others: their films open, the suction of an opening film drags them\n * part of the way down, and each is caught by the film of whatever is now beneath it.\n * The collapse ripples upward with every card's own timing because the films are a\n * chain of couplings, not a list of delays.\n *\n * The cards are real DOM — a title, a line of body, a dismiss button — placed by\n * writing one transform per card from the solved heights, through refs, never through\n * React state.\n */\n\n/** Seconds per step. 1/h³ is stiff, and a coarser step visibly shortens the tail. */\nconst STEP = 1 / 240;\n/** Sweeps of the implicit velocity solve. Eight is where a four-high collapse stops\n *  depending on which end of the stack the sweep started from. */\nconst SWEEPS = 8;\n/** Steps one frame may run, so a tab that was hidden does not integrate a minute. */\nconst MAX_STEPS = 8;\n/** Gravity, px/s². The house value; with the film coefficient it sets the whole fall. */\nconst G = 1400;\n/**\n * The lumped film coefficient, px⁵/s: μ·L·W³/2 and the card's mass rolled into one\n * number, because only the shape of the law is doing work here. Set from the settle it\n * has to produce — (C/2G)(1/4² − 1/16²) ≈ 0.52s of crawl, which is a card landing on a\n * pile rather than a card being placed on one.\n */\nconst FILM = 24900;\n/** Gap the faces bear on, px. Not zero: two cards in a pile stand off on their own edge\n *  bead, 1/h³ has no value there, and this is the visible seam between settled cards. */\nconst CONTACT = 4;\n/** Contact stiffness, px/s² per px of overlap. High enough that the whole four-high\n *  stack compresses about a third of a pixel, low enough that √K·STEP stays well under\n *  two, which is what an explicit spring needs at this step. */\nconst CONTACT_K = 40000;\n/** Smallest gap the 1/h³ term is evaluated at, px. Real surfaces are rough so the film\n *  never sees zero; numerically it is also what keeps the coefficient finite if a resize\n *  ever seats two cards overlapping. */\nconst H_MIN = 0.9;\n/**\n * Ceiling on the film's tensile force, px/s² per unit mass. Squeezing can be as stiff as\n * it likes, but absolute pressure stops at zero, so a vented film can only pull with\n * about one atmosphere over the card's face. Without the ceiling 1/h³ makes a separating\n * pile move as one rigid block — that is stiction, not a toast stack.\n */\nconst PULL_MAX = 2400;\n/** Height a new card is released above whatever is under it, px. Enough that the free\n *  fall and the crawl are both legible; more reads as a card thrown in from off-screen. */\nconst FALL = 34;\n/** Card height, px. Holds a title, a line of body and a dismiss target; the solver needs\n *  it because it is the pitch of the stack. */\nconst TOAST_H = 58;\n/** Underside to underside of two settled cards. */\nconst PITCH = TOAST_H + CONTACT;\n/** Cards kept. Past four a corner stack has stopped being a corner stack. */\nconst MAX_STACK = 4;\n/** Inset from the bottom-right corner of the stage, px. The Add button sits at the same\n *  inset on the left, so the whole stage keeps one frame. */\nconst MARGIN = 20;\n/** Room kept clear to the left of the cards, px, so a narrow stage narrows the toast\n *  instead of parking a card on top of the Add button. */\nconst CONTROL_ROOM = 118;\n/** Widest and narrowest a card may be drawn, px. */\nconst TOAST_W_MAX = 300;\nconst TOAST_W_MIN = 168;\n\n/** Six notifications a build tool would actually send, cycled by serial number. */\nconst MESSAGES: readonly (readonly [string, string])[] = [\n  ['Registry synced', '38 items · 2 changed since 14:02'],\n  ['Build passed', 'next build in 41s · no warnings'],\n  ['Draft saved', 'toast-stack.tsx · a moment ago'],\n  ['Export ready', 'court-archive.zip · 1.4 MB'],\n  ['Token refreshed', 'Session valid for another 12 hours'],\n  ['Three drafts removed', 'Undo stays available for 30 seconds'],\n];\n\ninterface Toast {\n  readonly id: number;\n  readonly title: string;\n  readonly body: string;\n}\n\n/** One card. `id` is what ties it to its DOM node and to React's list. */\ninterface Card {\n  readonly id: number;\n  /** Height of this card's underside above the page floor, px. */\n  y: number;\n  /** dy/dt, px/s. Negative while the card is coming down. */\n  v: number;\n}\n\ninterface Corner {\n  clock: number;\n  carry: number;\n  /** Seat every card at its resting height and stop integrating. Set under\n   *  `prefers-reduced-motion`, where the loop never runs and a stack advanced one\n   *  accumulator's worth per repaint would never land. */\n  snap: boolean;\n}\n\nfunction make(id: number): Toast {\n  const [title, body] = MESSAGES[id % MESSAGES.length];\n  return { id, title, body };\n}\n\n/*\n * Step scratch, allocated once: the gap under each card, its film coefficient, the\n * explicit half of its velocity update, and the velocity being solved for. Sharing these\n * between instances is safe for the same reason chip-pile shares its closest-point pair —\n * a step runs to completion without yielding.\n */\nconst gap = new Float64Array(MAX_STACK);\nconst coef = new Float64Array(MAX_STACK);\nconst rhs = new Float64Array(MAX_STACK);\nconst vel = new Float64Array(MAX_STACK);\n\n/** Every card on the geometry, stationary. A third of a pixel above where the contact\n *  springs would hold it, which is nothing to look at. */\nfunction seat(cards: readonly Card[]) {\n  for (let i = 0; i < cards.length; i++) {\n    cards[i].y = CONTACT + i * PITCH;\n    cards[i].v = 0;\n  }\n}\n\nfunction advance(cards: readonly Card[]) {\n  const n = cards.length;\n\n  for (let i = 0; i < n; i++) {\n    const card = cards[i];\n    // The page floor is card −1. An immovable surface is a body of infinite mass, so it\n    // needs no branch beyond never being pushed back.\n    const under = i > 0 ? cards[i - 1].y + TOAST_H : 0;\n    const underV = i > 0 ? cards[i - 1].v : 0;\n    const h = card.y - under;\n    const closing = card.v - underV;\n\n    const clamped = Math.max(H_MIN, h);\n    let c = FILM / (clamped * clamped * clamped);\n    // Limited in tension only, and limited by scaling the coefficient rather than the\n    // force: that caps the pull at PULL_MAX while leaving the term linear in ḣ, which is\n    // what the implicit solve below needs it to be.\n    if (closing > 0) c = Math.min(c, PULL_MAX / closing);\n\n    gap[i] = h;\n    coef[i] = c;\n    // Gravity and the bearing under it. The reaction of that bearing on the card below\n    // is added in the second pass, once every gap is known.\n    rhs[i] = card.v - STEP * G;\n    if (h < CONTACT) rhs[i] += STEP * CONTACT_K * (CONTACT - h);\n  }\n  for (let i = 1; i < n; i++) {\n    if (gap[i] < CONTACT) rhs[i - 1] -= STEP * CONTACT_K * (CONTACT - gap[i]);\n  }\n  for (let i = 0; i < n; i++) vel[i] = rhs[i];\n\n  /*\n   * The velocity update, taken implicitly in the film terms. C/h³ passes thirty thousand\n   * per second near contact and an explicit damper needs c·dt < 1, so explicit\n   * integration would want a step forty times smaller than this one. Backward Euler needs\n   * none of that, and because each film couples a card only to its neighbour the system\n   * is tridiagonal — swept alternately up and down the stack, which is Gauss–Seidel on\n   * exactly that matrix. It converges because the diagonal carries the identity too.\n   *\n   * The fixed point of this update for a single card is v = −g·h³/C exactly, so the crawl\n   * runs at the analytic terminal velocity at any step size. That is the reason the tail\n   * can be trusted: it is not a property of the integrator.\n   */\n  for (let pass = 0; pass < SWEEPS; pass++) {\n    const up = pass % 2 === 0;\n    for (let k = 0; k < n; k++) {\n      const i = up ? k : n - 1 - k;\n      const cAbove = i < n - 1 ? coef[i + 1] : 0;\n      const vBelow = i > 0 ? vel[i - 1] : 0;\n      const vAbove = i < n - 1 ? vel[i + 1] : 0;\n      vel[i] =\n        (rhs[i] + STEP * (coef[i] * vBelow + cAbove * vAbove)) /\n        (1 + STEP * (coef[i] + cAbove));\n    }\n  }\n\n  for (let i = 0; i < n; i++) {\n    cards[i].v = vel[i];\n    cards[i].y += vel[i] * STEP;\n  }\n}\n\nfunction run(state: Corner, cards: readonly Card[]) {\n  const now = performance.now();\n  // The hook hands the scene no time at all, so the clock is the scene's own. Capped,\n  // because a frame that took a second must not become a second of stiff integration.\n  const elapsed = state.clock ? Math.min(0.05, (now - state.clock) / 1000) : STEP;\n  state.clock = now;\n\n  if (state.snap) {\n    seat(cards);\n    return;\n  }\n\n  state.carry += elapsed;\n  let steps = 0;\n  while (state.carry >= STEP && steps < MAX_STEPS) {\n    advance(cards);\n    state.carry -= STEP;\n    steps += 1;\n  }\n  if (state.carry > STEP * MAX_STEPS) state.carry = 0;\n}\n\n/**\n * The card list, rebuilt against the toasts React is rendering. A card still on screen\n * keeps the y and the v it had, so a dismissal restarts nothing; a new one is released\n * FALL above whatever is under it *now* rather than above the slot it will end up in,\n * which is why adding onto a stack that has not finished settling starts from the real\n * gap. `rest` is the first pass only: the two toasts the component mounts with were\n * already there before it mounted.\n */\nfunction reconcile(held: readonly Card[], list: readonly Toast[], rest: boolean): Card[] {\n  const next: Card[] = [];\n  for (let i = 0; i < list.length; i++) {\n    const kept = held.find((card) => card.id === list[i].id);\n    if (kept) {\n      next.push(kept);\n      continue;\n    }\n    const under = i > 0 ? next[i - 1].y + TOAST_H : 0;\n    next.push({\n      id: list[i].id,\n      y: rest ? CONTACT + i * PITCH : under + CONTACT + FALL,\n      v: 0,\n    });\n  }\n  return next;\n}\n\n/** The solved heights, written straight to the nodes. The transform carries the corner\n *  inset as well, so no number in the stylesheet has to agree with a number in here. */\nfunction place(cards: readonly Card[], nodes: Map<number, HTMLElement>) {\n  for (const card of cards) {\n    const node = nodes.get(card.id);\n    if (!node) continue;\n    node.style.transform = `translate3d(${-MARGIN}px, ${(-(MARGIN + card.y)).toFixed(2)}px, 0)`;\n  }\n}\n\nfunction build({ width }: SceneSetupContext, layer: HTMLElement | null): Corner {\n  const boxW = Math.max(\n    TOAST_W_MIN,\n    Math.min(TOAST_W_MAX, width - MARGIN * 2 - CONTROL_ROOM),\n  );\n  // The card box is stage geometry, so it is published from the one place that decides\n  // stage geometry instead of being guessed at a second time in CSS.\n  if (layer) {\n    layer.style.setProperty('--toast-stack-w', `${boxW}px`);\n    layer.style.setProperty('--toast-stack-h', `${TOAST_H}px`);\n  }\n  return {\n    clock: 0,\n    carry: 0,\n    snap: false,\n  };\n}\n\n/**\n * The canvas has nothing of its own to show. It stays because `draw` is where the stack is\n * advanced and the cards are placed, and `setup` is the only thing that publishes the card\n * box to CSS; clearing is all the painting left.\n */\nfunction paint({ context, width, height }: SceneDrawContext<Corner>) {\n  context.clearRect(0, 0, width, height);\n}\n\n/**\n * The tracked element holds nothing but the canvas; every readable thing is a later\n * sibling stacked over it. That layer is transparent to the pointer as a whole and the\n * cards take their clicks back, because the stage takes pointer capture as it tracks and\n * a dismiss button inside it would have its click swallowed by that capture.\n */\n/** `compact` is the 298x240 catalogue card: the same stack in the same corner, with the\n *  hint dropped and the control strip shrunk. The toast width is measured off the live\n *  layer, so the lubrication solve is the same one either way — see `toast-stack.css`. */\nexport type ToastStackProps = { compact?: boolean };\n\nexport function ToastStack({ compact = false }: ToastStackProps) {\n  const reduced = useReducedMotion();\n  const [toasts, setToasts] = useState<Toast[]>(() => [make(0), make(1)]);\n\n  const cards = useRef<Card[]>([]);\n  const nodes = useRef(new Map<number, HTMLElement>());\n  const layer = useRef<HTMLDivElement | null>(null);\n  const serial = useRef(2);\n  const seeded = useRef(false);\n\n  const add = useCallback(() => {\n    setToasts((list) => {\n      const grown = [...list, make(serial.current)];\n      serial.current += 1;\n      /*\n       * The cap dropping the oldest is the best thing in here. The new card is still on\n       * its way down onto the top of the stack while every card below it falls a whole\n       * pitch into the slot the dropped one left — two different fall heights inside one\n       * event, arriving at times a stagger would have had to be told in advance.\n       */\n      return grown.length > MAX_STACK ? grown.slice(grown.length - MAX_STACK) : grown;\n    });\n  }, []);\n\n  const dismiss = useCallback((id: number) => {\n    setToasts((list) => list.filter((toast) => toast.id !== id));\n  }, []);\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<Corner>({\n    setup: (scene) => build(scene, layer.current),\n    draw: (scene) => {\n      const held = cards.current;\n      const stale =\n        held.length !== toasts.length ||\n        toasts.some((toast, i) => held[i].id !== toast.id);\n      if (stale) cards.current = reconcile(held, toasts, !seeded.current);\n      seeded.current = true;\n\n      scene.state.snap = reduced;\n      run(scene.state, cards.current);\n      place(cards.current, nodes.current);\n      paint(scene);\n    },\n  });\n\n  // The list is a React value the scene reads. With the loop stopped under reduced motion\n  // nothing else would run a frame, and a card that had just been added would never be\n  // given a position at all.\n  useEffect(() => {\n    requestRender();\n  }, [toasts, reduced, requestRender]);\n\n  return (\n    <div className=\"toast-stack-stage\" data-compact={compact ? 'true' : undefined}>\n      <div ref={stageRef} className=\"toast-stack-surface\" aria-hidden=\"true\">\n        <canvas ref={canvasRef} />\n      </div>\n\n      <div ref={layer} className=\"toast-stack-layer\">\n        <div className=\"toast-stack-controls\">\n          {/* Still pressable in a card, but out of the tab order: the card frame is\n              aria-hidden, and a focusable node inside one is a trap with no label. */}\n          <button\n            type=\"button\"\n            className=\"toast-stack-add\"\n            tabIndex={compact ? -1 : undefined}\n            onClick={add}\n          >\n            Add toast\n          </button>\n          <p className=\"toast-stack-hint\">Add a toast, then dismiss one</p>\n        </div>\n\n        <div\n          className=\"toast-stack-corner\"\n          role=\"status\"\n          aria-live=\"polite\"\n          aria-label=\"Notifications\"\n        >\n          {toasts.map((toast) => (\n            <div\n              key={toast.id}\n              ref={(node) => {\n                if (node) nodes.current.set(toast.id, node);\n                else nodes.current.delete(toast.id);\n              }}\n              className=\"toast-stack-toast\"\n            >\n              <p className=\"toast-stack-title\">{toast.title}</p>\n              <p className=\"toast-stack-body\">{toast.body}</p>\n              <button\n                type=\"button\"\n                className=\"toast-stack-dismiss\"\n                tabIndex={compact ? -1 : undefined}\n                onClick={() => dismiss(toast.id)}\n                aria-label={`Dismiss ${toast.title}`}\n              >\n                <span aria-hidden=\"true\">×</span>\n              </button>\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport default ToastStack;\n","type":"registry:ui"},{"path":"components/ui/toast-stack.css","target":"components/ui/toast-stack.css","content":".toast-stack-stage {\n  position: relative;\n  width: 100%;\n  min-height: 25rem;\n  overflow: hidden;\n  border-radius: 0.75rem;\n  background: radial-gradient(120% 110% at 24% 0%, #101823 0%, #090d15 58%, #05070c 100%);\n  color: #e8f1fb;\n}\n\n/* The tracked element. Nothing but the canvas is in here, so pointer capture during a\n   drag cannot reach anything that owns a click. */\n.toast-stack-surface {\n  position: absolute;\n  inset: 0;\n  touch-action: none;\n}\n\n.toast-stack-surface canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/* The readable layer, over the canvas. Transparent to the pointer as a whole; the cards\n   and the Add button take their own events back. */\n.toast-stack-layer {\n  position: absolute;\n  inset: 0;\n  pointer-events: none;\n}\n\n.toast-stack-controls {\n  position: absolute;\n  left: 1.25rem;\n  bottom: 1.25rem;\n  display: flex;\n  align-items: center;\n  gap: 0.75rem;\n}\n\n.toast-stack-add {\n  appearance: none;\n  margin: 0;\n  padding: 0.4375rem 0.9375rem;\n  border: 1px solid rgba(255, 255, 255, 0.15);\n  border-radius: 999px;\n  background: rgba(10, 16, 26, 0.5);\n  font: inherit;\n  font-size: 0.8125rem;\n  font-weight: 500;\n  color: rgba(232, 241, 251, 0.78);\n  cursor: pointer;\n  pointer-events: auto;\n  backdrop-filter: blur(6px);\n  transition:\n    border-color 160ms ease,\n    background-color 160ms ease,\n    color 160ms ease;\n}\n\n.toast-stack-add:hover {\n  border-color: rgba(150, 214, 222, 0.45);\n  background: rgba(150, 214, 222, 0.12);\n  color: #f4fdff;\n}\n\n.toast-stack-add:focus-visible {\n  outline: 2px solid rgba(150, 214, 222, 0.8);\n  outline-offset: 2px;\n}\n\n.toast-stack-hint {\n  margin: 0;\n  font: 500 0.6875rem/1 ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n  color: rgba(232, 241, 251, 0.26);\n}\n\n/*\n * The corner is `inset: 0` on purpose. Every card is placed by a transform the solver\n * writes and that transform carries the corner inset with it, so there is no number in this\n * file that has to be kept in step with a number in the component.\n */\n.toast-stack-corner {\n  position: absolute;\n  inset: 0;\n}\n\n/* The width and the height come from the solver, which is the only thing that knows the\n   stack's pitch. The fallbacks are the sizes it settles on at a comfortable width. */\n.toast-stack-toast {\n  position: absolute;\n  right: 0;\n  bottom: 0;\n  box-sizing: border-box;\n  display: grid;\n  grid-template-columns: minmax(0, 1fr) auto;\n  align-content: center;\n  gap: 0.1875rem 0.5rem;\n  width: var(--toast-stack-w, 300px);\n  height: var(--toast-stack-h, 58px);\n  padding: 0 0.5rem 0 0.75rem;\n  border: 1px solid rgba(232, 241, 251, 0.12);\n  border-radius: 0.5rem;\n  background: linear-gradient(180deg, #171d28 0%, #10151e 100%);\n  box-shadow:\n    inset 0 1px 0 rgba(232, 241, 251, 0.05),\n    0 6px 18px rgba(3, 6, 11, 0.5);\n  pointer-events: auto;\n  will-change: transform;\n}\n\n.toast-stack-title {\n  grid-column: 1;\n  margin: 0;\n  font-size: 0.8125rem;\n  font-weight: 500;\n  line-height: 1.2;\n  letter-spacing: -0.005em;\n  color: rgba(232, 241, 251, 0.94);\n}\n\n/* One line, clipped rather than wrapped: the card's height is the solver's pitch, and a\n   second line of body would push content out of a box the physics has already sized. */\n.toast-stack-body {\n  grid-column: 1;\n  margin: 0;\n  overflow: hidden;\n  font-size: 0.75rem;\n  line-height: 1.3;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  color: rgba(232, 241, 251, 0.48);\n}\n\n.toast-stack-dismiss {\n  appearance: none;\n  grid-row: 1 / 3;\n  grid-column: 2;\n  align-self: center;\n  display: grid;\n  place-items: center;\n  width: 1.875rem;\n  height: 1.875rem;\n  margin: 0;\n  padding: 0;\n  border: 1px solid transparent;\n  border-radius: 999px;\n  background: none;\n  font: inherit;\n  font-size: 1.0625rem;\n  line-height: 1;\n  color: rgba(232, 241, 251, 0.42);\n  cursor: pointer;\n  transition:\n    border-color 160ms ease,\n    background-color 160ms ease,\n    color 160ms ease;\n}\n\n.toast-stack-dismiss:hover {\n  border-color: rgba(150, 214, 222, 0.4);\n  background: rgba(150, 214, 222, 0.12);\n  color: #f4fdff;\n}\n\n.toast-stack-dismiss:focus-visible {\n  outline: 2px solid rgba(150, 214, 222, 0.8);\n  outline-offset: 1px;\n}\n\n/*\n * Opacity, and only opacity. The card becoming visible is not the same event as the card\n * arriving, and this one cannot fight the other because it never touches the transform —\n * which is the property the solver owns.\n */\n@media (prefers-reduced-motion: no-preference) {\n  .toast-stack-toast {\n    animation: toast-stack-appear 140ms ease-out both;\n  }\n}\n\n@keyframes toast-stack-appear {\n  from {\n    opacity: 0;\n  }\n\n  to {\n    opacity: 1;\n  }\n}\n\n/*\n * With the loop stopped the cards are seated on the stack's own geometry and stay there.\n * Adding and dismissing still work, because each one asks for a single repaint and the\n * solver is skipped in favour of the answer. What is gone is the fall in between, which is\n * the part that was asked to go.\n */\n@media (prefers-reduced-motion: reduce) {\n  .toast-stack-add,\n  .toast-stack-dismiss {\n    transition: none;\n  }\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled.\n * The stack needs no help — `--toast-stack-w` is measured off the live layer every\n * resize, so the cards are as wide as the card lets them be and the gap solve runs on\n * the real geometry. All this does is tighten the strip the button sits in.\n */\n.toast-stack-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  /* The card frame rounds and clips already. */\n  border-radius: 0;\n}\n\n/* A full-bleed surface that claims every touch traps the page inside a scrolling grid.\n   `pan-y` hands the vertical gesture back to the document. */\n.toast-stack-stage[data-compact='true'] .toast-stack-surface {\n  touch-action: pan-y;\n}\n\n.toast-stack-stage[data-compact='true'] .toast-stack-controls {\n  left: 0.6875rem;\n  bottom: 0.6875rem;\n}\n\n/* One line of copy, and it is the card's own title's job. */\n.toast-stack-stage[data-compact='true'] .toast-stack-hint {\n  display: none;\n}\n\n/* 22px tall, so the button clears the bottom card in the stack. */\n.toast-stack-stage[data-compact='true'] .toast-stack-add {\n  padding: 0.25rem 0.5625rem;\n  font-size: 0.6875rem;\n  line-height: 1;\n}\n","type":"registry:file"},{"path":"hooks/use-canvas-scene.ts","target":"hooks/use-canvas-scene.ts","content":"\"use client\"\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\n\n/**\n * The canvas preamble every 2D scene needs, in one place: a DPR-scaled backing\n * store, a rebuild on resize, a loop that stops when the stage scrolls out of\n * view, pointer tracking with per-frame deltas, and teardown.\n *\n * A scene supplies two functions. `setup` builds whatever mutable state the\n * animation owns and is re-run whenever the stage changes size, so the state can\n * be sized to the stage without ever being resized in place. `draw` paints one\n * frame from that state — it is called with the transform already scaled to\n * device pixels, so every coordinate in it is a CSS pixel.\n */\n\nexport type ScenePointer = {\n  x: number\n  y: number\n  /** Position at the previous painted frame, so `x - lastX` is a frame delta. */\n  lastX: number\n  lastY: number\n  down: boolean\n  inside: boolean\n}\n\nexport type SceneSetupContext = {\n  context: CanvasRenderingContext2D\n  width: number\n  height: number\n  dpr: number\n}\n\nexport type SceneDrawContext<State> = SceneSetupContext & {\n  state: State\n  pointer: ScenePointer\n  /** Painted frames since the last rebuild. Useful for every-Nth-frame work. */\n  frame: number\n}\n\nexport type CanvasSceneOptions<State> = {\n  setup: (context: SceneSetupContext) => State\n  draw: (context: SceneDrawContext<State>) => void\n}\n\nexport type CanvasScene = {\n  /** The sizing element. Owns the pointer listeners and is what is observed. */\n  stageRef: (node: HTMLDivElement | null) => void\n  canvasRef: (node: HTMLCanvasElement | null) => void\n  /** Paint one frame now. The escape hatch for a paused or reduced-motion loop. */\n  requestRender: () => void\n}\n\n/** Live `prefers-reduced-motion`. False during SSR and the first paint. */\nexport function useReducedMotion() {\n  const [reduced, setReduced] = useState(false)\n\n  useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    setReduced(query.matches)\n    const onChange = () => setReduced(query.matches)\n    query.addEventListener(\"change\", onChange)\n    return () => query.removeEventListener(\"change\", onChange)\n  }, [])\n\n  return reduced\n}\n\nexport function useCanvasScene<State>(options: CanvasSceneOptions<State>): CanvasScene {\n  const reduced = useReducedMotion()\n\n  /*\n   * `draw` is usually an inline closure, so it is a new function on every\n   * render. Reading it through a ref keeps the loop from being torn down and\n   * the scene from being rebuilt each time the component re-renders.\n   */\n  const optionsRef = useRef(options)\n  optionsRef.current = options\n\n  const stage = useRef<HTMLDivElement | null>(null)\n  const canvas = useRef<HTMLCanvasElement | null>(null)\n\n  /*\n   * Plain ref assignment, with no state behind it. React attaches refs during\n   * the commit phase, before passive effects run, so the effect below already\n   * sees both nodes on the first mount — which is why these used to bump a\n   * `mounted` counter for nothing: the two `setMounted` calls batched into one\n   * re-render, the counter went 0 → 2, and the effect's dependency on it tore\n   * the live scene down and rebuilt it. Every scene was constructed, measured\n   * and warmed twice on every mount, four times under StrictMode in dev.\n   *\n   * The requirement this trades for that: a consumer must render the stage and\n   * the canvas unconditionally, in the same commit as the component itself. All\n   * thirteen do. Gating the canvas behind a flag would leave the effect bailing\n   * on the null guard with nothing to re-run it.\n   */\n  const stageRef = useCallback((node: HTMLDivElement | null) => {\n    stage.current = node\n  }, [])\n  const canvasRef = useCallback((node: HTMLCanvasElement | null) => {\n    canvas.current = node\n  }, [])\n\n  /** Set once the scene is live, so `requestRender` before that is a no-op. */\n  const render = useRef<(() => void) | null>(null)\n  const requestRender = useCallback(() => render.current?.(), [])\n\n  useEffect(() => {\n    const stageNode = stage.current\n    const canvasNode = canvas.current\n    if (!stageNode || !canvasNode) return\n\n    const context = canvasNode.getContext(\"2d\")\n    if (!context) return\n\n    const pointer: ScenePointer = {\n      x: 0,\n      y: 0,\n      lastX: 0,\n      lastY: 0,\n      down: false,\n      inside: false,\n    }\n\n    let state: State | null = null\n    let width = 0\n    let height = 0\n    let dpr = 1\n    let frame = 0\n    let loop = 0\n    let pending = 0\n    let visible = true\n\n    /** Rebuild the backing store and the scene state for the current size. */\n    const measure = () => {\n      // `offsetWidth`/`offsetHeight`, not `getBoundingClientRect()`: the rect is\n      // post-transform, so a scene sitting inside a scaled ancestor measured its\n      // own frame at the scaled size, sized the backing store to that, and then\n      // had CSS scale the result a second time — the scene ran at a fraction of\n      // the box it was drawn into. The catalogue's scaled-poster branch is the\n      // one place that happens, and it is reachable again the moment an\n      // animation is registered without a card composition. These two properties\n      // are the untransformed layout box; both are integers, which is what the\n      // rounding below already reduced the rect to.\n      const nextWidth = Math.max(1, stageNode.offsetWidth)\n      const nextHeight = Math.max(1, stageNode.offsetHeight)\n      const nextDpr = Math.min(2, window.devicePixelRatio || 1)\n      if (nextWidth === width && nextHeight === height && nextDpr === dpr && state) return\n\n      width = nextWidth\n      height = nextHeight\n      dpr = nextDpr\n      canvasNode.width = Math.round(width * dpr)\n      canvasNode.height = Math.round(height * dpr)\n      canvasNode.style.width = `${width}px`\n      canvasNode.style.height = `${height}px`\n      frame = 0\n      state = optionsRef.current.setup({ context, width, height, dpr })\n    }\n\n    const paint = () => {\n      if (!state) return\n      // Re-applied every frame: a scene is free to install its own transform\n      // for a cell or a sprite, and most do.\n      context.setTransform(dpr, 0, 0, dpr, 0, 0)\n      optionsRef.current.draw({ context, width, height, dpr, state, pointer, frame })\n      pointer.lastX = pointer.x\n      pointer.lastY = pointer.y\n      frame += 1\n    }\n\n    /** One frame on the next tick, coalescing however many were asked for. */\n    const paintOnce = () => {\n      if (pending) return\n      pending = requestAnimationFrame(() => {\n        pending = 0\n        measure()\n        paint()\n      })\n    }\n    render.current = paintOnce\n\n    const tick = () => {\n      loop = requestAnimationFrame(tick)\n      if (visible) paint()\n    }\n\n    const start = () => {\n      if (loop || reduced) return\n      loop = requestAnimationFrame(tick)\n    }\n    const stop = () => {\n      if (!loop) return\n      cancelAnimationFrame(loop)\n      loop = 0\n    }\n\n    const at = (event: PointerEvent) => {\n      const rect = stageNode.getBoundingClientRect()\n      // The rect is the right thing to subtract here — `clientX` is viewport\n      // space and so is the rect — but the difference comes back in *rendered*\n      // pixels, and a scene reads `pointer` in the scene pixels `measure()` set\n      // up from the untransformed box. Under a CSS scale those two disagree, so\n      // divide the transform back out. `rect.width / offsetWidth` is the scale\n      // actually in force, whatever produced it, and it is exactly 1 when there\n      // is none.\n      const scale = stageNode.offsetWidth > 0 ? rect.width / stageNode.offsetWidth : 1\n      pointer.x = (event.clientX - rect.left) / (scale || 1)\n      pointer.y = (event.clientY - rect.top) / (scale || 1)\n      // A frozen loop still owes the user feedback for a drag.\n      if (reduced) paintOnce()\n    }\n\n    const onEnter = (event: PointerEvent) => {\n      pointer.inside = true\n      at(event)\n      pointer.lastX = pointer.x\n      pointer.lastY = pointer.y\n    }\n    const onMove = (event: PointerEvent) => {\n      pointer.inside = true\n      at(event)\n    }\n    const onDown = (event: PointerEvent) => {\n      pointer.down = true\n      at(event)\n      // Capture keeps a drag alive past the edge of the stage, which is where\n      // a hard throw naturally ends up.\n      stageNode.setPointerCapture(event.pointerId)\n    }\n    const onUp = (event: PointerEvent) => {\n      pointer.down = false\n      at(event)\n      if (stageNode.hasPointerCapture(event.pointerId)) {\n        stageNode.releasePointerCapture(event.pointerId)\n      }\n    }\n    const onLeave = () => {\n      pointer.inside = false\n      pointer.down = false\n      if (reduced) paintOnce()\n    }\n\n    stageNode.addEventListener(\"pointerenter\", onEnter)\n    stageNode.addEventListener(\"pointermove\", onMove)\n    stageNode.addEventListener(\"pointerdown\", onDown)\n    stageNode.addEventListener(\"pointerup\", onUp)\n    stageNode.addEventListener(\"pointercancel\", onUp)\n    stageNode.addEventListener(\"pointerleave\", onLeave)\n\n    const resizes = new ResizeObserver(() => paintOnce())\n    resizes.observe(stageNode)\n\n    /*\n     * An animation nobody can see is heat. The observer both pauses the loop\n     * and, on the way back in, repaints immediately rather than waiting a frame.\n     */\n    const views = new IntersectionObserver(\n      (entries) => {\n        visible = entries.some((entry) => entry.isIntersecting)\n        if (visible) {\n          start()\n          paintOnce()\n        } else {\n          stop()\n        }\n      },\n      { rootMargin: \"120px\" },\n    )\n    views.observe(stageNode)\n\n    measure()\n    paint()\n    start()\n\n    return () => {\n      render.current = null\n      stop()\n      if (pending) cancelAnimationFrame(pending)\n      resizes.disconnect()\n      views.disconnect()\n      stageNode.removeEventListener(\"pointerenter\", onEnter)\n      stageNode.removeEventListener(\"pointermove\", onMove)\n      stageNode.removeEventListener(\"pointerdown\", onDown)\n      stageNode.removeEventListener(\"pointerup\", onUp)\n      stageNode.removeEventListener(\"pointercancel\", onUp)\n      stageNode.removeEventListener(\"pointerleave\", onLeave)\n    }\n  }, [reduced])\n\n  return { stageRef, canvasRef, requestRender }\n}\n","type":"registry:hook"}],"meta":{"kind":"animations","categories":["stagger","transitions","micro"],"docs":"https://ui.artbloom.tech/artbloom/animations/toast-stack"}}