{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"balance-progress","type":"registry:ui","title":"Cart-Pole Progress","description":"A progress bar with an inverted pendulum riding it. The controller drives away from the fall before it drives toward the target, so the bar leans into its own travel and settles with no easing curve.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/balance-progress.tsx","target":"components/ui/balance-progress.tsx","content":"'use client';\n\nimport './balance-progress.css';\n\nimport { useEffect, useState } from 'react';\n\nimport { useCanvasScene, useReducedMotion, type SceneDrawContext, type SceneSetupContext } from '@/hooks/use-canvas-scene';\n\n/**\n * A five-stage release bar whose fill is carried by a cart balancing an inverted pole.\n *\n * The pair integrated here is the exact nonlinear cart-pole, not the small-angle version: the\n * shared 1 / (m_cart + m_pole) term couples the bodies both ways, the pole's angular acceleration\n * is subtracted back out of the cart, and the m_pole cos^2 term sits inside the effective inertia.\n * Linearising is easier and is also false at the angles this reaches — fourteen degrees on a stage\n * change, far more under a drag — where the reaction on the cart is most of what sells the mass.\n *\n * Steering is a cascade: a fast PD loop on pole angle (about 12 rad/s) inside a slow proportional\n * loop on cart position (about 4 rad/s). The outer loop cannot command travel, only lean, so\n * pressing a stage sends the cart the WRONG WAY first to topple the pole toward the target, then\n * chases it. That backwards step is the plant's right-half-plane zero, not a flourish. The clamp is\n * on force, never on angle: clamping the angle deletes the pole's mass and leaves a tween.\n */\n\nconst STEP = 1 / 150;\nconst STAGES = ['Draft', 'Review', 'Build', 'Canary', 'Live'];\nconst MASS_CART = 1;\nconst MASS_POLE = 0.14;\nconst POLE_HALF = 0.3; // half-length of a uniform rod, which is what the 4/3 inertia term assumes\nconst GRAVITY = 9.81;\nconst CART_DRAG = 0.6; // viscous, not Coulomb: sign(v) chatters at a fixed step and jams the cart\nconst POLE_DAMP = 0.006;\nconst GAIN_ANGLE = 70; // inner PD -> omega_n 11.9 rad/s, zeta 0.8, DC gain 1.19 on the lean ask\nconst GAIN_RATE = 7.9;\nconst GAIN_POS = 1.4; // outer P -> omega_n 4 rad/s, three times slower so the cascade holds\nconst GAIN_VEL = 0.7;\nconst MAX_TILT = 0.2; // radians of lean the outer loop may ask for\nconst MAX_FORCE = 24; // newtons at the wheels; the only clamp in the loop\nconst MAX_SPIN = 6; // a drag can never hand the pole more than this, so recovery always exists\nconst KICK = 9; // rad/s of tip spin per world unit of pointer travel\nconst WALL = 1.06;\nconst TRAIL = 26;\nconst WARM = 84; // 0.56 s of the real solver before the first paint\nconst ACCENT = '255, 196, 107';\n\ninterface State {\n  clock: number;\n  carry: number;\n  snap: boolean;\n  x: number; // cart position, world units; -1 and +1 are the ends of the bar\n  xd: number;\n  th: number; // pole angle from upright, positive leaning toward +x\n  thd: number;\n  force: number;\n  target: number;\n  kick: number; // pointer travel waiting to be handed to the pole as spin\n  trail: Float64Array; // tip path in world units: x absolute, y measured down from the pivot line\n  head: number;\n}\n\nconst clamp = (v: number, lo: number, hi: number) => (v < lo ? lo : v > hi ? hi : v);\n\nconst worldOf = (stage: number) => -1 + stage * (2 / (STAGES.length - 1));\n\n/**\n * theta'' = (g sin t - cos t * S - d theta') / (l (4/3 - m_p cos^2 t / M))\n *     x'' = S - m_p l theta'' cos t / M,  where S = (F - b x' + m_p l theta'^2 sin t) / M\n *\n * The denominator bottoms out at l (4/3 - m_p / M) = 0.363, so it can never reach zero and no\n * guard is needed there. Semi-implicit order — rate first, then position — because plain Euler\n * pumps energy into the pole at this stiffness and the pole slowly spins itself up.\n */\nfunction advance(s: State) {\n  const lean = clamp(GAIN_POS * (s.target - s.x) - GAIN_VEL * s.xd, -MAX_TILT, MAX_TILT);\n  s.force = clamp(GAIN_ANGLE * (s.th - lean) + GAIN_RATE * s.thd, -MAX_FORCE, MAX_FORCE);\n  const c = Math.cos(s.th);\n  const sn = Math.sin(s.th);\n  const total = MASS_CART + MASS_POLE;\n  const shared = (s.force - CART_DRAG * s.xd + MASS_POLE * POLE_HALF * s.thd * s.thd * sn) / total;\n  const thAcc =\n    (GRAVITY * sn - c * shared - POLE_DAMP * s.thd) /\n    (POLE_HALF * (4 / 3 - (MASS_POLE * c * c) / total));\n  const xAcc = shared - (MASS_POLE * POLE_HALF * thAcc * c) / total;\n  s.thd += thAcc * STEP;\n  s.th += s.thd * STEP;\n  // Wrap into (-pi, pi]. The inner loop reads this angle raw, so an unwrapped one is fatal: once a\n  // hard drag carries the pole over the top, the error keeps counting up past 2pi, the force pins\n  // at +MAX_FORCE against the wall and the pole windmills for good. sin and cos do not care about\n  // the wrap, so nothing drawn changes. One step moves at most MAX_SPIN * STEP = 0.04 rad, so a\n  // single correction always lands inside the range.\n  if (s.th > Math.PI) {\n    s.th -= 2 * Math.PI;\n  } else if (s.th < -Math.PI) {\n    s.th += 2 * Math.PI;\n  }\n  s.xd += xAcc * STEP;\n  s.x += s.xd * STEP;\n  // Hard stops at the bar ends, so an overshoot can never draw the cart off its own track.\n  if (s.x > WALL) {\n    s.x = WALL;\n    s.xd = Math.min(0, s.xd);\n  } else if (s.x < -WALL) {\n    s.x = -WALL;\n    s.xd = Math.max(0, s.xd);\n  }\n}\n\nfunction pushTip(s: State) {\n  const len = 2 * POLE_HALF;\n  s.trail[s.head * 2] = s.x + Math.sin(s.th) * len;\n  s.trail[s.head * 2 + 1] = -Math.cos(s.th) * len;\n  s.head = (s.head + 1) % TRAIL;\n}\n\nfunction capsule(context: CanvasRenderingContext2D, x0: number, x1: number, y: number, r: number) {\n  // Ordered ends, because the force readout is drawn backwards half the time and an unordered\n  // pair would collapse the left-pointing case to a dot — the very frames worth seeing.\n  const a = Math.min(x0, x1);\n  const b = Math.max(x0, x1);\n  context.beginPath();\n  context.arc(a, y, r, Math.PI / 2, -Math.PI / 2);\n  context.arc(b, y, r, -Math.PI / 2, Math.PI / 2);\n  context.closePath();\n  context.fill();\n}\n\n/** `compact` is the 298x240 catalogue-card variant: presentation only, all of it CSS. */\nexport type BalanceProgressProps = { compact?: boolean };\n\nexport function BalanceProgress({ compact = false }: BalanceProgressProps) {\n  const reduced = useReducedMotion();\n  const [stage, setStage] = useState(2);\n\n  // Typed without binding the argument: the warm-up is pure physics in world units and needs no\n  // geometry, and an unused parameter fails the build.\n  const setup: (c: SceneSetupContext) => State = () => {\n    const s: State = {\n      clock: 0,\n      carry: 0,\n      snap: reduced,\n      x: worldOf(0),\n      xd: 0,\n      th: 0,\n      thd: 0,\n      force: 0,\n      target: worldOf(stage),\n      kick: 0,\n      trail: new Float64Array(TRAIL * 2),\n      head: 0,\n    };\n    if (reduced) {\n      s.x = s.target;\n      return s;\n    }\n    // Warm the real solver so the first painted frame is already mid-transit and leaning, and seed\n    // the tip streak from it — nothing here is a shortcut around the loop, it IS the loop.\n    for (let i = 0; i < WARM; i += 1) {\n      advance(s);\n      if (i % 3 === 0) pushTip(s);\n    }\n    return s;\n  };\n\n  const draw = ({ context, width, height, state, pointer }: SceneDrawContext<State>) => {\n    const now = performance.now() / 1000;\n    const dt = state.clock === 0 ? 0 : Math.min(0.05, now - state.clock);\n    state.clock = now;\n    state.target = worldOf(stage);\n\n    const mid = width / 2;\n    const trackY = Math.round(Math.min(height - 92, height * 0.7));\n    // Width sets the scale and height caps it. The pole is 0.6 world units long, so on a wide,\n    // short card an uncapped scale swings the tip out through the top of the stage, where\n    // overflow: hidden eats it. The floor keeps the pointer-to-world division finite.\n    const scale = Math.max(48, Math.min((width - 92) / 2, (trackY - 46) / (2 * POLE_HALF)));\n    const px = (wx: number) => mid + wx * scale;\n\n    if (state.snap) {\n      state.x = state.target;\n      state.xd = 0;\n      state.th = 0;\n      state.thd = 0;\n      state.force = 0;\n    } else {\n      if (pointer.down && pointer.inside) {\n        state.kick += ((pointer.x - pointer.lastX) / scale) * KICK;\n      }\n      if (state.kick !== 0) {\n        // Spend the whole frame's drag as one impulse on the tip. Capping the resulting spin is\n        // what keeps a furious drag inside the envelope the clamped force can still recover from.\n        state.thd = clamp(state.thd + clamp(state.kick, -1.6, 1.6), -MAX_SPIN, MAX_SPIN);\n        state.kick = 0;\n      }\n      state.carry += dt;\n      let n = 0;\n      while (state.carry >= STEP && n < 8) {\n        advance(state);\n        state.carry -= STEP;\n        n += 1;\n      }\n      if (n === 8) {\n        state.carry = 0;\n      }\n      pushTip(state);\n    }\n\n    context.clearRect(0, 0, width, height);\n    const cartX = px(state.x);\n\n    context.fillStyle = 'rgba(255, 255, 255, 0.075)';\n    capsule(context, px(-1), px(1), trackY, 4.5);\n    context.fillStyle = `rgba(${ACCENT}, 0.82)`;\n    capsule(context, px(-1), Math.max(px(-1), cartX), trackY, 4.5);\n\n    for (let i = 0; i < STAGES.length; i += 1) {\n      const wx = worldOf(i);\n      const passed = state.x >= wx - 0.012;\n      context.fillStyle = passed ? 'rgba(10, 14, 23, 0.85)' : 'rgba(255, 255, 255, 0.24)';\n      context.beginPath();\n      context.arc(px(wx), trackY, 2.6, 0, Math.PI * 2);\n      context.fill();\n    }\n\n    context.strokeStyle = `rgba(${ACCENT}, 0.4)`;\n    context.lineWidth = 1;\n    context.setLineDash([3, 4]);\n    context.beginPath();\n    context.moveTo(px(state.target), trackY - 44);\n    context.lineTo(px(state.target), trackY + 30);\n    context.stroke();\n    context.setLineDash([]);\n\n    // The force the inner loop is actually asking for, drawn from the cart it acts on. On a stage\n    // change it points away from the dashed target for the first third of a second; that is the\n    // whole mechanism, so it is on screen rather than in the console.\n    const clipped = Math.abs(state.force) > MAX_FORCE - 0.05;\n    context.fillStyle = clipped ? 'rgba(255, 255, 255, 0.6)' : `rgba(${ACCENT}, 0.5)`;\n    capsule(context, cartX, cartX + state.force * 1.4, trackY + 20, 1.5);\n\n    const wheelR = 5.5;\n    const wheelY = trackY - 10;\n    const spin = (state.x * scale) / wheelR; // kinematic: no rolling constraint is solved, it reads\n    for (let i = -1; i <= 1; i += 2) {\n      const wx = cartX + i * 8;\n      context.fillStyle = 'rgba(12, 17, 27, 0.95)';\n      context.strokeStyle = 'rgba(255, 255, 255, 0.32)';\n      context.lineWidth = 1.4;\n      context.beginPath();\n      context.arc(wx, wheelY, wheelR, 0, Math.PI * 2);\n      context.fill();\n      context.stroke();\n      context.beginPath();\n      context.moveTo(wx - Math.cos(spin) * 3.6, wheelY - Math.sin(spin) * 3.6);\n      context.lineTo(wx + Math.cos(spin) * 3.6, wheelY + Math.sin(spin) * 3.6);\n      context.stroke();\n    }\n\n    context.fillStyle = '#e8edf7';\n    capsule(context, cartX - 13, cartX + 13, trackY - 22, 8);\n\n    const pivotY = trackY - 30;\n    const poleLen = 2 * POLE_HALF * scale;\n\n    // Tip history is absolute, so the streak is the path the mass took across the bar rather than\n    // a decoration stuck to the cart. It is the clearest tell that the lean leads the travel.\n    if (!state.snap) {\n      context.lineWidth = 1.6;\n      for (let i = 1; i < TRAIL; i += 1) {\n        const a = (state.head + i) % TRAIL;\n        const b = (state.head + i - 1) % TRAIL;\n        context.strokeStyle = `rgba(${ACCENT}, ${((i / TRAIL) * 0.34).toFixed(3)})`;\n        context.beginPath();\n        context.moveTo(px(state.trail[b * 2]), pivotY + state.trail[b * 2 + 1] * scale);\n        context.lineTo(px(state.trail[a * 2]), pivotY + state.trail[a * 2 + 1] * scale);\n        context.stroke();\n      }\n    }\n\n    const tipX = cartX + Math.sin(state.th) * poleLen;\n    const tipY = pivotY - Math.cos(state.th) * poleLen;\n    context.strokeStyle = 'rgba(232, 237, 247, 0.88)';\n    context.lineWidth = 3;\n    context.lineCap = 'round';\n    context.beginPath();\n    context.moveTo(cartX, pivotY);\n    context.lineTo(tipX, tipY);\n    context.stroke();\n    context.lineCap = 'butt';\n\n    context.fillStyle = `rgb(${ACCENT})`;\n    context.beginPath();\n    context.arc(tipX, tipY, 6.5, 0, Math.PI * 2);\n    context.fill();\n\n    context.fillStyle = 'rgba(10, 14, 23, 0.9)';\n    context.beginPath();\n    context.arc(cartX, pivotY, 2.4, 0, Math.PI * 2);\n    context.fill();\n  };\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<State>({ setup, draw });\n  useEffect(() => requestRender(), [reduced, stage, requestRender]);\n\n  return (\n    <div className=\"balance-progress-stage\" data-compact={compact ? 'true' : undefined}>\n      <div ref={stageRef} className=\"balance-progress-surface\">\n        <canvas ref={canvasRef} aria-hidden=\"true\" />\n      </div>\n      <div className=\"balance-progress-content\">\n        <div>\n          <p className=\"balance-progress-eyebrow\">Release pipeline</p>\n          <div className=\"balance-progress-head\">\n            <h3 className=\"balance-progress-title\">{STAGES[stage]}</h3>\n            <span className=\"balance-progress-percent\">\n              {Math.round(stage * (100 / (STAGES.length - 1)))}%\n            </span>\n          </div>\n          <p className=\"balance-progress-sub\">\n            The cart carries the fill and balances the pole while it moves. It has to lean toward the\n            stage you pick before it can travel there, so the first step is always backwards.\n          </p>\n        </div>\n        <nav className=\"balance-progress-steps\" aria-label=\"Release stage\">\n          {STAGES.map((name, i) => (\n            /* Still clickable in a card — only the tab order changes, because the card\n               frame is aria-hidden and a focusable node under that is a real bug. */\n            <button\n              key={name}\n              type=\"button\"\n              className=\"balance-progress-step\"\n              aria-current={i === stage ? 'step' : undefined}\n              tabIndex={compact ? -1 : undefined}\n              onClick={() => setStage(i)}\n            >\n              {name}\n            </button>\n          ))}\n        </nav>\n      </div>\n      <p className=\"balance-progress-hint\">drag to disturb</p>\n    </div>\n  );\n}\n\nexport default BalanceProgress;\n","type":"registry:ui"},{"path":"components/ui/balance-progress.css","target":"components/ui/balance-progress.css","content":".balance-progress-stage {\n  position: relative;\n  display: grid;\n  width: 100%;\n  min-height: 20rem;\n  padding: 1.75rem 1.75rem 1.5rem;\n  overflow: hidden;\n  border-radius: 0.75rem;\n  background: radial-gradient(120% 120% at 50% 0%, #111a29 0%, #0a0e17 58%, #06080e 100%);\n  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07);\n  font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;\n  color: #eef1f7;\n  isolation: isolate;\n}\n\n/* The measured element carries no border: `inset: 0` resolves against the padding\n   box, so one pixel of border would shift the canvas origin and the cart would ride\n   beside the track instead of on it. The hairline lives on the stage as a shadow. */\n.balance-progress-surface {\n  position: absolute;\n  inset: 0;\n  touch-action: none;\n}\n\n.balance-progress-surface canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/* Transparent to the pointer so a drag anywhere over the card still reaches the\n   pole. Only the stage buttons take events back, and they can because they are a\n   later sibling of the capture surface rather than a child of it. */\n.balance-progress-content {\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n  pointer-events: none;\n}\n\n.balance-progress-eyebrow {\n  margin: 0 0 0.625rem;\n  font: 500 0.6875rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.14em;\n  text-transform: uppercase;\n  color: rgba(238, 241, 247, 0.48);\n}\n\n.balance-progress-head {\n  display: flex;\n  align-items: baseline;\n  justify-content: space-between;\n  gap: 1rem;\n}\n\n.balance-progress-title {\n  margin: 0;\n  font-size: 1.5rem;\n  font-weight: 500;\n  line-height: 1.1;\n  letter-spacing: -0.02em;\n  text-shadow: 0 1px 16px rgba(6, 8, 14, 0.75);\n}\n\n.balance-progress-percent {\n  font-size: 0.875rem;\n  font-weight: 500;\n  font-variant-numeric: tabular-nums;\n  letter-spacing: 0.02em;\n  color: #ffc46b;\n  text-shadow: 0 1px 16px rgba(6, 8, 14, 0.8);\n}\n\n.balance-progress-sub {\n  max-width: 24rem;\n  margin: 0.5rem 0 0;\n  font-size: 0.8125rem;\n  line-height: 1.45;\n  color: rgba(238, 241, 247, 0.55);\n  text-shadow: 0 1px 14px rgba(6, 8, 14, 0.85);\n}\n\n.balance-progress-steps {\n  display: flex;\n  gap: 0.3125rem;\n}\n\n.balance-progress-step {\n  appearance: none;\n  flex: 1;\n  min-width: 0;\n  margin: 0;\n  padding: 0.5rem 0.25rem;\n  overflow: hidden;\n  border: 1px solid rgba(255, 255, 255, 0.13);\n  border-radius: 999px;\n  background: rgba(8, 12, 20, 0.5);\n  font: inherit;\n  font-size: 0.75rem;\n  font-weight: 500;\n  letter-spacing: 0.01em;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  color: rgba(238, 241, 247, 0.7);\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.balance-progress-step:hover {\n  border-color: rgba(255, 196, 107, 0.45);\n  color: #fff6e8;\n}\n\n.balance-progress-step[aria-current='step'] {\n  border-color: rgba(255, 196, 107, 0.65);\n  background: rgba(255, 196, 107, 0.16);\n  color: #fff3e0;\n}\n\n.balance-progress-step:focus-visible {\n  outline: 2px solid rgba(255, 196, 107, 0.8);\n  outline-offset: 2px;\n}\n\n.balance-progress-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(238, 241, 247, 0.26);\n  pointer-events: none;\n}\n\n@media (max-width: 26rem) {\n  .balance-progress-stage {\n    padding: 1.5rem 1rem 1.375rem;\n  }\n\n  .balance-progress-title {\n    font-size: 1.25rem;\n  }\n\n  .balance-progress-step {\n    padding: 0.5rem 0.125rem;\n    font-size: 0.6875rem;\n    letter-spacing: 0;\n  }\n}\n\n/*\n * With the loop stopped the cart is placed at the selected stage, upright, and the\n * bar reads that stage exactly — the analytic rest state of the pair, which is the\n * one state the controller is trying to reach anyway. What is switched off is the\n * lean, the backwards step and the settling wobble, plus the button colour fades.\n * Pressing a stage still repaints, so the picture is never stale, and a drag no\n * longer does anything because there is no frame in which to recover from it.\n */\n@media (prefers-reduced-motion: reduce) {\n  .balance-progress-step {\n    transition: none;\n  }\n}\n\n/*\n * Card variant: the same component authored for the 298x240 catalogue frame, unscaled.\n * The stage stops being a section — the canvas takes the whole box, the copy collapses\n * to the eyebrow, and the stage buttons shrink into a strip pinned below the track, so\n * the cart, the pole and its tip streak are the only things with room.\n */\n.balance-progress-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  padding: 0;\n  border-radius: 0;\n}\n\n/* The surface's `inset: 0` resolves against the padding box, so zeroing the padding\n   above is what hands the canvas all 298x240: the track widens, and the pole and the\n   streak grow with it. `pan-y` rather than `none` because a full-bleed drag surface in\n   a scrolling grid traps a phone on this card, and the disturbance only ever reads\n   horizontal pointer travel — a vertical swipe was never an input here. */\n.balance-progress-stage[data-compact='true'] .balance-progress-surface {\n  touch-action: pan-y;\n}\n\n/* One overlay strip along the bottom instead of a column the mechanism has to share.\n   The base rule's `pointer-events: none` is left alone, so a drag started on the\n   eyebrow still lands on the pole. */\n.balance-progress-stage[data-compact='true'] .balance-progress-content {\n  position: absolute;\n  inset: auto 0 0 0;\n  padding: 0.625rem 0.75rem;\n}\n\n.balance-progress-stage[data-compact='true'] .balance-progress-eyebrow {\n  margin: 0 0 0.375rem;\n}\n\n/* The eyebrow is the one line of text left. The stage name is already the highlighted\n   button, the percent means nothing without the title beside it, and the buttons say\n   plainly enough that they can be pressed. */\n.balance-progress-stage[data-compact='true'] .balance-progress-head,\n.balance-progress-stage[data-compact='true'] .balance-progress-sub,\n.balance-progress-stage[data-compact='true'] .balance-progress-hint {\n  display: none;\n}\n\n.balance-progress-stage[data-compact='true'] .balance-progress-steps {\n  gap: 0.25rem;\n}\n\n/* Pressing a stage is the demo — the wrong-way first step — so the buttons stay, small\n   enough that five of them cross 298px without reaching for the ellipsis. */\n.balance-progress-stage[data-compact='true'] .balance-progress-step {\n  padding: 0.25rem 0.1875rem;\n  font-size: 0.625rem;\n  letter-spacing: 0;\n  pointer-events: auto;\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":["loaders","micro"],"docs":"https://ui.artbloom.tech/artbloom/animations/balance-progress"}}