{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"pull-cord","type":"registry:ui","title":"Pull Cord","description":"A switch you pull. Eighteen masses on inextensible links, and the click fires when the plunger runs out of travel — not when the angle looks right.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/pull-cord.tsx","target":"components/ui/pull-cord.tsx","content":"'use client';\n\nimport './pull-cord.css';\n\nimport { useEffect, 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 switch you pull, with a cord that is a real rope.\n *\n * The cord is eighteen point masses integrated by Verlet and relaxed against\n * inextensible distance constraints — position-based dynamics, the same method a\n * cloth solver uses. Nothing about the swing is authored: the arc when you drag it\n * sideways is the pendulum the rope is, and the wave that runs down it after the\n * switch trips is the top of the rope having moved while the bottom had not heard\n * yet.\n *\n * The click is a detent, not a timer. A real pull-chain switch does not fire when\n * the chain reaches an angle; it fires when the chain has drawn a plunger a fixed\n * distance out of the mechanism against a spring, and then the plunger snaps home.\n * So the rope here is genuinely inextensible and the travel is paid out by the\n * mount: pull past the point where the rope is taut and the plunger follows your\n * hand, until it reaches the end of its travel and trips. It cannot fire twice\n * without being let up first, because the detent has to re-seat.\n *\n * The rope is the pointer affordance; the switch is a real `role=\"switch\"` button\n * that rides on the knob, so a keyboard reaches it and a screen reader is told\n * what it is and whether it is on.\n */\n\n/** Seconds per step. */\nconst STEP = 1 / 120;\n/** Masses in the cord. */\nconst NODES = 18;\n/** Rest length of one link, in pixels. Eighteen of these is the cord. */\nconst SEGMENT = 9;\n/** Gravity, px/s². */\nconst G = 2200;\n/** Velocity lost per step. Air, and the fibre's own hysteresis. */\nconst DRAG = 0.008;\n/**\n * Relaxation passes over the links per step. Position-based dynamics converges on\n * an inextensible rope in single figures; eight leaves under a pixel of stretch\n * across the whole cord even while the knob is being hauled on.\n */\nconst RELAX = 8;\n/** The knob's inverse mass. Heavier than a link, so the cord whips and it does not. */\nconst KNOB_INV = 0.25;\n/** Stiffness of the hand's hold on the knob, per relaxation pass. */\nconst GRAB = 0.55;\n/** How near the knob a press has to land to take hold of it. */\nconst REACH = 30;\n/** The plunger's travel before the switch trips, in pixels. */\nconst DETENT = 22;\n/** How fast the plunger follows the pull. Stiff: this is steel, not elastic. */\nconst PLUNGER = 40;\n/** The plunger has to come back inside this fraction of its travel to re-arm. */\nconst RESEAT = 0.4;\n/** Where the mount hangs, below the top of the stage. */\nconst MOUNT_Y = 6;\n\ninterface CordState {\n  readonly count: number;\n  /** Positions, and the positions one step ago. Verlet keeps velocity in the gap. */\n  readonly x: Float64Array;\n  readonly y: Float64Array;\n  readonly px: Float64Array;\n  readonly py: Float64Array;\n  readonly inv: Float64Array;\n  readonly mountX: number;\n  /** How far the plunger has been drawn out of the mechanism, 0…DETENT. */\n  sag: number;\n  /** True while the detent is seated and able to trip. */\n  armed: boolean;\n  /** True while the hand has hold of the knob. */\n  held: boolean;\n  /** Set by `advance` when the detent trips; the component reads and clears it. */\n  fired: boolean;\n  /** Whether the lamp is on, for the knob's own bloom. Written by the component. */\n  lit: boolean;\n  /** Last cord position published to CSS, so the write is skipped when it has not moved. */\n  postedX: number;\n  postedY: number;\n  carry: number;\n  clock: number;\n  /** Hang the cord straight and take no drags. Set under `prefers-reduced-motion`. */\n  snap: boolean;\n}\n\n/** The pose the cord holds with nothing acting on it but gravity: a straight line. */\nfunction hang(state: CordState) {\n  for (let i = 0; i < state.count; i++) {\n    state.x[i] = state.mountX;\n    state.y[i] = MOUNT_Y + i * SEGMENT;\n    state.px[i] = state.x[i];\n    state.py[i] = state.y[i];\n  }\n  state.sag = 0;\n  state.armed = true;\n}\nfunction advance(state: CordState, grabX: number, grabY: number) {\n  const { count, x, y, px, py, inv } = state;\n\n  /*\n   * The plunger. `sag` is not integrated from a tension estimate — it is read off\n   * the geometry, which is exact: the rope cannot stretch, so if the hand is D from\n   * the mount and the rope is L long, the mechanism has had to pay out D − L, and\n   * never more than its own travel.\n   */\n  const demand = state.held\n    ? Math.hypot(grabX - state.mountX, grabY - MOUNT_Y) - SEGMENT * (count - 1)\n    : 0;\n  const wanted = Math.max(0, Math.min(DETENT, demand));\n  state.sag += (wanted - state.sag) * Math.min(1, STEP * PLUNGER);\n\n  if (state.armed && state.sag >= DETENT - 0.5) {\n    // Trip. The plunger snaps home inside one step, which is what puts the wave in\n    // the cord: the top has moved twenty-two pixels and the bottom has not heard.\n    state.armed = false;\n    state.fired = true;\n    state.sag = 0;\n  } else if (!state.armed && wanted < DETENT * RESEAT) {\n    state.armed = true;\n  }\n\n  // Verlet, for every node but the first. Node 0 is the plunger's eye: it is\n  // placed, and its zero inverse mass keeps the relaxation from moving it.\n  for (let i = 1; i < count; i++) {\n    const vx = (x[i] - px[i]) * (1 - DRAG);\n    const vy = (y[i] - py[i]) * (1 - DRAG);\n    px[i] = x[i];\n    py[i] = y[i];\n    x[i] += vx;\n    y[i] += vy + G * STEP * STEP;\n  }\n  x[0] = state.mountX;\n  y[0] = MOUNT_Y + state.sag;\n\n  for (let pass = 0; pass < RELAX; pass++) {\n    for (let i = 1; i < count; i++) {\n      const dx = x[i] - x[i - 1];\n      const dy = y[i] - y[i - 1];\n      const distance = Math.hypot(dx, dy) || 1e-6;\n      const share = (distance - SEGMENT) / distance / (inv[i - 1] + inv[i]);\n      x[i - 1] += dx * share * inv[i - 1];\n      y[i - 1] += dy * share * inv[i - 1];\n      x[i] -= dx * share * inv[i];\n      y[i] -= dy * share * inv[i];\n    }\n\n    /*\n     * The hand, as one more constraint rather than as an assignment. Setting the\n     * knob's position outright would overwrite the Verlet history that *is* its\n     * velocity, and letting go mid-swing would drop it dead. As a constraint the\n     * motion stays in the positions, so a throw carries.\n     */\n    if (state.held) {\n      x[count - 1] += (grabX - x[count - 1]) * GRAB;\n      y[count - 1] += (grabY - y[count - 1]) * GRAB;\n    }\n  }\n}\nfunction build({ width }: SceneSetupContext, lit: boolean): CordState {\n  const count = NODES;\n  const inv = new Float64Array(count);\n  for (let i = 1; i < count; i++) inv[i] = 1;\n  // The knob is the heavy end. Weighting the corrections by inverse mass is what\n  // makes the cord whip around it instead of the two trading places.\n  inv[count - 1] = KNOB_INV;\n\n  const state: CordState = {\n    count,\n    x: new Float64Array(count),\n    y: new Float64Array(count),\n    px: new Float64Array(count),\n    py: new Float64Array(count),\n    inv,\n    mountX: width * 0.78,\n    sag: 0,\n    armed: true,\n    held: false,\n    fired: false,\n    lit,\n    postedX: Number.NaN,\n    postedY: Number.NaN,\n    carry: 0,\n    clock: 0,\n    snap: false,\n  };\n\n  // Hanging straight is the exact rest pose, not an approximation of one: with only\n  // gravity acting and every link inextensible, the solution is a vertical line.\n  hang(state);\n\n  return state;\n}\n\nfunction paint({ context, width, height, state, pointer }: SceneDrawContext<CordState>) {\n  const now = performance.now();\n  const elapsed = state.clock ? Math.min(0.05, (now - state.clock) / 1000) : STEP;\n  state.clock = now;\n\n  const knob = state.count - 1;\n\n  if (state.snap) {\n    state.held = false;\n    hang(state);\n  } else {\n    // Take hold on a press that lands near the knob, and let go when it ends. The\n    // grab is not re-tested while held, so dragging outside the stage keeps it.\n    if (!pointer.down) state.held = false;\n    else if (\n      !state.held &&\n      pointer.inside &&\n      Math.hypot(pointer.x - state.x[knob], pointer.y - state.y[knob]) < REACH\n    ) {\n      state.held = true;\n    }\n\n    state.carry += elapsed;\n    let steps = 0;\n    while (state.carry >= STEP && steps < 6) {\n      advance(state, pointer.x, pointer.y);\n      state.carry -= STEP;\n      steps += 1;\n    }\n    if (state.carry > STEP * 6) state.carry = 0;\n  }\n\n  context.clearRect(0, 0, width, height);\n  // The mount, and the plunger at its real extension. The travel is drawn because\n  // the travel is the mechanism: what you see move is what decides when it clicks.\n  context.fillStyle = 'rgba(228,214,190,0.14)';\n  context.fillRect(state.mountX - 17, 0, 34, MOUNT_Y);\n  context.fillStyle = 'rgba(238,222,196,0.46)';\n  context.fillRect(state.mountX - 1.5, MOUNT_Y, 3, Math.max(0, state.sag));\n\n  /*\n   * The cord through the midpoints of its links rather than through the nodes: a\n   * quadratic to each midpoint with the node as the control point is C¹ across the\n   * whole rope, so at nine pixels a link there is no facet to see. Drawing node to\n   * node is a polygon, and it reads as one the moment the cord swings.\n   */\n  const { count, x, y } = state;\n  context.strokeStyle = 'rgba(230,209,173,0.7)';\n  context.lineWidth = 2.2;\n  context.lineJoin = 'round';\n  context.lineCap = 'round';\n  context.beginPath();\n  context.moveTo(x[0], y[0]);\n  for (let i = 1; i < count - 1; i++) {\n    context.quadraticCurveTo(x[i], y[i], (x[i] + x[i + 1]) * 0.5, (y[i] + y[i + 1]) * 0.5);\n  }\n  context.lineTo(x[knob], y[knob]);\n  context.stroke();\n\n  const kx = x[knob];\n  const ky = y[knob];\n  if (state.lit) {\n    context.shadowColor = 'rgba(255,203,132,0.6)';\n    context.shadowBlur = 26;\n  }\n  const bead = context.createRadialGradient(kx - 3, ky - 4, 1, kx, ky, 12);\n  bead.addColorStop(0, state.lit ? '#fff4d8' : '#e6dac2');\n  bead.addColorStop(1, state.lit ? '#b8863f' : '#877553');\n  context.fillStyle = bead;\n  context.beginPath();\n  context.arc(kx, ky, 10, 0, Math.PI * 2);\n  context.fill();\n  context.shadowBlur = 0;\n\n  /*\n   * The knob's position, published to CSS so the switch button rides on it and the\n   * focus ring lands where the control actually is. Written only when it has moved\n   * a visible amount: a custom property on the stage invalidates style for its\n   * subtree, and there is no reason to pay that on a frame where nothing moved.\n   */\n  if (Math.abs(kx - state.postedX) > 0.5 || Math.abs(ky - state.postedY) > 0.5) {\n    state.postedX = kx;\n    state.postedY = ky;\n    const host = context.canvas.parentElement;\n    if (host) {\n      host.style.setProperty('--cord-x', `${kx.toFixed(1)}px`);\n      host.style.setProperty('--cord-y', `${ky.toFixed(1)}px`);\n    }\n  }\n}\n\nexport type PullCordProps = {\n  /** Card variant: one line of type along the bottom, the cord given the whole box. */\n  compact?: boolean;\n};\n\n/**\n * The cord layer is the stage and takes every pointer event; the copy sits on it\n * with `pointer-events: none`. The switch is a real button, focusable and labelled,\n * but `pointer-events: none` as well — so a mouse press on the knob is the drag the\n * cord expects, while Tab and Space reach the same control and toggle it outright.\n */\nexport function PullCord({ compact = false }: PullCordProps) {\n  const [on, setOn] = useState(false);\n  const reduced = useReducedMotion();\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<CordState>({\n    setup: (scene) => build(scene, on),\n    draw: (scene) => {\n      scene.state.lit = on;\n      scene.state.snap = reduced;\n      paint(scene);\n      // The detent trips inside the solver, so the React state follows the physics\n      // rather than the other way round.\n      if (scene.state.fired) {\n        scene.state.fired = false;\n        setOn((was) => !was);\n      }\n    },\n  });\n\n  // With the loop stopped under reduced motion, the toggle still has to repaint or\n  // the knob would keep the colour of the state it just left.\n  useEffect(() => {\n    requestRender();\n  }, [on, requestRender]);\n\n  return (\n    <div\n      className=\"pull-cord-stage\"\n      data-on={on ? 'true' : 'false'}\n      data-compact={compact ? 'true' : undefined}\n    >\n      <div ref={stageRef} className=\"pull-cord-line\">\n        <canvas ref={canvasRef} aria-hidden=\"true\" />\n        <button\n          type=\"button\"\n          role=\"switch\"\n          className=\"pull-cord-switch\"\n          aria-checked={on}\n          aria-label=\"Reading light\"\n          // The card frame is aria-hidden, so inside it the switch leaves the tab\n          // order. It stays clickable — only the keyboard path is withdrawn.\n          tabIndex={compact ? -1 : undefined}\n          onClick={() => setOn((was) => !was)}\n        />\n      </div>\n\n      <div className=\"pull-cord-face\">\n        <p className=\"pull-cord-eyebrow\">Detent</p>\n        <h2>{on ? 'The light is on.' : 'The light is off.'}</h2>\n        <p className=\"pull-cord-copy\">\n          Eighteen masses, inextensible links, and a plunger with twenty-two pixels of\n          travel. It clicks when the mechanism reaches the end of that travel, and it\n          will not click again until the cord has been let up.\n        </p>\n      </div>\n\n      <p className=\"pull-cord-hint\">Pull the cord</p>\n    </div>\n  );\n}\n\nexport default PullCord;\n","type":"registry:ui"},{"path":"components/ui/pull-cord.css","target":"components/ui/pull-cord.css","content":".pull-cord-stage {\n  position: relative;\n  width: 100%;\n  min-height: 340px;\n  overflow: hidden;\n  border-radius: 0.75rem;\n  background: radial-gradient(120% 100% at 78% 4%, #14171d 0%, #0a0c11 58%, #06070a 100%);\n  color: #f2ece0;\n  isolation: isolate;\n}\n\n/* The lamp, as light rather than as a lamp. Behind the cord because a pseudo\n   element paints before its element's children. */\n.pull-cord-stage::before {\n  content: '';\n  position: absolute;\n  inset: 0;\n  background: radial-gradient(58% 62% at 78% 2%, rgba(255, 194, 118, 0.32), rgba(255, 194, 118, 0) 72%);\n  opacity: 0;\n  pointer-events: none;\n  transition: opacity 280ms ease;\n}\n\n.pull-cord-stage[data-on='true']::before {\n  opacity: 1;\n}\n\n.pull-cord-line {\n  position: absolute;\n  inset: 0;\n  cursor: grab;\n  touch-action: none;\n}\n\n.pull-cord-line:active {\n  cursor: grabbing;\n}\n\n.pull-cord-line canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/*\n * Rides on the knob: the draw loop publishes the knob's position as `--cord-x` and\n * `--cord-y`, so the focus ring lands on the control instead of on a guess about\n * where the control usually hangs. Deaf to the pointer, because a press on the knob\n * belongs to the cord — but still in the tab order, and still activated by Space.\n */\n.pull-cord-switch {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 2.5rem;\n  height: 2.5rem;\n  appearance: none;\n  margin: 0;\n  padding: 0;\n  border: 0;\n  border-radius: 999px;\n  background: none;\n  transform: translate(calc(var(--cord-x, 0px) - 1.25rem), calc(var(--cord-y, 0px) - 1.25rem));\n  pointer-events: none;\n}\n\n.pull-cord-switch:focus-visible {\n  outline: 2px solid rgba(255, 214, 150, 0.85);\n  outline-offset: 3px;\n}\n\n/* Transparent to the pointer, so the cord can be grabbed through the copy. */\n.pull-cord-face {\n  position: relative;\n  max-width: 27rem;\n  padding: 4.5rem 3rem 5.25rem;\n  pointer-events: none;\n}\n\n.pull-cord-eyebrow {\n  margin: 0 0 1rem;\n  font: 500 0.6875rem/1 ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n  letter-spacing: 0.16em;\n  text-transform: uppercase;\n  color: rgba(255, 196, 122, 0.78);\n}\n\n.pull-cord-face h2 {\n  margin: 0 0 1rem;\n  font-size: clamp(1.625rem, 3.2vw, 2.5rem);\n  font-weight: 500;\n  line-height: 1.1;\n  letter-spacing: -0.02em;\n  text-wrap: balance;\n  color: rgba(242, 236, 224, 0.72);\n  transition: color 280ms ease;\n}\n\n.pull-cord-stage[data-on='true'] .pull-cord-face h2 {\n  color: #fff6e6;\n}\n\n.pull-cord-copy {\n  margin: 0;\n  max-width: 24rem;\n  font-size: 0.9375rem;\n  line-height: 1.65;\n  color: rgba(242, 236, 224, 0.5);\n  transition: color 280ms ease;\n}\n\n.pull-cord-stage[data-on='true'] .pull-cord-copy {\n  color: rgba(242, 236, 224, 0.66);\n}\n\n.pull-cord-hint {\n  position: absolute;\n  right: 0.875rem;\n  bottom: 0.75rem;\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(242, 236, 224, 0.28);\n  pointer-events: none;\n}\n\n/*\n * With the loop stopped the cord hangs straight and refuses to be dragged, because\n * dragging it would be motion smuggled in through the pointer handler. The switch\n * button is the whole control here, and it is a real one — Tab to it, Space toggles\n * it, and the lamp changes state with no travel in between.\n */\n@media (prefers-reduced-motion: reduce) {\n  .pull-cord-line {\n    cursor: default;\n  }\n\n  .pull-cord-stage::before,\n  .pull-cord-face h2,\n  .pull-cord-copy {\n    transition: none;\n  }\n}\n\n/*\n * The card variant: the same cord authored for the 298x240 catalogue frame rather\n * than scaled into it. The section copy goes, one line of state stays pinned along\n * the bottom edge, and the mechanism — mount, plunger, rope, knob — is given the\n * whole box. No `vw` anywhere below: the frame is 298px wide and the viewport is not.\n */\n.pull-cord-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/*\n * `pan-y`, not `none`, even though pulling a cord is a vertical gesture: a full-bleed\n * drag surface that swallows vertical touches traps the page in a scrolling grid of\n * cards, which is the worse failure. Horizontal drags still reach the cord, and about\n * 85px of sideways swing draws the plunger its full 22px and trips the detent — so\n * the real behaviour, click included, survives the trade on a phone.\n */\n.pull-cord-stage[data-compact='true'] .pull-cord-line {\n  touch-action: pan-y;\n}\n\n/*\n * The text layer off the mechanism's back and onto the bottom edge. Still deaf to the\n * pointer, so a drag anywhere over it takes hold of the cord.\n */\n.pull-cord-stage[data-compact='true'] .pull-cord-face {\n  position: absolute;\n  inset: auto 0 0 0;\n  max-width: none;\n  padding: 0.75rem;\n  pointer-events: none;\n}\n\n/* Everything but the state line: a paragraph of prose, a label the card already\n   carries in its own title, and a hint the grab cursor gives for free. */\n.pull-cord-stage[data-compact='true'] .pull-cord-copy,\n.pull-cord-stage[data-compact='true'] .pull-cord-eyebrow,\n.pull-cord-stage[data-compact='true'] .pull-cord-hint {\n  display: none;\n}\n\n/*\n * The one line that stays, at a fixed size. It is the heading because the heading is\n * the state readout — it is what turns over when the detent trips, so the card says\n * whether the pull worked. The rope is 153px of the 240 and the knob cannot swing\n * past about 191, so this strip is clear of it.\n */\n.pull-cord-stage[data-compact='true'] .pull-cord-face h2 {\n  margin: 0;\n  font-size: 0.8125rem;\n  line-height: 1.2;\n  white-space: nowrap;\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":["micro","draggable","springs"],"docs":"https://ui.artbloom.tech/artbloom/animations/pull-cord"}}