{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"slosh-gauge","type":"registry:ui","title":"Slosh Gauge","description":"A stat card whose liquid obeys the shallow-water equations, so it lands on the exact number with no easing and the wave crosses the tank at its own speed.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/slosh-gauge.tsx","target":"components/ui/slosh-gauge.tsx","content":"'use client';\n\nimport './slosh-gauge.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 level gauge whose liquid obeys the shallow-water equations.\n *\n * Depth and along-tank velocity live on a staggered grid — depth at the cell\n * centres, velocity at the faces between them — and are advanced by the pair of\n * conservation laws a tide model uses: mass in equals mass out, and water\n * accelerates down the slope of its own surface. Nothing here is a sine wave with\n * a phase offset. The crest that runs across the tank when the level changes is\n * the gravity wave the pour actually launched, travelling at √(g·h), and it turns\n * around at the far side because the wall is a zero-velocity boundary rather than\n * a place the animation happens to stop.\n *\n * The flux at every face is upwinded and the two end faces are pinned shut, so\n * the sum of the depths only ever changes by what the inlet adds. That is why the\n * gauge comes to rest on the number it was asked for, exactly, with nothing\n * easing it there.\n *\n * Move the pointer across the card to tilt the tank: its offset from the centre\n * becomes the along-bed component of gravity, and the surface takes up the slope\n * that implies.\n */\n\n/** Seconds per step. */\nconst STEP = 1 / 240;\n/**\n * Target cell width in pixels, rather than a fixed cell count. An explicit scheme\n * is stable while a wave crosses less than one cell per step, and the wave speed\n * is √(g·h) — so it is dx that has to hold still as the card resizes. A fixed\n * count would halve dx on a narrow phone and put the solver over the limit.\n */\nconst CELL = 6;\n/** Gravity, px/s². With the depth, this is what sets the wave speed. */\nconst G = 1400;\n/** Bed friction, linearised. The only thing that finally flattens the surface. */\nconst FRICTION = 2.2;\n/** Along-bed gravity at full pointer deflection — a tilt of about fifteen degrees. */\nconst TILT = 380;\n/** How fast the inlet may change the mean depth, in pixels per second. */\nconst POUR = 300;\n/** Depth a cell is never taken below, so a dry cell cannot go negative. */\nconst FLOOR = 0.75;\n/** Fraction of the card the tank fills at 100%. The rest is room for crests. */\nconst HEAD = 0.8;\n/** Cells the inlet spreads over. One cell would be a spike, not a stream. */\nconst MOUTH = 9;\n\nconst LEVELS = [18, 46, 72, 96];\n\ninterface SloshState {\n  readonly cells: number;\n  readonly dx: number;\n  /** Depth at the cell centres. The volume of water, in one array. */\n  readonly h: Float64Array;\n  /** Along-tank velocity at the faces between cells. Both ends stay at zero. */\n  readonly u: Float64Array;\n  readonly flux: Float64Array;\n  /** Inlet weights, one per cell, summing to one so a pour adds exactly its budget. */\n  readonly mouth: Float64Array;\n  readonly mouthX: number;\n  readonly bedY: number;\n  readonly maxDepth: number;\n  /** Wanted mean depth in pixels, written from the component's value each frame. */\n  target: number;\n  /** Along-bed gravity from the tilt, px/s². */\n  gx: number;\n  /** How hard the inlet ran on the last step, −1…1. Only used to draw the stream. */\n  pour: number;\n  carry: number;\n  clock: number;\n  /**\n   * Put the tank at rest on the target and skip the solver. Set under\n   * `prefers-reduced-motion`, where the loop never runs and a gauge that advanced\n   * one accumulator's worth per repaint would never arrive.\n   */\n  snap: boolean;\n}\n\n/** The tank flat at the target level, stationary. */\nfunction flatten(state: SloshState) {\n  state.h.fill(Math.max(FLOOR, state.target));\n  state.u.fill(0);\n  state.pour = 0;\n}\n\nfunction step(state: SloshState) {\n  const { cells, dx, h, u, flux, mouth } = state;\n\n  /*\n   * The inlet, rate-limited. Depth is added over a cosine bump rather than into\n   * one cell: a point source at this rate is a spike a hundred pixels tall that\n   * the solver then has to survive, and the wave it launches is nothing like the\n   * one a stream of water launches.\n   */\n  let total = 0;\n  for (let i = 0; i < cells; i++) total += h[i];\n  const limit = POUR * cells * STEP;\n  const move = Math.max(-limit, Math.min(limit, state.target * cells - total));\n  state.pour = move / limit;\n  if (move !== 0) {\n    for (let i = 0; i < cells; i++) h[i] = Math.max(FLOOR, h[i] + move * mouth[i]);\n  }\n  /*\n   * Momentum at the faces: water accelerates down the surface slope, is carried by\n   * its own flow, leans with the tilt, and loses speed to the bed. The advection\n   * term is upwinded — differencing it centrally is unstable at this Courant\n   * number and shows up as a checkerboard along the surface inside a second.\n   */\n  for (let j = 1; j < cells; j++) {\n    const speed = u[j];\n    const slope = (h[j] - h[j - 1]) / dx;\n    const shear = speed > 0 ? (speed - u[j - 1]) / dx : (u[j + 1] - speed) / dx;\n    u[j] = speed + (-G * slope - speed * shear + state.gx - FRICTION * speed) * STEP;\n  }\n\n  /*\n   * Continuity, in flux form. `flux[0]` and `flux[cells]` are never written, so no\n   * water crosses the walls and the total is conserved to the last pixel — which\n   * is the whole reason the settled level is the requested one and not near it.\n   * The floor below is the one leak, and at these levels it never triggers.\n   */\n  for (let j = 1; j < cells; j++) flux[j] = u[j] * (u[j] > 0 ? h[j - 1] : h[j]);\n  for (let i = 0; i < cells; i++) {\n    h[i] = Math.max(FLOOR, h[i] - (flux[i + 1] - flux[i]) * (STEP / dx));\n  }\n}\n\nfunction build({ width, height }: SceneSetupContext, value: number): SloshState {\n  const cells = Math.max(24, Math.round(width / CELL));\n  const dx = width / cells;\n  const maxDepth = height * HEAD;\n\n  // A raised-cosine inlet, normalised. Inset from the wall by its own width so the\n  // bump is not half-clipped and the pour does not lean on the boundary.\n  const mouth = new Float64Array(cells);\n  const centre = Math.min(cells - 1, MOUTH);\n  let weight = 0;\n  for (let i = 0; i < cells; i++) {\n    const away = Math.abs(i - centre) / MOUTH;\n    if (away >= 1) continue;\n    mouth[i] = 0.5 + 0.5 * Math.cos(Math.PI * away);\n    weight += mouth[i];\n  }\n  for (let i = 0; i < cells; i++) mouth[i] /= weight;\n\n  const state: SloshState = {\n    cells,\n    dx,\n    h: new Float64Array(cells),\n    u: new Float64Array(cells + 1),\n    flux: new Float64Array(cells + 1),\n    mouth,\n    mouthX: (centre + 0.5) * dx,\n    bedY: height,\n    maxDepth,\n    target: (maxDepth * value) / 100,\n    gx: 0,\n    pour: 0,\n    carry: 0,\n    clock: 0,\n    snap: false,\n  };\n\n  // Starting flat and full is the honest initial condition: the tank was already\n  // at this level before the component mounted.\n  flatten(state);\n\n  return state;\n}\nfunction paint({ context, width, height, state, pointer }: SceneDrawContext<SloshState>) {\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  // The pointer's offset from the centre is the tilt. Leaving the card levels the\n  // tank rather than freezing it at whatever angle the cursor left on.\n  state.gx = pointer.inside ? TILT * ((pointer.x / width) * 2 - 1) : 0;\n\n  state.carry += elapsed;\n  let steps = 0;\n  while (state.carry >= STEP && steps < 8) {\n    step(state);\n    state.carry -= STEP;\n    steps += 1;\n  }\n  if (state.carry > STEP * 8) state.carry = 0;\n  if (state.snap) flatten(state);\n\n  const { cells, dx, h, bedY, maxDepth } = state;\n\n  context.clearRect(0, 0, width, height);\n\n  // Quarter marks, so the level reads as a measurement and not as decoration.\n  context.strokeStyle = 'rgba(226,240,255,0.07)';\n  context.lineWidth = 1;\n  for (let mark = 1; mark <= 4; mark++) {\n    const y = Math.round(bedY - (maxDepth * mark) / 4) + 0.5;\n    context.beginPath();\n    context.moveTo(0, y);\n    context.lineTo(width, y);\n    context.stroke();\n  }\n\n  // The surface polyline, walked twice: once as the lid of the body and once on its\n  // own as the lit edge. Cell centres, with the two half-cells at the walls carried\n  // out flat — the wall is where the velocity is zero, not where the depth is.\n  const trace = () => {\n    context.moveTo(0, bedY - h[0]);\n    for (let i = 0; i < cells; i++) context.lineTo((i + 0.5) * dx, bedY - h[i]);\n    context.lineTo(width, bedY - h[cells - 1]);\n  };\n\n  context.beginPath();\n  trace();\n  context.lineTo(width, bedY);\n  context.lineTo(0, bedY);\n  context.closePath();\n  const body = context.createLinearGradient(0, bedY - maxDepth, 0, bedY);\n  body.addColorStop(0, 'rgba(90,200,218,0.58)');\n  body.addColorStop(1, 'rgba(22,84,124,0.9)');\n  context.fillStyle = body;\n  context.fill();\n\n  context.beginPath();\n  trace();\n  context.strokeStyle = 'rgba(186,246,255,0.85)';\n  context.lineWidth = 1.5;\n  context.stroke();\n\n  /*\n   * The stream is drawn from the inlet's actual flux, so it appears when the gauge\n   * is filling, thickens with the rate, and stops the instant the level is reached.\n   * Draining is silent because the outlet is under the water.\n   */\n  if (state.pour > 0.02) {\n    const cell = Math.min(cells - 1, Math.max(0, Math.round(state.mouthX / dx - 0.5)));\n    const surface = Math.max(0, bedY - h[cell]);\n    const stream = context.createLinearGradient(0, 0, 0, surface);\n    stream.addColorStop(0, 'rgba(186,246,255,0.04)');\n    stream.addColorStop(1, `rgba(186,246,255,${0.3 * state.pour})`);\n    context.fillStyle = stream;\n    const half = 1.5 + state.pour * 2.5;\n    context.fillRect(state.mouthX - half, 0, half * 2, surface);\n  }\n}\n\n/** `compact` is the 298x240 catalogue card: the same tank, given the whole frame,\n *  with the copy cut to one line along the bottom. Presentation only — the CSS. */\nexport type SloshGaugeProps = { compact?: boolean };\n\n/**\n * The card is the component; the tank is its background. The canvas layer sits\n * underneath and the readout sits on top with `pointer-events: none`, so a move\n * anywhere over the card tilts the tank while the level keys keep their clicks —\n * the stage takes pointer capture as it tracks, and a real button inside it would\n * have its click swallowed by that capture.\n */\nexport function SloshGauge({ compact = false }: SloshGaugeProps) {\n  const [value, setValue] = useState(46);\n  const reduced = useReducedMotion();\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<SloshState>({\n    setup: (scene) => build(scene, value),\n    draw: (scene) => {\n      scene.state.target = (scene.state.maxDepth * value) / 100;\n      scene.state.snap = reduced;\n      paint(scene);\n    },\n  });\n\n  // The level has to repaint on its own account: with the loop stopped under\n  // reduced motion nothing else would, and the water would stay at the old mark.\n  useEffect(() => {\n    requestRender();\n  }, [value, requestRender]);\n\n  return (\n    <div className=\"slosh-gauge-stage\" data-compact={compact ? 'true' : undefined}>\n      <div className=\"slosh-gauge-card\">\n        <div ref={stageRef} className=\"slosh-gauge-tank\" aria-hidden=\"true\">\n          <canvas ref={canvasRef} />\n        </div>\n\n        <div className=\"slosh-gauge-face\">\n          <div>\n            <p className=\"slosh-gauge-label\">Object storage</p>\n            <p\n              className=\"slosh-gauge-read\"\n              role=\"meter\"\n              aria-label=\"Object storage used\"\n              aria-valuemin={0}\n              aria-valuemax={100}\n              aria-valuenow={value}\n              aria-valuetext={`${value} percent of 2 TB`}\n            >\n              {value}\n              <span className=\"slosh-gauge-unit\">%</span>\n            </p>\n            <p className=\"slosh-gauge-sub\">of 2 TB provisioned</p>\n          </div>\n\n          <div className=\"slosh-gauge-keys\" role=\"group\" aria-label=\"Set level\">\n            {LEVELS.map((level) => (\n              <button\n                key={level}\n                type=\"button\"\n                className=\"slosh-gauge-key\"\n                aria-pressed={level === value}\n                // The card frame is aria-hidden, so inside it the keys leave the tab\n                // order. They stay clickable — only the keyboard path is withdrawn.\n                tabIndex={compact ? -1 : undefined}\n                onClick={() => setValue(level)}\n              >\n                {level}%\n              </button>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      <p className=\"slosh-gauge-hint\">Move across to tilt</p>\n    </div>\n  );\n}\n\nexport default SloshGauge;\n","type":"registry:ui"},{"path":"components/ui/slosh-gauge.css","target":"components/ui/slosh-gauge.css","content":".slosh-gauge-stage {\n  position: relative;\n  display: grid;\n  place-content: center;\n  width: 100%;\n  min-height: 340px;\n  padding: 2.5rem 1.5rem;\n  overflow: hidden;\n  border-radius: 0.75rem;\n  background: radial-gradient(120% 110% at 50% 0%, #0c1620 0%, #070b12 60%, #05070c 100%);\n  color: #eaf5ff;\n}\n\n.slosh-gauge-card {\n  position: relative;\n  width: min(23rem, 100%);\n  height: 19rem;\n  overflow: hidden;\n  border: 1px solid rgba(255, 255, 255, 0.09);\n  border-radius: 1rem;\n  background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.015));\n  isolation: isolate;\n}\n\n/* The tank, behind the readout. `overflow: hidden` on the card is what gives the\n   water its rounded corners — the solver knows nothing about the border radius. */\n.slosh-gauge-tank {\n  position: absolute;\n  inset: 0;\n  touch-action: none;\n}\n\n.slosh-gauge-tank canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/* Transparent to the pointer, so a move anywhere over the card still tilts the\n   tank. Only the level keys take events back. */\n.slosh-gauge-face {\n  position: relative;\n  display: flex;\n  height: 100%;\n  flex-direction: column;\n  justify-content: space-between;\n  padding: 1.25rem 1.375rem 1.125rem;\n  pointer-events: none;\n}\n\n.slosh-gauge-label {\n  margin: 0 0 0.5rem;\n  font: 500 0.6875rem/1 ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n  letter-spacing: 0.14em;\n  text-transform: uppercase;\n  color: rgba(234, 245, 255, 0.5);\n}\n\n.slosh-gauge-read {\n  margin: 0;\n  font-size: 3.25rem;\n  font-weight: 500;\n  line-height: 0.92;\n  letter-spacing: -0.035em;\n  font-variant-numeric: tabular-nums;\n  text-shadow: 0 1px 18px rgba(5, 12, 20, 0.55);\n}\n\n.slosh-gauge-unit {\n  margin-left: 0.15em;\n  font-size: 1.25rem;\n  font-weight: 500;\n  letter-spacing: -0.01em;\n  color: rgba(234, 245, 255, 0.62);\n}\n\n.slosh-gauge-sub {\n  margin: 0.5rem 0 0;\n  font-size: 0.8125rem;\n  color: rgba(234, 245, 255, 0.52);\n  text-shadow: 0 1px 14px rgba(5, 12, 20, 0.5);\n}\n\n.slosh-gauge-keys {\n  display: flex;\n  gap: 0.3125rem;\n}\n\n.slosh-gauge-key {\n  appearance: none;\n  flex: 1;\n  margin: 0;\n  padding: 0.4375rem 0;\n  border: 1px solid rgba(255, 255, 255, 0.14);\n  border-radius: 999px;\n  background: rgba(6, 14, 22, 0.42);\n  font: inherit;\n  font-size: 0.75rem;\n  font-weight: 500;\n  font-variant-numeric: tabular-nums;\n  color: rgba(234, 245, 255, 0.72);\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.slosh-gauge-key:hover {\n  border-color: rgba(186, 246, 255, 0.4);\n  color: #f2fbff;\n}\n\n.slosh-gauge-key[aria-pressed='true'] {\n  border-color: rgba(186, 246, 255, 0.62);\n  background: rgba(186, 246, 255, 0.16);\n  color: #f5fdff;\n}\n\n.slosh-gauge-key:focus-visible {\n  outline: 2px solid rgba(186, 246, 255, 0.75);\n  outline-offset: 2px;\n}\n\n.slosh-gauge-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(234, 245, 255, 0.28);\n  pointer-events: none;\n}\n\n/*\n * With the loop stopped the tank is placed flat at the requested level and stays\n * there. Pressing a key still moves the water, because the component asks for one\n * repaint and the solver is skipped in favour of the settled state — what is gone\n * is the wave in between, which is the part that was asked to go.\n */\n@media (prefers-reduced-motion: reduce) {\n  .slosh-gauge-key {\n    transition: none;\n  }\n}\n\n/*\n * The card variant: the same tank authored for the 298x240 catalogue frame rather\n * than scaled into it. The section padding goes, the gauge becomes the frame — water\n * edge to edge — and the readout drops to a strip along the bottom with the level\n * keys beside it. No `vw` and no `clamp()` anywhere below: the card is 298px wide and\n * the viewport it sits in is not.\n */\n.slosh-gauge-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  padding: 0;\n  /* The card frame rounds and clips already. */\n  border-radius: 0;\n}\n\n/*\n * The gauge fills the frame instead of floating centred in it: 298 x 240 of tank,\n * which at 100% is 192px of water with 48px of headroom left for the crests. Absolute\n * rather than a stretched grid item, so the height is the frame's and not a track\n * sized from whatever is left once the copy has gone.\n */\n.slosh-gauge-stage[data-compact='true'] .slosh-gauge-card {\n  position: absolute;\n  inset: 0;\n  width: auto;\n  height: auto;\n  border: 0;\n  border-radius: 0;\n}\n\n/*\n * `pan-y`, not `none`. The tank is now the full bleed of the card, and a drag surface\n * that swallows vertical touches traps the page in a scrolling grid of cards, which is\n * the worse failure by a distance. Nothing is lost here: the tilt is read from the\n * pointer's x alone, so a sideways drag still tips the tank its full fifteen degrees.\n */\n.slosh-gauge-stage[data-compact='true'] .slosh-gauge-tank {\n  touch-action: pan-y;\n}\n\n/*\n * The readout off the tank's back and onto the bottom edge, one row instead of a\n * column, so the water and its crests get the box rather than sharing it with a\n * column of type. Still deaf to the pointer, so a drag across it tilts the tank.\n */\n.slosh-gauge-stage[data-compact='true'] .slosh-gauge-face {\n  position: absolute;\n  inset: auto 0 0 0;\n  height: auto;\n  flex-direction: row;\n  align-items: flex-end;\n  gap: 0.5rem;\n  padding: 0.75rem;\n  pointer-events: none;\n}\n\n/* Everything but the level: the provisioned figure is a sentence, the section label is\n   what the card's own title already says, and the hint asks for a hover a phone has\n   not got. */\n.slosh-gauge-stage[data-compact='true'] .slosh-gauge-label,\n.slosh-gauge-stage[data-compact='true'] .slosh-gauge-sub,\n.slosh-gauge-stage[data-compact='true'] .slosh-gauge-hint {\n  display: none;\n}\n\n/*\n * The one line that stays, at a fixed 22px. It is the readout because the readout is\n * the state — it is the number the water is asked for and settles on, so the card says\n * what the level means rather than only showing a coloured shape move. From 3.25rem,\n * which was sized for a 19rem card in a 340px section. The water is behind it at the\n * upper levels, which is what the text-shadow already on it is for.\n */\n.slosh-gauge-stage[data-compact='true'] .slosh-gauge-read {\n  font-size: 1.375rem;\n  line-height: 1;\n  white-space: nowrap;\n}\n\n.slosh-gauge-stage[data-compact='true'] .slosh-gauge-unit {\n  font-size: 0.6875rem;\n}\n\n/*\n * The keys stay, because pressing one is the pour — the crest that runs the length of\n * the tank is the half of this animation a hover cannot show. Sized to sit on the\n * strip beside the readout, and still the only thing here taking the pointer back.\n */\n.slosh-gauge-stage[data-compact='true'] .slosh-gauge-keys {\n  gap: 0.25rem;\n}\n\n.slosh-gauge-stage[data-compact='true'] .slosh-gauge-key {\n  flex: 0 0 auto;\n  padding: 0.25rem 0.4375rem;\n  font-size: 0.625rem;\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":["numbers","micro","loaders"],"docs":"https://ui.artbloom.tech/artbloom/animations/slosh-gauge"}}