{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"liquid-nav","type":"registry:ui","title":"Liquid Nav","description":"A tab indicator that is fifteen masses on a spring chain, so it necks and catches up instead of easing. Drop-in for a real nav.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/liquid-nav.tsx","target":"components/ui/liquid-nav.tsx","content":"'use client';\n\nimport './liquid-nav.css';\n\nimport { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';\n\nimport {\n  useCanvasScene,\n  useReducedMotion,\n  type SceneDrawContext,\n  type SceneSetupContext,\n} from '@/hooks/use-canvas-scene';\n\n/**\n * A tab bar whose indicator is a drop of liquid rather than a rectangle on a\n * transition.\n *\n * The pill is a chain of point masses strung along the bar with stiff springs and\n * damping on the rate of stretch. Every mass is pulled toward its own place in the\n * destination, but not equally: the pull ramps from weak at the trailing end to\n * full at the leading one. That single asymmetry is the whole reason the middle\n * arrives late, the shape leans into the travel, and it overshoots and settles\n * rather than easing.\n *\n * The thinning is not a scale animation. Every element of the chain is treated as\n * incompressible: stretched by λ along the bar it has to give up the same factor\n * across it, so the local half-thickness goes as 1/λ. Pull the ends apart and the\n * neck has nowhere to take the area from but its own width; let them come back\n * together and it has to bulge.\n *\n * The tabs are real buttons in a real tablist, and their boxes are measured from\n * the DOM rather than assumed, so the indicator lands on the actual text whatever\n * the font does to it. The canvas sits behind them and takes no pointer events.\n */\n\n/** Seconds per step. Links this stiff want a short one. */\nconst STEP = 1 / 480;\n/** Masses in the chain. Enough that the neck is several nodes wide. */\nconst NODES = 15;\n/** Link stiffness, per unit mass. */\nconst LINK_K = 9000;\n/**\n * Damping on the rate of stretch of a link, rather than on each node's velocity.\n * This is what makes the chain a medium instead of fifteen separate springs: the\n * pill can still translate freely, but the internal ringing that would otherwise\n * wobble for seconds is gone inside a couple of tenths.\n */\nconst LINK_DAMP = 62;\n/**\n * How hard a node is pulled toward its own place in the destination. Every node is\n * driven, not just the ends: a single driven end has to hand its whole force down\n * the chain, and a spring drive proportional to a two-hundred-pixel error tears\n * the first link open long before the last one has heard about it.\n */\nconst DRIVE_K = 340;\n/**\n * The drive is weakest at the trailing end and full strength at the leading one,\n * and this is the weak end's share. It is the only asymmetry in the file, and it\n * is what makes the pill lean: the front lets go first, the back is still being\n * persuaded, and the difference between the two is carried as tension — which\n * peaks in the middle, which is where the neck appears.\n */\nconst TAIL_WEIGHT = 0.3;\n/** Viscous drag against the bar. The only thing that finally stops the pill. */\nconst DRAG = 16;\n/**\n * Bounds on λ before it sets the thickness. Measured, not guessed: a jump across\n * two tabs takes the middle to λ ≈ 2.1 and the arrival swell to λ ≈ 0.66, so these\n * clip the swell and leave the stretch alone. Uncapped in the other direction the\n * neck closes to nothing and the pill separates — a real thing liquid does, and the\n * wrong thing for a control that has to stay legible as one object.\n */\nconst MIN_STRETCH = 0.8;\nconst MAX_STRETCH = 2.4;\n\nconst TABS = [\n  { label: 'Overview', body: 'Fifteen masses on springs. No keyframes and no easing curve anywhere in the file.' },\n  { label: 'Physics', body: 'Damping on the stretch rate rather than on velocity, integrated at 480 Hz under the paint loop.' },\n  { label: 'Install', body: 'One component and one hook. The indicator measures your buttons; it does not lay them out.' },\n  { label: 'Changelog', body: 'Thickness now falls as 1/λ rather than 1/√λ — the area is conserved in the plane, not in a cylinder.' },\n];\n\ntype TabBox = { left: number; top: number; width: number; height: number };\n\ninterface LiquidState {\n  readonly count: number;\n  /** Node positions along the bar, and their velocities. The whole simulation. */\n  readonly sx: Float64Array;\n  readonly vx: Float64Array;\n  readonly force: Float64Array;\n  /** Half-thickness per node, from the local stretch. */\n  readonly radius: Float64Array;\n  /** Live tab geometry, shared with the component and re-measured on resize. */\n  readonly boxes: { current: TabBox[] };\n  /** Half-thickness at rest and the bar's centre line, both taken from the DOM. */\n  base: number;\n  cy: number;\n  /** Rest spacing between nodes. Follows the aim tab's width, with a lag. */\n  rest: number;\n  carry: number;\n  clock: number;\n  /** Index of the tab the chain is being pulled toward. Set by the component. */\n  aim: number;\n  /**\n   * The aim the ramp was last built for, and which way it points. Latched on the\n   * change of aim rather than recomputed per step: deciding the direction from the\n   * live centre would flip the ramp the instant the pill crossed its target, which\n   * is a discontinuity in the middle of the travel and looks like a stumble.\n   */\n  held: number;\n  lead: number;\n  /**\n   * Skip the solver and place the chain at rest on the aim. Set under\n   * `prefers-reduced-motion`, where there is no loop to integrate the travel and a\n   * pill that crept a twelfth of the way per repaint would be worse than none.\n   */\n  snap: boolean;\n}\n\n/**\n * The span the chain is heading for. The aim tab's box inset by the end radius,\n * because the end nodes carry a disc that reaches `base` past them — inset by\n * exactly that and the settled pill covers the tab instead of overhanging it.\n */\nfunction span(state: LiquidState) {\n  const boxes = state.boxes.current;\n  const box = boxes[state.aim] ?? boxes[0];\n  if (!box) return null;\n\n  state.base = Math.max(6, box.height * 0.5);\n  state.cy = box.top + box.height * 0.5;\n  const inset = Math.min(state.base, box.width * 0.32);\n\n  return { left: box.left + inset, right: box.left + box.width - inset };\n}\n\n/** Place the chain at rest across the aim tab, evenly spaced and stationary. */\nfunction settle(state: LiquidState) {\n  const aim = span(state);\n  const left = aim ? aim.left : 0;\n  const right = aim ? aim.right : state.base * 2;\n\n  state.rest = Math.max(0.5, (right - left) / (state.count - 1));\n  for (let i = 0; i < state.count; i++) {\n    state.sx[i] = left + state.rest * i;\n    state.vx[i] = 0;\n  }\n  // Already there, so there is no travel for the ramp to describe.\n  state.held = state.aim;\n}\n\n/**\n * One step: link forces, the distributed drive, then a semi-implicit Euler update.\n */\nfunction advance(state: LiquidState) {\n  const { count, sx, vx, force } = state;\n  const aim = span(state);\n  if (!aim) return;\n\n  /*\n   * Rest spacing follows the destination rather than snapping to it. Tabs are\n   * different widths, and jumping the rest length in one step would restate the\n   * stretch of every link at once — a flinch the springs then have to absorb.\n   */\n  const spacing = (aim.right - aim.left) / (count - 1);\n  state.rest += (spacing - state.rest) * Math.min(1, STEP * 14);\n\n  // Which end leads, decided once per aim. See `held` on the state.\n  if (state.aim !== state.held) {\n    state.lead = (aim.left + aim.right) * 0.5 >= (sx[0] + sx[count - 1]) * 0.5 ? 1 : -1;\n    state.held = state.aim;\n  }\n\n  force.fill(0);\n  for (let i = 1; i < count; i++) {\n    const stretch = sx[i] - sx[i - 1] - state.rest;\n    const rate = vx[i] - vx[i - 1];\n    const pull = LINK_K * stretch + LINK_DAMP * rate;\n    force[i] -= pull;\n    force[i - 1] += pull;\n  }\n\n  /*\n   * Every node toward its own place in the destination, weighted along the chain.\n   * Tension is the running sum of the drive imbalance, so it vanishes at both free\n   * ends and peaks somewhere in the middle — which is the neck, arrived at rather\n   * than drawn. Driving only the leading end instead would put a force of\n   * DRIVE_K × 200px into one node, and the links can only answer that by opening\n   * some thirty pixels: the chain tears and its nodes cross over.\n   */\n  for (let i = 0; i < count; i++) {\n    const along = i / (count - 1);\n    const weight = TAIL_WEIGHT + (1 - TAIL_WEIGHT) * (state.lead > 0 ? along : 1 - along);\n    force[i] += DRIVE_K * weight * (aim.left + spacing * i - sx[i]);\n  }\n\n  for (let i = 0; i < count; i++) {\n    vx[i] += (force[i] - DRAG * vx[i]) * STEP;\n    sx[i] += vx[i] * STEP;\n  }\n}\n\n/** Half-thickness per node from the local stretch, area preserved in the plane. */\nfunction thicken(state: LiquidState) {\n  const { count, sx, radius, rest, base } = state;\n\n  for (let i = 0; i < count; i++) {\n    const before = i > 0 ? sx[i] - sx[i - 1] : sx[1] - sx[0];\n    const after = i < count - 1 ? sx[i + 1] - sx[i] : sx[count - 1] - sx[count - 2];\n    // Centred, so a node's thickness answers to the links on both sides of it\n    // rather than stepping between them.\n    const stretch = Math.min(MAX_STRETCH, Math.max(MIN_STRETCH, (before + after) * 0.5 / rest));\n    radius[i] = base / stretch;\n  }\n}\n\nfunction build(\n  { height }: SceneSetupContext,\n  boxes: { current: TabBox[] },\n  aim: number,\n): LiquidState {\n  const count = NODES;\n  const state: LiquidState = {\n    count,\n    sx: new Float64Array(count),\n    vx: new Float64Array(count),\n    force: new Float64Array(count),\n    radius: new Float64Array(count),\n    boxes,\n    // Fallbacks for the frame before the tabs have been measured. `span` replaces\n    // both the moment there is a real box to read.\n    base: Math.max(6, height * 0.32),\n    cy: height * 0.5,\n    rest: 1,\n    carry: 0,\n    clock: 0,\n    aim,\n    held: aim,\n    lead: 1,\n    snap: false,\n  };\n\n  // Start settled on the active tab. A pill that flies in from the origin on\n  // every resize is a component announcing its own implementation.\n  settle(state);\n  thicken(state);\n\n  return state;\n}\n\nfunction paint({ context, width, height, state }: SceneDrawContext<LiquidState>) {\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  state.carry += elapsed;\n  let steps = 0;\n  // 480 Hz is eight sub-steps per frame at 60 fps, so the cap has to clear sixteen\n  // or a display running at 30 would silently integrate the pill at half speed.\n  while (state.carry >= STEP && steps < 16) {\n    advance(state);\n    state.carry -= STEP;\n    steps += 1;\n  }\n  if (state.carry > STEP * 16) state.carry = 0;\n  if (state.snap) settle(state);\n  thicken(state);\n\n  context.clearRect(0, 0, width, height);\n  if (!state.boxes.current.length) return;\n\n  const { count, sx, radius, cy } = state;\n\n  /*\n   * The pill is the union of the chain's discs: one path of fifteen circles and\n   * one fill. Overlaps in a single colour are invisible, so the union needs no\n   * isosurface pass, and with the nodes spaced well inside their own width the\n   * scallop left on the silhouette is a fraction of a pixel.\n   */\n  const trace = (shrink: number) => {\n    context.beginPath();\n    for (let i = 0; i < count; i++) {\n      const r = Math.max(0.5, radius[i] - shrink);\n      // Enter each circle at its own start angle, or `arc` draws a chord in from\n      // wherever the last one finished and the union fills solid.\n      context.moveTo(sx[i] + r, cy);\n      context.arc(sx[i], cy, r, 0, Math.PI * 2);\n    }\n  };\n\n  // The rim, with its bloom, then the body laid back over it. Two fills of the\n  // same union a pixel and a half apart is the entire lit edge.\n  context.shadowColor = 'rgba(126,186,255,0.42)';\n  context.shadowBlur = 22;\n  trace(0);\n  context.fillStyle = 'rgba(152,205,255,0.7)';\n  context.fill();\n\n  context.shadowBlur = 0;\n  trace(1.5);\n  const body = context.createLinearGradient(0, cy - state.base, 0, cy + state.base);\n  body.addColorStop(0, 'rgba(33,56,92,0.96)');\n  body.addColorStop(1, 'rgba(13,22,40,0.96)');\n  context.fillStyle = body;\n  context.fill();\n}\n\n/** `compact` is the 298x240 catalogue card: the same bar and the same solver, handed\n *  the whole box, with the panel copy dropped. Presentation only — see `liquid-nav.css`. */\nexport type LiquidNavProps = { compact?: boolean };\n\nexport function LiquidNav({ compact = false }: LiquidNavProps) {\n  const [active, setActive] = useState(1);\n  /** The tab being pointed at, so the pill can lean at it before the click. */\n  const [hover, setHover] = useState(-1);\n  const list = useRef<HTMLDivElement | null>(null);\n  const boxes = useRef<TabBox[]>([]);\n  const reduced = useReducedMotion();\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<LiquidState>({\n    setup: (scene) => build(scene, boxes, active),\n    draw: (scene) => {\n      scene.state.aim = hover >= 0 ? hover : active;\n      scene.state.snap = reduced;\n      paint(scene);\n    },\n  });\n\n  // Selection has to repaint on its own account: with the loop stopped under\n  // reduced motion nothing else would, and the indicator would stay behind.\n  useEffect(() => {\n    requestRender();\n  }, [active, hover, requestRender]);\n\n  /*\n   * The indicator is told where the tabs are; it does not decide. Measuring the\n   * real boxes is what lets this sit under any label set, font and padding — and\n   * it is why the observer watches the buttons and not just the bar, since a font\n   * swap resizes them without moving the bar at all.\n   */\n  const measure = useCallback(() => {\n    const node = list.current;\n    if (!node) return;\n    const frame = node.getBoundingClientRect();\n    boxes.current = Array.from(node.children, (child) => {\n      const box = child.getBoundingClientRect();\n      return {\n        left: box.left - frame.left,\n        top: box.top - frame.top,\n        width: box.width,\n        height: box.height,\n      };\n    });\n    requestRender();\n  }, [requestRender]);\n\n  useEffect(() => {\n    const node = list.current;\n    if (!node) return;\n    measure();\n    const sizes = new ResizeObserver(measure);\n    sizes.observe(node);\n    for (const child of Array.from(node.children)) sizes.observe(child);\n    return () => sizes.disconnect();\n  }, [measure]);\n\n  // A tablist takes one tab stop and the arrows move within it, which is the part\n  // of a tab control that hand-rolled ones usually skip.\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const move = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;\n    if (!move) return;\n    event.preventDefault();\n    const next = (active + move + TABS.length) % TABS.length;\n    setActive(next);\n    (list.current?.children[next] as HTMLElement | undefined)?.focus();\n  };\n\n  return (\n    <div className=\"liquid-nav-stage\" data-compact={compact ? 'true' : undefined}>\n      <div className=\"liquid-nav-bar\">\n        <div ref={stageRef} className=\"liquid-nav-field\" aria-hidden=\"true\">\n          <canvas ref={canvasRef} />\n        </div>\n\n        <div\n          ref={list}\n          className=\"liquid-nav-tabs\"\n          role=\"tablist\"\n          aria-label=\"Sections\"\n          onKeyDown={onKeyDown}\n          onPointerLeave={() => setHover(-1)}\n        >\n          {/* The roving tab stop, except in a card: the frame there is aria-hidden,\n              and a focusable node inside one is a trap with no name. The buttons stay\n              clickable in both — only the tab order changes. */}\n          {TABS.map((tab, index) => (\n            <button\n              key={tab.label}\n              type=\"button\"\n              role=\"tab\"\n              id={`liquid-nav-tab-${index}`}\n              className=\"liquid-nav-tab\"\n              aria-selected={index === active}\n              aria-controls=\"liquid-nav-panel\"\n              tabIndex={compact ? -1 : index === active ? 0 : -1}\n              onClick={() => setActive(index)}\n              onPointerEnter={() => setHover(index)}\n              onFocus={() => setHover(index)}\n              onBlur={() => setHover(-1)}\n            >\n              {tab.label}\n            </button>\n          ))}\n        </div>\n      </div>\n\n      <p\n        className=\"liquid-nav-panel\"\n        id=\"liquid-nav-panel\"\n        role=\"tabpanel\"\n        aria-labelledby={`liquid-nav-tab-${active}`}\n      >\n        {TABS[active].body}\n      </p>\n\n      <p className=\"liquid-nav-hint\">Hover to lean, click to travel</p>\n    </div>\n  );\n}\n\nexport default LiquidNav;\n","type":"registry:ui"},{"path":"components/ui/liquid-nav.css","target":"components/ui/liquid-nav.css","content":".liquid-nav-stage {\n  position: relative;\n  display: grid;\n  place-content: center;\n  gap: 1.75rem;\n  width: 100%;\n  min-height: 260px;\n  padding: 2.5rem 1.5rem;\n  overflow: hidden;\n  border-radius: 0.75rem;\n  background: radial-gradient(120% 110% at 50% 0%, #101828 0%, #080c14 58%, #05070c 100%);\n  color: #eaf3ff;\n}\n\n.liquid-nav-bar {\n  position: relative;\n  justify-self: center;\n  border: 1px solid rgba(255, 255, 255, 0.08);\n  border-radius: 999px;\n  background: rgba(255, 255, 255, 0.03);\n}\n\n/* The indicator, behind the labels and deaf to the pointer. Every interaction\n   here belongs to a real button; the canvas only ever draws. */\n.liquid-nav-field {\n  position: absolute;\n  inset: 0;\n  pointer-events: none;\n}\n\n.liquid-nav-field canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/* Shares its origin with the field: the bar carries no padding of its own, so an\n   absolute `inset: 0` and this row start at the same pixel and the boxes measured\n   from here are already canvas coordinates. */\n.liquid-nav-tabs {\n  position: relative;\n  display: flex;\n  gap: 0.25rem;\n  padding: 0.5rem;\n}\n\n.liquid-nav-tab {\n  appearance: none;\n  margin: 0;\n  padding: 0.5rem 1.125rem;\n  border: 0;\n  border-radius: 999px;\n  background: none;\n  font: inherit;\n  font-size: 0.875rem;\n  font-weight: 500;\n  letter-spacing: -0.005em;\n  color: rgba(234, 243, 255, 0.52);\n  cursor: pointer;\n  transition: color 200ms ease;\n}\n\n.liquid-nav-tab:hover {\n  color: rgba(234, 243, 255, 0.82);\n}\n\n.liquid-nav-tab[aria-selected='true'] {\n  color: #f2f8ff;\n}\n\n.liquid-nav-tab:focus-visible {\n  outline: 2px solid rgba(152, 205, 255, 0.7);\n  outline-offset: 2px;\n}\n\n.liquid-nav-panel {\n  margin: 0;\n  justify-self: center;\n  max-width: 34rem;\n  font-size: 0.875rem;\n  line-height: 1.6;\n  text-align: center;\n  text-wrap: pretty;\n  color: rgba(234, 243, 255, 0.58);\n}\n\n.liquid-nav-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, 243, 255, 0.28);\n  pointer-events: none;\n}\n\n/*\n * With the loop stopped the chain never integrates, so the pill stays where\n * `setup` placed it — on the active tab, at rest, correct. Selecting a tab still\n * works and still moves the indicator, because a resize or a `requestRender` is\n * enough to repaint one settled frame; what is gone is the travel between them,\n * which is exactly what was asked for.\n */\n@media (prefers-reduced-motion: reduce) {\n  .liquid-nav-tab {\n    transition: none;\n  }\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled.\n * The bar was a content-width pill floating in the middle of a 260px stage, which in a\n * card is a great deal of gradient around a small control. Here it spans the frame and\n * the four tabs divide it evenly, so the pill is 61px long, 36px thick and travels 195px\n * end to end: the stretch and the neck are the subject rather than a detail.\n */\n.liquid-nav-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   * One deterministic column rather than a track sized to the labels, and a bottom\n   * padding that reserves the strip the hint sits in, so the bar reads as centred in\n   * the space above it.\n   */\n  grid-template-columns: minmax(0, 1fr);\n  gap: 0;\n  padding: 0.75rem 0.75rem 2.125rem;\n}\n\n/*\n * No `touch-action` line here, and none needed: the canvas layer is `pointer-events:\n * none` and the only pointer targets are four buttons, so this stage never becomes a\n * full-bleed drag surface that claims the vertical gesture. A touch on the card scrolls\n * the page as it should, and declaring `pan-y` would only cost the card its pinch-zoom.\n */\n\n/* Fills the column, so the travel is the card's width instead of the labels'. */\n.liquid-nav-stage[data-compact='true'] .liquid-nav-bar {\n  justify-self: stretch;\n}\n\n/*\n * The deeper inset is the bar's own chrome, not the tabs': it thickens the capsule\n * without touching a tab box — and the pill is drawn from the tab boxes — while buying\n * the pill's end caps a couple of pixels of clearance inside the bar's 999px ends.\n */\n.liquid-nav-stage[data-compact='true'] .liquid-nav-tabs {\n  padding: 0.75rem 0.5rem;\n}\n\n/*\n * Equal shares of the bar, and the size is set by the longest label rather than by taste.\n * Four `flex: 1 1 0` tabs split the 274px bar into 61px each; \"Changelog\" sets 61px of its\n * own at 12px, so at the 6px inline padding it has at full size it needed 73px and the bar's\n * `overflow: hidden` ate the descender. 11px brings the word to 56px and 2px of padding\n * leaves 3px of slack — measured after the webfont lands, not before, which is what made the\n * first pass at this look like it fitted.\n *\n * `line-height: 1` makes the tab 35px tall. The number that matters is not 35 but that half\n * of it stays under the 0.32 × width cap `span` insets the pill by — 17.5 against 19.5 — so\n * the settled pill still covers its own tab to the pixel and never reaches under a\n * neighbour's label. Nothing here is a `vw`: the card is 298px wide and the viewport is not.\n */\n.liquid-nav-stage[data-compact='true'] .liquid-nav-tab {\n  flex: 1 1 0;\n  min-width: 0;\n  padding: 0.75rem 0.0625rem;\n  font-size: 0.6875rem;\n  line-height: 1;\n  text-align: center;\n  white-space: nowrap;\n}\n\n/* Four lines of prose about the solver, in a box this size, is the card title's job. */\n.liquid-nav-stage[data-compact='true'] .liquid-nav-panel {\n  display: none;\n}\n\n/*\n * The one line of text kept: this pill is still until it is hovered or clicked, so the\n * instruction is the part worth the room. A strip along the bottom edge, clear of the bar\n * by the stage padding above, at the fixed 0.6875rem it was already set in. The\n * `pointer-events: none` on the rule above is left alone, so the strip never swallows a\n * press meant for the bar; the tabs go on taking their own clicks.\n */\n.liquid-nav-stage[data-compact='true'] .liquid-nav-hint {\n  inset: auto 0 0 0;\n  padding: 0 0.75rem 0.6875rem;\n  text-align: center;\n  color: rgba(234, 243, 255, 0.4);\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","springs","morph"],"docs":"https://ui.artbloom.tech/artbloom/animations/liquid-nav"}}