{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"relaxation-typing","type":"registry:ui","title":"Relaxation Typing","description":"A typing indicator whose three dots are coupled to each other rather than delayed behind each other. Drag it toward a faster typist and the ripple tightens into a near-simultaneous blink instead of merely running quicker, which a keyframe stagger cannot do at any duration.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/relaxation-typing.tsx","target":"components/ui/relaxation-typing.tsx","content":"'use client';\n\nimport './relaxation-typing.css';\n\nimport { 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 * THREE COUPLED RELAXATION OSCILLATORS — the neon-lamp kind, one lamp per dot.\n *\n * Each dot is a capacitor charging through the cadence resistor toward the supply:\n *\n *     dV/dt = (VS - V) / (R·C)        →    V(t) = VS - (VS - V0)·exp(-t / R·C)\n *\n * At V_FIRE the lamp breaks down and a much smaller resistance appears across the same\n * capacitor, so the same first-order equation runs again toward a far lower target:\n *\n *     dV/dt = (VS - V)/(R·C) + (V_EXT + u - V)/(R_ON·C)\n *\n * and it keeps conducting until the arc current (V - V_EXT)/R_ON falls below I_MAINT. That\n * is the entire mechanism: one linear equation whose coefficients switch on a threshold,\n * with hysteresis between the strike and the drop-out. Both legs are stepped with the exact\n * solution of dV/dt = (V∞ - V)/τ over a fixed 1/240 s, which is what survives the 25:1\n * stiffness ratio between charging and dumping with no stability guard anywhere.\n *\n * The three are not three copies of one thing. Each lamp's arc current returns through a\n * shared cathode resistor wired into the *next* stage's return, lifting it by u = R_K·I.\n * That lift both pushes the follower's capacitor voltage down and raises the potential it\n * has to climb to, so a conducting stage holds the stage downstream of it off until its own\n * arc drops out. The dots are phase-coupled, not delayed.\n *\n * NOT a sine wave with a phase offset. NOT three eased keyframes on a stagger. NOT a spring\n * pulled toward a rounded target. NOT a gradient sweep. There is no duration in this file.\n *\n * THE CONSEQUENCE YOU ONLY GET BY SOLVING IT: the period is R·C·ln((VS-V_OFF)/(VS-V_FIRE))\n * plus the dump, and the cascade lag is the dump alone, set by R_ON·C — two different\n * products, and the cadence control only touches R. Dragging it stretches the period from\n * 229 ms to 583 ms while the gap from the first dot to the third only drifts 89 ms to 76 ms,\n * so the wave does not merely slow down: the spread sweeps from 39% of a cycle to 13% and the\n * ripple visibly tightens into a near-simultaneous blink. A keyframe stagger is always a\n * fraction of its duration and cannot do that. And every rise is an exponential against a\n * vertical collapse — 4.6:1 asymmetric at the fast end, 17:1 at the slow end — which is not a\n * shape any ease-in-out produces at any control points.\n */\n\n/** Lamps in the chain. Three, because a typing indicator has three dots. */\nconst STAGES = 3;\n/** Supply, volts. 15 V of headroom over the strike keeps the climb steep when it arrives. */\nconst V_SUPPLY = 105;\n/** Breakdown voltage, volts. Nothing conducts below it; this is the upper switch. */\nconst V_FIRE = 90;\n/** Arc voltage the capacitor dumps toward, volts. 28 V under the strike. */\nconst V_EXT = 62;\n/** Conducting resistance, MΩ. 26 kΩ against 220–660 kΩ charging: the 25:1 that makes the\n *  fall look vertical next to the rise while both are the same exponential. */\nconst R_ON = 0.026;\n/** Maintaining current, µA. Below it the arc cannot sustain itself and the lamp opens. */\nconst I_MAINT = 350;\n/** Drop-out voltage: V_EXT + R_ON·I_MAINT = 71.1 V. The floor of every cycle, and with\n *  V_FIRE the 18.9 V swing that one dot's brightness is mapped from. */\nconst V_OFF = V_EXT + R_ON * I_MAINT;\n/** Shared cathode resistor, MΩ. 4 kΩ lifts a follower 4.4 V at peak arc current, the least\n *  that gates it completely: at 2 kΩ the gate leaks and the spread collapses to 17%, and at\n *  0 the chain is three unrelated lamps whose dots scatter to 85% of a cycle apart. */\nconst R_K = 0.004;\n/** Capacitances, µF, falling along the chain so every follower is intrinsically the faster\n *  lamp and is always pressing against the gate ahead of it rather than trailing it. */\nconst CAPS = [1, 0.97, 0.94];\n/** Cadence resistance at the slow end of the pot, MΩ, and at the fast end. The 3:1 span is\n *  the whole travel: 583 ms down to 229 ms per cycle. */\nconst R_SLOW = 0.66;\nconst R_FAST = 0.22;\n/** Keystrokes a minute reported at R_SLOW — 12 wpm, a thumb on a phone. */\nconst KEYS_SLOW = 60;\n/** Pot span. The law is logarithmic, so equal travel is an equal *ratio* of charging\n *  current and the reported cadence stays exactly proportional to VS/R. */\nconst KEYS_SPAN = R_SLOW / R_FAST;\n/** Solver step, seconds: four per display frame, and a sixth of the dump's 24 ms τ. */\nconst STEP = 1 / 240;\n/** Substep ceiling. 12 × STEP is 50 ms, the same window the frame delta is clamped to. */\nconst MAX_STEPS = 12;\n/** Seconds run before the first paint. The chain needs seven of them to converge at the\n *  slowest cadence — only twelve cycles. */\nconst SETTLE = 8;\n/** Runaway bound on settle's stop-on-strike search, in solver steps: comfortably past one\n *  cycle at the slowest cadence, so only the strike itself ever ends that loop. */\nconst STRIKE_STEPS = 160;\n/** Where the pot starts: mid-travel, 105 keys/min, a 23% spread with all three dots\n *  separately legible and the asymmetry already obvious. */\nconst START = 0.5;\n/** One arrow press, as a fraction of travel — 24 notches from end to end. */\nconst KEY_STEP = 1 / 24;\n/** Card side padding in CSS px, matching the face's 1.375rem. */\nconst INSET = 22;\n/** Avatar box, CSS px (1.75rem), and the gap either side of the pill (0.625rem). */\nconst AVATAR = 28;\nconst GAP = 10;\n/** The typing pill: 60 × 24 px with the three dots on a 16 px pitch about its centre. */\nconst PILL_W = 60;\nconst PILL_H = 24;\nconst DOT_PITCH = 16;\nconst DOT_R = 3.4;\n/** How far a dot rides up between drop-out and strike, CSS px. Small on purpose: the read\n *  is meant to come from the brightness, with the lift only confirming it. */\nconst LIFT = 5;\n/** Cadence track, CSS px up from the bottom of the card. */\nconst TRACK_Y = 15;\n/** Near-white, and the one accent. Everything else is one of these under an alpha. */\nconst INK = '234, 243, 255';\nconst ACCENT = '158, 205, 255';\nconst TAU = Math.PI * 2;\n\n/** Everything the chain owns. Geometry is `readonly` because only `setup` may write it. */\ninterface RelaxationState {\n  /** Centre line of the indicator row, CSS px. */\n  readonly rowY: number;\n  /** Left edge of the pill, CSS px. */\n  readonly pillX: number;\n  readonly trackX: number;\n  readonly trackW: number;\n  readonly trackY: number;\n  /** Capacitor-side node voltage of each lamp. */\n  readonly node: Float64Array;\n  /** Arc current of each lamp from the step just taken, µA. */\n  readonly amps: Float64Array;\n  /** Cathode lift each lamp is sitting on, volts. */\n  readonly lift: Float64Array;\n  /** Which lamps are conducting. */\n  readonly lit: boolean[];\n  /** Simulated time of each lamp's most recent strike, seconds. */\n  readonly struck: Float64Array;\n  /** Simulated seconds since the scene was built. */\n  sim: number;\n  /** `performance.now()` at the last paint, and the unspent remainder of the frame delta. */\n  clock: number;\n  carry: number;\n  /** Pot travel, 0 slow to 1 fast. */\n  drive: number;\n  /** True while a press owns the pot, so a drag survives leaving the card. */\n  dragging: boolean;\n  /** The travel the frozen picture was solved for. NaN forces a re-solve. */\n  frozen: number;\n  snap: boolean;\n  postedX: number;\n  postedY: number;\n}\n\nfunction clamp01(value: number): number {\n  return value < 0 ? 0 : value > 1 ? 1 : value;\n}\n\n/** The pot's law, and the cadence it is calibrated in. Both geometric in the travel, which\n *  is what keeps the reported keystroke rate proportional to the charging current. */\nfunction ohmsFor(drive: number): number {\n  return R_SLOW / KEYS_SPAN ** drive;\n}\n\nfunction keysFor(drive: number): number {\n  return KEYS_SLOW * KEYS_SPAN ** drive;\n}\n\n/**\n * One fixed step of the whole chain. Arc currents and cathode lifts first, from the voltages\n * as they stand; then every capacitor is moved by the exact solution of its own linear leg;\n * then the two thresholds are tested. Nothing here is scaled by a frame time.\n */\nfunction advance(state: RelaxationState, ohms: number): void {\n  const { node, amps, lift, lit, struck } = state;\n\n  for (let i = 0; i < STAGES; i += 1) {\n    amps[i] = lit[i] ? Math.max(0, (node[i] - lift[i] - V_EXT) / R_ON) : 0;\n  }\n  // The cathode resistor stores nothing, so its drop is algebraic rather than integrated:\n  // stage i rides on the arc current of stage i-1, and the master rides on nothing.\n  lift[0] = 0;\n  for (let i = 1; i < STAGES; i += 1) lift[i] = R_K * amps[i - 1];\n\n  for (let i = 0; i < STAGES; i += 1) {\n    let rate = 1 / (ohms * CAPS[i]);\n    let flow = V_SUPPLY / (ohms * CAPS[i]);\n    if (lit[i]) {\n      rate += 1 / (R_ON * CAPS[i]);\n      flow += (V_EXT + lift[i]) / (R_ON * CAPS[i]);\n    }\n    // τ = 1/Σ(1/RC) and V∞ = τ·Σ(V/RC): the two legs in parallel, solved over the step\n    // instead of differenced across it. A forward difference would need h under 50 µs to\n    // stay bounded while a lamp conducts, which is twelve times this step.\n    const tau = 1 / rate;\n    const rest = flow * tau;\n    node[i] = rest + (node[i] - rest) * Math.exp(-STEP / tau);\n  }\n\n  for (let i = 0; i < STAGES; i += 1) {\n    const across = node[i] - lift[i];\n    if (lit[i]) {\n      if ((across - V_EXT) / R_ON < I_MAINT) lit[i] = false;\n      continue;\n    }\n    if (across < V_FIRE) continue;\n    lit[i] = true;\n    struck[i] = state.sim;\n  }\n  state.sim += STEP;\n}\n\n/**\n * Run the chain until it has locked, then on to the master's next strike. Stopping on a\n * strike is what makes the frozen reduced-motion picture the same picture every time instead\n * of whichever phase the clock happened to land on.\n */\nfunction settle(state: RelaxationState, ohms: number): void {\n  const steps = Math.round(SETTLE / STEP);\n  for (let k = 0; k < steps; k += 1) advance(state, ohms);\n  const mark = state.struck[0];\n  for (let k = 0; k < STRIKE_STEPS && state.struck[0] === mark; k += 1) advance(state, ohms);\n  state.frozen = state.drive;\n}\n\nfunction build(\n  { width, height }: SceneSetupContext,\n  drive: number,\n  snap: boolean,\n): RelaxationState {\n  const state: RelaxationState = {\n    rowY: height - 64,\n    pillX: INSET + AVATAR + GAP,\n    trackX: INSET,\n    trackW: Math.max(40, width - INSET * 2),\n    trackY: height - TRACK_Y,\n    // Staggered start voltages so the chain has somewhere to lock *from*. Where it ends up is\n    // the coupling's business, not this line's: with R_K at 0 and the capacitors detuned the\n    // three phases just drift past each other forever, which is how the gate was measured.\n    node: Float64Array.from([92, 86, 80]),\n    amps: new Float64Array(STAGES),\n    lift: new Float64Array(STAGES),\n    lit: [false, false, false],\n    struck: new Float64Array(STAGES),\n    sim: 0,\n    clock: 0,\n    carry: 0,\n    drive,\n    dragging: false,\n    frozen: Number.NaN,\n    snap,\n    postedX: Number.NaN,\n    postedY: Number.NaN,\n  };\n  settle(state, ohmsFor(drive));\n  return state;\n}\n\n/**\n * The pill, the three dots, and the pot's rail. Every dot's brightness, radius and lift come\n * off the same capacitor voltage — there is no second animation of the dots anywhere.\n */\nfunction paint({ context, width, height, state }: SceneDrawContext<RelaxationState>): void {\n  context.clearRect(0, 0, width, height);\n\n  // The pill the dots sit in. The tint, and nothing else, carries the accent here.\n  const pillY = state.rowY - PILL_H / 2;\n  const radius = PILL_H / 2;\n  context.beginPath();\n  context.moveTo(state.pillX + radius, pillY);\n  context.arcTo(state.pillX + PILL_W, pillY, state.pillX + PILL_W, pillY + PILL_H, radius);\n  context.arcTo(state.pillX + PILL_W, pillY + PILL_H, state.pillX, pillY + PILL_H, radius);\n  context.arcTo(state.pillX, pillY + PILL_H, state.pillX, pillY, radius);\n  context.arcTo(state.pillX, pillY, state.pillX + PILL_W, pillY, radius);\n  context.closePath();\n  context.fillStyle = `rgba(${ACCENT}, 0.07)`;\n  context.fill();\n  context.strokeStyle = `rgba(${INK}, 0.1)`;\n  context.lineWidth = 1;\n  context.stroke();\n\n  for (let i = 0; i < STAGES; i += 1) {\n    const across = state.node[i] - state.lift[i];\n    const charge = clamp01((across - V_OFF) / (V_FIRE - V_OFF));\n    const x = state.pillX + PILL_W / 2 + (i - 1) * DOT_PITCH;\n    // Half the lift each way, so the swing is centred in the pill rather than hanging off\n    // the top of it — a discharged dot sits low, a charged one sits high, neither is off axis.\n    const y = state.rowY + LIFT / 2 - charge * LIFT;\n    if (state.lit[i]) {\n      // The strike itself: a bloom that exists only while the arc is drawing current.\n      context.beginPath();\n      context.arc(x, y, DOT_R * 2.7, 0, TAU);\n      context.fillStyle = `rgba(${ACCENT}, 0.1)`;\n      context.fill();\n    }\n    context.beginPath();\n    context.arc(x, y, DOT_R * (0.8 + 0.28 * charge), 0, TAU);\n    context.fillStyle = `rgba(${ACCENT}, ${0.3 + 0.62 * charge})`;\n    context.fill();\n  }\n\n  // The pot. Six ticks, then the rail, then the travelled part in the accent so the knob\n  // the DOM places on top reads as a level rather than a loose plate.\n  context.strokeStyle = `rgba(${INK}, 0.12)`;\n  context.lineWidth = 1;\n  for (let k = 0; k <= 6; k += 1) {\n    const x = Math.round(state.trackX + (k / 6) * state.trackW) + 0.5;\n    context.beginPath();\n    context.moveTo(x, state.trackY - 4);\n    context.lineTo(x, state.trackY - 8);\n    context.stroke();\n  }\n  context.beginPath();\n  context.moveTo(state.trackX, state.trackY);\n  context.lineTo(state.trackX + state.trackW, state.trackY);\n  context.strokeStyle = `rgba(${INK}, 0.14)`;\n  context.stroke();\n  context.beginPath();\n  context.moveTo(state.trackX, state.trackY);\n  context.lineTo(state.trackX + state.drive * state.trackW, state.trackY);\n  context.strokeStyle = `rgba(${ACCENT}, 0.5)`;\n  context.lineWidth = 2;\n  context.stroke();\n}\n\n/**\n * The knob is a real DOM `role=\"slider\"`, so it has to be moved to the pixel the canvas drew\n * the rail's travel to. Writing it from the solver's own geometry is what keeps the two from\n * drifting; nothing in the stylesheet knows where the track is.\n */\nfunction place(node: HTMLElement | null, state: RelaxationState): void {\n  if (!node) return;\n  const x = state.trackX + state.drive * state.trackW;\n  const y = state.trackY;\n  if (Math.abs(x - state.postedX) < 0.4 && Math.abs(y - state.postedY) < 0.4) return;\n  // The knob has no position until the solver has been asked for one, so it starts\n  // transparent rather than in the corner. Opacity and not `visibility`, which would take\n  // the slider out of the accessibility tree for as long as it took to place it.\n  if (Number.isNaN(state.postedX)) node.style.opacity = '1';\n  state.postedX = x;\n  state.postedY = y;\n  node.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) translate(-50%, -50%)`;\n}\n\n/**\n * A support thread whose typing indicator is the oscillator chain.\n *\n * The canvas layer takes every pointer event, so the messages, the indicator row and the\n * knob all sit over it in sibling layers that are transparent to the pointer. The knob is\n * still the real slider: a press anywhere on the card hands it focus, so the keyboard reaches\n * the same cadence control the mouse is dragging.\n */\n/** `compact` is the 298x240 catalogue card: the same chain, the same three lamps and the same\n *  cadence pot, with the hint below the card dropped and the transcript tightened. Not one of\n *  the numbers the canvas and the stylesheet share is touched — the indicator row's 2.75rem\n *  offset and 2.5rem height are what put its centre on `height − 64`, and the 1.375rem side\n *  inset is `INSET`. See `relaxation-typing.css`. */\nexport type RelaxationTypingProps = { compact?: boolean };\n\nexport function RelaxationTyping({ compact = false }: RelaxationTypingProps) {\n  const reduced = useReducedMotion();\n  const [readout, setReadout] = useState(() => ({\n    keys: Math.round(keysFor(START) / 5) * 5,\n  }));\n  /** What the labels are showing, so `draw` only touches React when a number changes. */\n  const shownRef = useRef(readout);\n  /** The pot position the solver is holding, read by `setup` and by the key handler. */\n  const driveRef = useRef(START);\n  const knobRef = useRef<HTMLDivElement>(null);\n  /**\n   * A keypress leaves the travel it wants here for the next frame to take, rather than\n   * reaching into the solver from an event handler: the pot has to move between substeps or\n   * the chain integrates half a step against the old resistance.\n   */\n  const pendingRef = useRef<number | null>(null);\n\n  const draw = (scene: SceneDrawContext<RelaxationState>) => {\n    const { state, pointer } = scene;\n    state.snap = reduced;\n\n    // The latch is what keeps a drag alive once the pointer has been thrown past the edge of\n    // the card: the hook holds the capture, but `inside` goes false at the boundary.\n    if (!pointer.down) state.dragging = false;\n    else if (pointer.inside) state.dragging = true;\n    if (state.dragging) state.drive = clamp01((pointer.x - state.trackX) / state.trackW);\n\n    const pending = pendingRef.current;\n    if (pending !== null) {\n      pendingRef.current = null;\n      state.drive = clamp01(pending);\n    }\n\n    const ohms = ohmsFor(state.drive);\n    if (state.snap) {\n      // The loop is stopped, so an accumulator advanced once per repaint would never arrive.\n      // Re-solve to the locked answer for the pot's new position instead — the same chain,\n      // run until it settles, which is the only honest still frame of an oscillator. Drag and\n      // the arrow keys both still change the value and both still redraw.\n      if (state.frozen !== state.drive) settle(state, ohms);\n      state.clock = 0;\n      state.carry = 0;\n    } else {\n      const now = performance.now();\n      const elapsed = state.clock ? Math.min(0.05, (now - state.clock) / 1000) : STEP;\n      state.clock = now;\n      state.carry += elapsed;\n      const count = Math.min(MAX_STEPS, Math.floor(state.carry / STEP));\n      for (let k = 0; k < count; k += 1) advance(state, ohms);\n      if (count > 0) state.carry -= count * STEP;\n      // A tab left in the background for a minute comes back owing far more than the ceiling\n      // can pay; dropping the debt is better than a burst of stale steps.\n      if (state.carry > STEP * MAX_STEPS) state.carry = 0;\n      state.frozen = Number.NaN;\n    }\n\n    paint(scene);\n    place(knobRef.current, state);\n    driveRef.current = state.drive;\n\n    const next = { keys: Math.round(keysFor(state.drive) / 5) * 5 };\n    if (next.keys !== shownRef.current.keys) {\n      shownRef.current = next;\n      setReadout(next);\n    }\n  };\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<RelaxationState>({\n    setup: (scene) => build(scene, driveRef.current, reduced),\n    draw,\n  });\n\n  // The labels are React and the lamps are not, so a change of reading — or of the motion\n  // preference, which stops the loop outright — has to ask for the one repaint that keeps the\n  // canvas showing the same cadence the text does.\n  useEffect(() => {\n    requestRender();\n  }, [readout, reduced, requestRender]);\n\n  /** Arrows step the pot by a notch, Home and End take it to the ends of its travel. */\n  const handleKey = (event: KeyboardEvent<HTMLDivElement>) => {\n    const drive = driveRef.current;\n    if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {\n      pendingRef.current = drive + KEY_STEP;\n    } else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {\n      pendingRef.current = drive - KEY_STEP;\n    } else if (event.key === 'End') {\n      pendingRef.current = 1;\n    } else if (event.key === 'Home') {\n      pendingRef.current = 0;\n    } else {\n      return;\n    }\n    event.preventDefault();\n    requestRender();\n  };\n\n  return (\n    <div\n      className=\"relaxation-typing-stage\"\n      data-compact={compact ? 'true' : undefined}\n      /* The knob is transparent to the pointer so the canvas keeps the press and the capture\n         with it; without this the pot could only ever be reached by Tab. In a card there is\n         nothing to hand focus to — the whole frame is aria-hidden — so the press is left to\n         the canvas alone. */\n      onPointerDown={\n        compact ? undefined : () => knobRef.current?.focus({ preventScroll: true })\n      }\n    >\n      <div\n        className=\"relaxation-typing-card\"\n        role=\"group\"\n        aria-label=\"Support thread with Priya Raman\"\n      >\n        <div ref={stageRef} className=\"relaxation-typing-well\" aria-hidden=\"true\">\n          <canvas ref={canvasRef} />\n        </div>\n\n        <div className=\"relaxation-typing-face\">\n          <div className=\"relaxation-typing-head\">\n            <span className=\"relaxation-typing-label\">Thread 4821</span>\n            <span className=\"relaxation-typing-label\">{readout.keys} keys/min</span>\n          </div>\n\n          <ol className=\"relaxation-typing-log\">\n            <li className=\"relaxation-typing-note\">\n              <span className=\"relaxation-typing-who\">Priya</span>\n              <p className=\"relaxation-typing-said\">\n                The invoice still shows last month&rsquo;s plan.\n              </p>\n            </li>\n            <li className=\"relaxation-typing-note relaxation-typing-note-mine\">\n              <span className=\"relaxation-typing-who\">You</span>\n              <p className=\"relaxation-typing-said\">\n                Checking now — your card was charged on the 3rd.\n              </p>\n            </li>\n          </ol>\n        </div>\n\n        {/* Absolutely placed, because the canvas paints the three dots into the gap this row\n            reserves and both have to agree on one number: the row's centre line. */}\n        <p className=\"relaxation-typing-live\">\n          <span className=\"relaxation-typing-avatar\" aria-hidden=\"true\">\n            PR\n          </span>\n          <span className=\"relaxation-typing-dots\" aria-hidden=\"true\" />\n          Priya is typing\n        </p>\n\n        <div\n          ref={knobRef}\n          className=\"relaxation-typing-knob\"\n          role=\"slider\"\n          tabIndex={compact ? -1 : 0}\n          aria-label=\"Incoming keystroke cadence\"\n          aria-valuemin={KEYS_SLOW}\n          aria-valuemax={Math.round(KEYS_SLOW * KEYS_SPAN)}\n          aria-valuenow={readout.keys}\n          aria-valuetext={`${readout.keys} keystrokes a minute`}\n          onKeyDown={handleKey}\n        />\n      </div>\n\n      <p className=\"relaxation-typing-hint\">\n        <span>Drag for a faster typist</span>\n      </p>\n    </div>\n  );\n}\n\nexport default RelaxationTyping;\n","type":"registry:ui"},{"path":"components/ui/relaxation-typing.css","target":"components/ui/relaxation-typing.css","content":"/*\n * Two colours and a tint: near-white ink, one accent, and that accent under a tenth of an\n * alpha for the outgoing bubble, the avatar and the pill the dots live in. Nothing here\n * animates — every moving pixel is painted by the solver in the .tsx, and the one geometric\n * number this file shares with it is the indicator row's centre line, which the canvas\n * derives as `height - 64` from the row's own `bottom` and `height`.\n */\n\n.relaxation-typing-stage {\n  position: relative;\n  display: grid;\n  align-content: center;\n  justify-items: center;\n  gap: 0.75rem;\n  width: 100%;\n  min-height: 22rem;\n  padding: 2rem 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: #eaf3ff;\n}\n\n.relaxation-typing-card {\n  position: relative;\n  width: min(23rem, 100%);\n  height: 16.5rem;\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 canvas layer. It owns the pointer, which is why every layer above it is inert. */\n.relaxation-typing-well {\n  position: absolute;\n  inset: 0;\n  touch-action: none;\n}\n\n.relaxation-typing-well canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n/* Transparent to the pointer, so a press over the transcript still takes the cadence pot.\n   The bottom padding is the control's room: the indicator row and the pot both live inside\n   it and nothing in the flow may reach them. */\n.relaxation-typing-face {\n  position: relative;\n  display: flex;\n  height: 100%;\n  flex-direction: column;\n  padding: 1.25rem 1.375rem 5.25rem;\n  pointer-events: none;\n}\n\n.relaxation-typing-head {\n  display: flex;\n  justify-content: space-between;\n  gap: 0.75rem;\n  margin-bottom: 0.75rem;\n}\n\n.relaxation-typing-label {\n  font: 500 0.625rem/1 ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n  letter-spacing: 0.14em;\n  text-transform: uppercase;\n  color: rgba(234, 243, 255, 0.5);\n  white-space: nowrap;\n}\n\n/* `justify-content: flex-end` plus `overflow: hidden` is what makes this a transcript: if the\n   messages ever outgrow the space — three lines a bubble on a narrow phone — the oldest one\n   clips off the top rather than pushing the indicator row down onto the pot. */\n.relaxation-typing-log {\n  display: flex;\n  flex: 1;\n  min-height: 0;\n  flex-direction: column;\n  justify-content: flex-end;\n  gap: 0.5rem;\n  margin: 0;\n  padding: 0;\n  overflow: hidden;\n  list-style: none;\n}\n\n.relaxation-typing-note {\n  display: flex;\n  flex-direction: column;\n  align-items: flex-start;\n  gap: 0.25rem;\n}\n.relaxation-typing-note-mine {\n  align-items: flex-end;\n}\n\n.relaxation-typing-who {\n  font: 500 0.625rem/1 ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n  letter-spacing: 0.12em;\n  text-transform: uppercase;\n  color: rgba(234, 243, 255, 0.34);\n}\n\n.relaxation-typing-said {\n  max-width: 88%;\n  margin: 0;\n  padding: 0.5rem 0.6875rem;\n  border: 1px solid rgba(255, 255, 255, 0.07);\n  border-radius: 0.625rem 0.625rem 0.625rem 0.1875rem;\n  background: rgba(255, 255, 255, 0.04);\n  font-size: 0.78125rem;\n  line-height: 1.4;\n  color: rgba(234, 243, 255, 0.78);\n}\n\n/* The tint, and the only place the accent carries a whole surface. */\n.relaxation-typing-note-mine .relaxation-typing-said {\n  border-color: rgba(158, 205, 255, 0.2);\n  border-radius: 0.625rem 0.625rem 0.1875rem;\n  background: rgba(158, 205, 255, 0.09);\n  color: rgba(234, 243, 255, 0.9);\n}\n\n/* The indicator row: 2.5rem tall, 2.75rem up, so its centre sits 4rem — 64px — above the\n   bottom of the card, which is the line the canvas draws the pill and the dots on. */\n.relaxation-typing-live {\n  position: absolute;\n  right: 1.375rem;\n  bottom: 2.75rem;\n  left: 1.375rem;\n  display: flex;\n  height: 2.5rem;\n  align-items: center;\n  gap: 0.625rem;\n  margin: 0;\n  font-size: 0.78125rem;\n  color: rgba(234, 243, 255, 0.62);\n  pointer-events: none;\n}\n\n.relaxation-typing-avatar {\n  display: grid;\n  flex: none;\n  place-items: center;\n  width: 1.75rem;\n  height: 1.75rem;\n  border: 1px solid rgba(158, 205, 255, 0.28);\n  border-radius: 50%;\n  background: rgba(158, 205, 255, 0.09);\n  font: 600 0.625rem/1 ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n  letter-spacing: 0.06em;\n  color: rgba(234, 243, 255, 0.82);\n}\n\n/* Reserves the 3.75rem the canvas paints the pill into. Deliberately empty: the dots are the\n   three lamps, and CSS has no business drawing them. */\n.relaxation-typing-dots {\n  flex: none;\n  width: 3.75rem;\n  height: 1.5rem;\n}\n\n/*\n * The pot's knob. Its `transform` is written by the solver every frame from the same track\n * geometry the canvas drew the rail with, so the only thing CSS owns here is the second\n * translate that centres it on the point it is handed. `pointer-events: none` is what keeps\n * the press on the canvas, where the capture is; focus still lands here, so Tab and the arrow\n * keys reach the real slider.\n */\n.relaxation-typing-knob {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 1.75rem;\n  height: 1.125rem;\n  border: 1px solid rgba(158, 205, 255, 0.45);\n  border-radius: 0.3125rem;\n  background: linear-gradient(180deg, rgba(158, 205, 255, 0.22), rgba(8, 14, 22, 0.74));\n  box-shadow:\n    0 2px 10px rgba(4, 9, 16, 0.6),\n    inset 0 1px 0 rgba(234, 243, 255, 0.18);\n  pointer-events: none;\n  opacity: 0;\n  will-change: transform;\n  transition: border-color 160ms ease;\n}\n\n/* Knurl, so the plate reads as something a hand holds rather than a rounded rectangle. */\n.relaxation-typing-knob::after {\n  content: \"\";\n  position: absolute;\n  inset: 0.3125rem 0.625rem;\n  border-left: 1px solid rgba(234, 243, 255, 0.26);\n  border-right: 1px solid rgba(234, 243, 255, 0.26);\n}\n\n.relaxation-typing-knob:focus-visible {\n  border-color: rgba(158, 205, 255, 0.8);\n  outline: 2px solid rgba(158, 205, 255, 0.75);\n  outline-offset: 3px;\n}\n\n/* Outside the card, in the stage's second grid row, so it stays the card's width at any\n   viewport instead of spreading to the edges of a 1340px stage. */\n.relaxation-typing-hint {\n  display: flex;\n  width: min(23rem, 100%);\n  justify-content: center;\n  margin: 0;\n  font: 500 0.625rem/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 is solved to its locked state and held there, so the dots\n * still sit at the phases the coupling put them in — what is gone is the sweep. Dragging the\n * pot and the arrow keys both still re-solve it, which is why the dots still rearrange under\n * this rule.\n */\n@media (prefers-reduced-motion: reduce) {\n  .relaxation-typing-knob {\n    transition: none;\n  }\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled.\n *\n * Three numbers in this file are shared with the canvas and none of them appears below.\n * `.relaxation-typing-live`'s `bottom: 2.75rem` and `height: 2.5rem` put its centre 4rem up,\n * which is the `height − 64` the pill and the dots are drawn on; its `1.375rem` side inset is\n * `INSET`, off which the pill's left edge and the pot's rail are measured. Move any of them\n * here and the dots leave the gap the row reserves for them. So the bottom padding stays at\n * 5.25rem too — it is that row's room — and everything below is the top half of the card.\n */\n.relaxation-typing-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  /* Centring leaves the row and the column auto-sized, and a track sized from its content is\n     what the card's `height: 100%` and `width: 100%` would then resolve against. Stretched,\n     the single track is the frame and the card is the track. */\n  align-content: stretch;\n  justify-items: stretch;\n  gap: 0;\n  padding: 0.625rem;\n  /* The card frame rounds and clips already. */\n  border-radius: 0;\n}\n\n.relaxation-typing-stage[data-compact='true'] .relaxation-typing-card {\n  width: 100%;\n  height: 100%;\n}\n\n/* A full-bleed drag surface that claims every touch traps the page inside a scrolling grid.\n   `pan-y` hands the vertical gesture back to the document; the horizontal drag the pot is\n   about still arrives. */\n.relaxation-typing-stage[data-compact='true'] .relaxation-typing-well {\n  touch-action: pan-y;\n}\n\n/* 12px of head room rather than 20, and the sides and the floor left alone. */\n.relaxation-typing-stage[data-compact='true'] .relaxation-typing-face {\n  padding-top: 0.75rem;\n}\n\n.relaxation-typing-stage[data-compact='true'] .relaxation-typing-head {\n  margin-bottom: 0.5rem;\n}\n\n/* Two bubbles have 106px between the head and the indicator row here. At the full size they\n   would want 104 of it, which the transcript would survive — it clips from the top by design\n   — but only by losing a name. A smaller bubble is the better trade. */\n.relaxation-typing-stage[data-compact='true'] .relaxation-typing-said {\n  padding: 0.4375rem 0.625rem;\n  font-size: 0.75rem;\n}\n\n/* It is outside the card, in the stage's second row, and the card's own title says it. */\n.relaxation-typing-stage[data-compact='true'] .relaxation-typing-hint {\n  display: none;\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","infinite","micro"],"docs":"https://ui.artbloom.tech/artbloom/animations/relaxation-typing"}}