{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"euler-disk-spinner","type":"registry:ui","title":"Euler Disk Spinner","description":"A loading state that spends energy instead of looping. The coin's tilt carries the remaining work and its rattle rises as the disk lies down, the way a real Euler disk finishes — audibly close before it stops.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/euler-disk-spinner.tsx","target":"components/ui/euler-disk-spinner.tsx","content":"'use client';\n\nimport './euler-disk-spinner.css';\n\nimport { useEffect, useId, useRef } from 'react';\n\nimport { useCanvasScene, useReducedMotion, type SceneDrawContext, type SceneSetupContext } from '@/hooks/use-canvas-scene';\n\n/**\n * A determinate loader whose spinner is Euler's disk: a coin rolling on its rim, rattling faster\n * as it dies, then lying flat and dead the moment the job is done.\n *\n * Rolling without slipping locks the precession rate to the inclination, Omega^2 = (4g/3R)/sin a,\n * so Omega DIVERGES like a^-1/2 while the energy E = MgR*sin a drains away. Two sinks drain it:\n * air squeezed out of the closing wedge, P ~ Omega^2/sin a (Moffatt), and rolling friction at the\n * contact, P ~ Omega. Dividing by dE/da = MgR*cos a gives the equation this file integrates,\n *\n *     da/dt = -(C_v * Omega^2 / sin a + C_r * Omega) / cos a,\n *\n * whose viscous term alone makes a^3 fall linearly in time: a reaches zero at a finite instant\n * with the rate still climbing. That is why this beats a rotating arc — the ending is a real\n * singularity, not a fade-out.\n *\n * a and Omega are separate states and Omega is capped at the step limit. Solve Omega from the\n * constraint alone and the last frames turn over more than a lap each: the contact point aliases\n * into a jitter and the coin tears off the plate one frame before it should be flat.\n */\n\nconst TAU = Math.PI * 2;\nconst STEP = 1 / 240;              // the rattle clears 7 Hz before it dies; 120 Hz aliases the tail\nconst MAX_SUBSTEPS = 8;\nconst ALPHA_START = 0.6;           // rad of inclination at full energy: 34 degrees, a 2.5 s run\nconst SIN_START = Math.sin(ALPHA_START);\nconst ALPHA_FLOOR = 0.0035;        // the drain divides by sin(alpha), so it never sees zero\nconst ALPHA_STOP = 0.006;          // the singularity has arrived: lie flat and stop dead\nconst GRAV_COUPLE = 26;            // 4g/3R in stage units: 1.1 Hz at full tilt, 7.3 Hz at the cap\nconst OMEGA_CAP = 46;              // rad/s, i.e. 0.19 rad per substep — the last frames climb toward this\nconst VISC_DRAIN = 1.0e-4;         // air in the closing wedge: only wins below 5 degrees, as Moffatt has it\nconst ROLL_DRAIN = 0.018;          // rolling friction, the sink that carries the first two seconds\nconst CONSTRAINT_LAG = 120;        // contact friction pulling Omega onto the rolling constraint: 8 ms,\n                                   // because the constraint gains 30 rad/s in the run's last 70 ms\nconst TRAIL_MAX = 40;\nconst TRAIL_SPACING = 0.09;        // rad of precession per sample, so the tail reads the same at any rate\nconst WARM_SECONDS = 0.45;         // first painted frame already has tilt, a tail and a percentage\nconst CAM_FOOT = 0.42;             // sin(camera elevation): how flat the plate reads\nconst CAM_RISE = 0.906;            // cos(camera elevation): how much height reads\nconst RIM_SEGMENTS = 44;\nconst WIDE_PX = 512;\nconst COIN_EDGE = '#f3c04a';\nconst COIN_DARK = '#4b3a15';\n\ninterface State {\n  clock: number;\n  carry: number;\n  alpha: number;\n  omega: number;\n  phase: number;\n  settled: boolean;\n  sinceSample: number;\n  /** (phase, alpha) per sample, never pixels — a resize must not invalidate the tail. */\n  trail: Float64Array;\n  trailHead: number;\n  trailCount: number;\n  pctShown: number;\n  rateShown: number;\n  settleShown: boolean;\n  kickSeen: number;\n  wasDown: boolean;\n}\n\ntype View = { cx: number; cy: number; r: number };\n\n/** Readouts are written straight to the DOM; the nodes only exist after mount. */\nfunction setText(node: HTMLElement | null, text: string): void {\n  if (node) {\n    node.textContent = text;\n  }\n}\n\n/** The rolling constraint. Capped, because sin(alpha)^-1/2 outruns any fixed timestep. */\nfunction constraintOmega(alpha: number): number {\n  return Math.min(OMEGA_CAP, Math.sqrt(GRAV_COUPLE / Math.max(Math.sin(alpha), ALPHA_FLOOR)));\n}\n\nfunction pushTrail(state: State): void {\n  const i = state.trailHead * 2;\n  state.trail[i] = state.phase;\n  state.trail[i + 1] = state.alpha;\n  state.trailHead = (state.trailHead + 1) % TRAIL_MAX;\n  if (state.trailCount < TRAIL_MAX) {\n    state.trailCount += 1;\n  }\n}\n\nfunction advance(state: State): void {\n  if (state.settled) {\n    return;\n  }\n  // E = MgR*sin(alpha), so the divisor is dE/dalpha = MgR*cos(alpha), not MgR. The run starts at\n  // 34 degrees, where the small-angle shortcut understates the drain by about 20%.\n  const lean = Math.max(Math.sin(state.alpha), ALPHA_FLOOR);\n  const k = 1 - Math.exp(-STEP * CONSTRAINT_LAG);\n  state.omega += (constraintOmega(state.alpha) - state.omega) * k;\n  const power = (VISC_DRAIN * state.omega * state.omega) / lean + ROLL_DRAIN * state.omega;\n  // Explicit Euler against a rate that diverges: below about 0.03 rad one step already asks for more\n  // than the whole remaining tilt, which would leave a negative alpha in state and hand that negative\n  // alpha to the trail sample taken further down this same call. Capping the drop at half the tilt\n  // keeps the approach one-sided; arrival is still finite because a halving reaches ALPHA_STOP two or\n  // three steps later, and the cap only ever engages inside the last few milliseconds.\n  const drop = Math.min((power / Math.cos(state.alpha)) * STEP, state.alpha * 0.5);\n  state.alpha -= drop;\n  state.phase = (state.phase + state.omega * STEP) % TAU;\n  state.sinceSample += state.omega * STEP;\n  if (state.sinceSample >= TRAIL_SPACING) {\n    pushTrail(state);\n    state.sinceSample = 0;\n  }\n  if (state.alpha <= ALPHA_STOP) {\n    state.alpha = 0;\n    state.omega = 0;\n    state.settled = true;\n  }\n}\n\nfunction kick(state: State): void {\n  state.alpha = ALPHA_START;\n  state.omega = constraintOmega(ALPHA_START);\n  state.settled = false;\n  state.carry = 0;\n  state.sinceSample = 0;\n  state.trailHead = 0;\n  state.trailCount = 0;\n}\n\nfunction warm(state: State, seconds: number): void {\n  for (let left = seconds; left > 0; left -= STEP) {\n    advance(state);\n  }\n}\n\n// The coin keeps clear of the card: 1.44r of rim can rise above the centre when the disc leans\n// away from the camera, so the radius is bounded by the height as well as the width.\nfunction viewFor(width: number, height: number): View {\n  const wide = width >= WIDE_PX;\n  const r = wide\n    ? Math.min(width * 0.19, height * 0.27, 112)\n    : Math.min(width * 0.28, height * 0.19, 96);\n  return {\n    cx: wide ? width * 0.73 : width * 0.5,\n    cy: wide ? height * 0.5 : height * 0.32,\n    r: Math.max(34, r),\n  };\n}\n\n/**\n * The rim of the disc: centre fixed at height R*sin(alpha), contact point riding a circle of\n * radius R*cos(alpha) at azimuth `phase`. `scale` draws a smaller concentric ring in the same\n * plane — the centre height stays R*sin(alpha), so it must not be folded into the radius.\n * `flat` drops the height for the overhead shadow.\n */\nfunction rimPath(\n  context: CanvasRenderingContext2D,\n  view: View,\n  state: State,\n  flat: boolean,\n  scale: number,\n): void {\n  const ca = Math.cos(state.alpha);\n  const sa = Math.sin(state.alpha);\n  const cp = Math.cos(state.phase);\n  const sp = Math.sin(state.phase);\n  const r = view.r * scale;\n  context.beginPath();\n  for (let i = 0; i <= RIM_SEGMENTS; i += 1) {\n    const psi = (i / RIM_SEGMENTS) * TAU;\n    const cw = Math.cos(psi);\n    const sw = Math.sin(psi);\n    const x = r * (sp * cw + ca * cp * sw);\n    const y = r * (ca * sp * sw - cp * cw);\n    const z = flat ? 0 : view.r * sa - r * sa * sw;\n    const sx = view.cx + x;\n    const sy = view.cy - y * CAM_FOOT - z * CAM_RISE;\n    if (i === 0) {\n      context.moveTo(sx, sy);\n    } else {\n      context.lineTo(sx, sy);\n    }\n  }\n  context.closePath();\n}\n\nfunction paintPlate(context: CanvasRenderingContext2D, view: View, state: State): void {\n  const halo = view.r * 2.6;\n  const glow = context.createRadialGradient(view.cx, view.cy, 0, view.cx, view.cy, halo);\n  glow.addColorStop(0, 'rgba(243, 192, 74, 0.15)');\n  glow.addColorStop(0.5, 'rgba(243, 192, 74, 0.05)');\n  glow.addColorStop(1, 'rgba(243, 192, 74, 0)');\n  context.fillStyle = glow;\n  context.beginPath();\n  context.ellipse(view.cx, view.cy, halo, halo * CAM_FOOT + view.r, 0, 0, TAU);\n  context.fill();\n\n  const plate = view.r * 1.6;\n  context.beginPath();\n  context.ellipse(view.cx, view.cy, plate, plate * CAM_FOOT, 0, 0, TAU);\n  context.fillStyle = 'rgba(255, 248, 235, 0.045)';\n  context.fill();\n  context.lineWidth = 1;\n  context.strokeStyle = 'rgba(255, 248, 235, 0.13)';\n  context.stroke();\n\n  const ring = view.r * Math.cos(state.alpha);\n  context.beginPath();\n  context.ellipse(view.cx, view.cy, ring, ring * CAM_FOOT, 0, 0, TAU);\n  context.strokeStyle = 'rgba(243, 192, 74, 0.16)';\n  context.stroke();\n}\n\n/**\n * The contact point's own track, sampled per 0.09 rad of precession rather than per frame so the\n * tail is the same length at 1 Hz and at 7 Hz. Drawn in two passes: the disc's footprint all but\n * covers the track, so one pass either buries the near half under the coin or floats the far half\n * over it. Nearer means lower on screen, which is sin(azimuth) < 0.\n */\nfunction paintTrail(context: CanvasRenderingContext2D, view: View, state: State, near: boolean): void {\n  const start = (state.trailHead - state.trailCount + TRAIL_MAX) % TRAIL_MAX;\n  for (let i = 0; i < state.trailCount; i += 1) {\n    const j = ((start + i) % TRAIL_MAX) * 2;\n    const swing = Math.sin(state.trail[j]);\n    if ((swing < 0) !== near) {\n      continue;\n    }\n    const age = (i + 1) / state.trailCount;\n    const reach = view.r * Math.cos(state.trail[j + 1]);\n    const sx = view.cx + reach * Math.cos(state.trail[j]);\n    const sy = view.cy - reach * swing * CAM_FOOT;\n    context.beginPath();\n    context.arc(sx, sy, 0.6 + age * 2.2, 0, TAU);\n    context.fillStyle = `rgba(243, 192, 74, ${age * age * 0.5})`;\n    context.fill();\n  }\n}\n\nfunction paintCoin(context: CanvasRenderingContext2D, view: View, state: State): void {\n  const ca = Math.cos(state.alpha);\n  const sa = Math.sin(state.alpha);\n  const cp = Math.cos(state.phase);\n  const sp = Math.sin(state.phase);\n  const r = view.r;\n\n  rimPath(context, view, state, true, 1);\n  context.fillStyle = 'rgba(3, 4, 7, 0.55)';\n  context.fill();\n\n  const lowX = view.cx + r * ca * cp;\n  const lowY = view.cy - r * ca * sp * CAM_FOOT;\n  const highX = view.cx - r * ca * cp;\n  const highY = view.cy + r * ca * sp * CAM_FOOT - 2 * r * sa * CAM_RISE;\n  const face = context.createLinearGradient(highX, highY, lowX, lowY);\n  face.addColorStop(0, '#fce6ab');\n  face.addColorStop(0.5, COIN_EDGE);\n  face.addColorStop(1, COIN_DARK);\n\n  context.lineJoin = 'round';\n  rimPath(context, view, state, false, 1);\n  context.fillStyle = face;\n  context.fill();\n  context.lineWidth = 2;\n  context.strokeStyle = 'rgba(255, 245, 222, 0.5)';\n  context.stroke();\n\n  rimPath(context, view, state, false, 0.62);\n  context.lineWidth = 1;\n  context.strokeStyle = 'rgba(74, 55, 18, 0.5)';\n  context.stroke();\n\n  if (!state.settled) {\n    context.beginPath();\n    context.arc(lowX, lowY, 3, 0, TAU);\n    context.fillStyle = '#fff6de';\n    context.fill();\n  }\n}\n\n/** `compact` is the 298x240 catalogue card: the same coin and the same solver, with the\n *  copy cut to one line along the bottom edge and the plate given the middle of the box.\n *  Presentation only — see `euler-disk-spinner.css`. */\nexport type EulerDiskSpinnerProps = { compact?: boolean };\n\nexport function EulerDiskSpinner({ compact = false }: EulerDiskSpinnerProps) {\n  const reduced = useReducedMotion();\n  const uid = useId();\n  const statusId = `${uid}-status`;\n  const sim = useRef<State | null>(null);\n  const kicks = useRef(0);\n  const statusRef = useRef<HTMLParagraphElement | null>(null);\n  const meterRef = useRef<HTMLDivElement | null>(null);\n  const fillRef = useRef<HTMLSpanElement | null>(null);\n  const pctRef = useRef<HTMLSpanElement | null>(null);\n  const rateRef = useRef<HTMLSpanElement | null>(null);\n  const buttonRef = useRef<HTMLButtonElement | null>(null);\n\n  // The run outlives setup deliberately. setup re-runs on every resize, and building the state\n  // there would restart the job under the reader each time the pane changes width.\n  const setup: (c: SceneSetupContext) => State = () => {\n    const existing = sim.current;\n    if (existing) {\n      return existing;\n    }\n    const state: State = {\n      clock: 0, carry: 0, alpha: ALPHA_START, omega: constraintOmega(ALPHA_START), phase: 0,\n      settled: false, sinceSample: 0, trail: new Float64Array(TRAIL_MAX * 2), trailHead: 0,\n      trailCount: 0, pctShown: -1, rateShown: -1, settleShown: false, kickSeen: kicks.current,\n      wasDown: false,\n    };\n    if (reduced) {\n      state.alpha = 0;\n      state.omega = 0;\n      state.settled = true;\n    } else {\n      warm(state, WARM_SECONDS);\n    }\n    state.settleShown = !state.settled;\n    sim.current = state;\n    return state;\n  };\n\n  // Percentage is the energy already gone, 1 - sin(alpha)/sin(alpha_0), so the readout accelerates\n  // because the dissipation does. Written straight to the DOM: setState per frame would re-run setup.\n  const syncReadouts = (state: State) => {\n    const pct = state.settled\n      ? 100\n      : Math.max(0, Math.min(99, Math.round((1 - Math.sin(state.alpha) / SIN_START) * 100)));\n    if (pct !== state.pctShown) {\n      state.pctShown = pct;\n      setText(pctRef.current, `${pct}%`);\n      if (fillRef.current) {\n        fillRef.current.style.width = `${pct}%`;\n      }\n      meterRef.current?.setAttribute('aria-valuenow', String(pct));\n    }\n    if (state.settleShown !== state.settled) {\n      state.settleShown = state.settled;\n      state.rateShown = -1;\n      setText(statusRef.current, state.settled ? 'Bundle ready' : 'Compiling template bundle');\n      setText(buttonRef.current, state.settled ? 'Spin it up again' : 'Add energy');\n    }\n    const hz = state.omega / TAU;\n    if (Math.abs(hz - state.rateShown) >= 0.05 && rateRef.current) {\n      state.rateShown = hz;\n      setText(rateRef.current, state.settled ? 'at rest' : `${hz.toFixed(1)} Hz rattle`);\n    }\n  };\n\n  const draw = ({ context, width, height, state, pointer }: SceneDrawContext<State>) => {\n    if (!context) {\n      return;\n    }\n    const now = performance.now() / 1000;\n    const dt = state.clock === 0 ? 0 : Math.min(0.05, now - state.clock);\n    state.clock = now;\n\n    if (reduced) {\n      state.alpha = 0;\n      state.omega = 0;\n      state.settled = true;\n      state.trailCount = 0;\n    } else {\n      const press = pointer.down && pointer.inside;\n      if ((press && !state.wasDown) || kicks.current !== state.kickSeen) {\n        kick(state);\n      }\n      state.wasDown = press;\n      state.kickSeen = kicks.current;\n      state.carry += dt;\n      let n = 0;\n      while (state.carry >= STEP && n < MAX_SUBSTEPS) {\n        advance(state);\n        state.carry -= STEP;\n        n += 1;\n      }\n      if (n === MAX_SUBSTEPS) {\n        state.carry = 0;\n      }\n    }\n\n    const view = viewFor(width, height);\n    context.clearRect(0, 0, width, height);\n    paintPlate(context, view, state);\n    paintTrail(context, view, state, false);\n    paintCoin(context, view, state);\n    paintTrail(context, view, state, true);\n    syncReadouts(state);\n  };\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<State>({ setup, draw });\n  useEffect(() => requestRender(), [reduced, requestRender]);\n\n  // Keyboard and pointer arrive here by the same route: the button's click. A press on the plate\n  // itself is picked up from the pointer edge inside draw.\n  const spin = () => {\n    kicks.current += 1;\n    requestRender();\n  };\n\n  return (\n    <div className=\"euler-disk-spinner-stage\" data-compact={compact ? 'true' : undefined}>\n      <div ref={stageRef} className=\"euler-disk-spinner-surface\">\n        <canvas ref={canvasRef} aria-hidden=\"true\" />\n      </div>\n      <div className=\"euler-disk-spinner-content\">\n        <div className=\"euler-disk-spinner-card\">\n          <p ref={statusRef} id={statusId} className=\"euler-disk-spinner-status\" role=\"status\">\n            {reduced ? 'Bundle ready' : 'Compiling template bundle'}\n          </p>\n          <p className=\"euler-disk-spinner-detail\">\n            {/* No item count here. It read \"24 registry items\" — a number that was\n                never checked against the catalogue and was wrong by the time anyone\n                read it. The line describes the kind of work, which stays true. */}\n            One bundle, one lockfile, no network. The coin holds the work that is left.\n          </p>\n          <div\n            ref={meterRef}\n            className=\"euler-disk-spinner-meter\"\n            role=\"progressbar\"\n            aria-labelledby={statusId}\n            aria-valuemin={0}\n            aria-valuemax={100}\n            aria-valuenow={reduced ? 100 : 0}\n          >\n            <span ref={fillRef} className=\"euler-disk-spinner-fill\" />\n          </div>\n          <div className=\"euler-disk-spinner-row\">\n            <span ref={pctRef} className=\"euler-disk-spinner-pct\">\n              {reduced ? '100%' : '0%'}\n            </span>\n            <span ref={rateRef} className=\"euler-disk-spinner-rate\" aria-hidden=\"true\">\n              {reduced ? 'at rest' : '1.1 Hz rattle'}\n            </span>\n          </div>\n          <button\n            ref={buttonRef}\n            type=\"button\"\n            className=\"euler-disk-spinner-button\"\n            disabled={reduced}\n            // The card frame is aria-hidden, so inside it the button leaves the tab order.\n            // It stays clickable — only the keyboard path is withdrawn.\n            tabIndex={compact ? -1 : undefined}\n            onClick={spin}\n          >\n            {reduced ? 'Spin it up again' : 'Add energy'}\n          </button>\n        </div>\n      </div>\n      <p className=\"euler-disk-spinner-hint\">press to spin it up</p>\n    </div>\n  );\n}\n\nexport default EulerDiskSpinner;\n","type":"registry:ui"},{"path":"components/ui/euler-disk-spinner.css","target":"components/ui/euler-disk-spinner.css","content":".euler-disk-spinner-stage {\n  position: relative;\n  display: block;\n  width: 100%;\n  min-height: 20rem;\n  overflow: hidden;\n  border-radius: 0.75rem;\n  background: radial-gradient(120% 120% at 72% 18%, #14110b 0%, #0a0a0e 58%, #06070a 100%);\n  color: #f6f1e6;\n}\n\n/* The plate is measured off this box, so it carries no border: `inset: 0` is against\n   the padding box and a border would slide the contact circle off the plate. */\n.euler-disk-spinner-surface {\n  position: absolute;\n  inset: 0;\n  touch-action: none;\n  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07);\n  border-radius: inherit;\n}\n\n.euler-disk-spinner-surface canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/* Transparent to the pointer so a press anywhere on the plate spins the coin up;\n   the button takes events back for itself. */\n.euler-disk-spinner-content {\n  position: relative;\n  display: flex;\n  align-items: flex-end;\n  min-height: 20rem;\n  padding: 1.5rem;\n  pointer-events: none;\n}\n\n.euler-disk-spinner-card {\n  width: min(17.5rem, 100%);\n}\n\n.euler-disk-spinner-status {\n  margin: 0;\n  font-size: 1.0625rem;\n  font-weight: 500;\n  letter-spacing: -0.015em;\n  text-shadow: 0 1px 16px rgba(6, 7, 10, 0.85);\n}\n\n.euler-disk-spinner-detail {\n  margin: 0.375rem 0 0.875rem;\n  font-size: 0.8125rem;\n  line-height: 1.45;\n  color: rgba(246, 241, 230, 0.56);\n  text-shadow: 0 1px 14px rgba(6, 7, 10, 0.8);\n}\n\n.euler-disk-spinner-meter {\n  position: relative;\n  height: 0.25rem;\n  overflow: hidden;\n  border-radius: 999px;\n  background: rgba(246, 241, 230, 0.12);\n  box-shadow: 0 1px 14px rgba(6, 7, 10, 0.6);\n}\n\n.euler-disk-spinner-fill {\n  display: block;\n  width: 0;\n  height: 100%;\n  border-radius: inherit;\n  background: linear-gradient(90deg, rgba(243, 192, 74, 0.5), #f3c04a);\n}\n\n.euler-disk-spinner-row {\n  display: flex;\n  align-items: baseline;\n  justify-content: space-between;\n  gap: 0.75rem;\n  margin-top: 0.5rem;\n  font: 500 0.6875rem/1 ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n  font-variant-numeric: tabular-nums;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n}\n\n.euler-disk-spinner-pct {\n  color: #f3c04a;\n}\n\n.euler-disk-spinner-rate {\n  color: rgba(246, 241, 230, 0.42);\n}\n\n.euler-disk-spinner-button {\n  appearance: none;\n  margin: 1rem 0 0;\n  padding: 0.5rem 1rem;\n  border: 1px solid rgba(243, 192, 74, 0.38);\n  border-radius: 999px;\n  background: rgba(243, 192, 74, 0.1);\n  font: inherit;\n  font-size: 0.8125rem;\n  font-weight: 500;\n  color: #f8dfa2;\n  cursor: pointer;\n  pointer-events: auto;\n  transition:\n    border-color 160ms ease,\n    background-color 160ms ease,\n    color 160ms ease;\n}\n\n.euler-disk-spinner-button:hover {\n  border-color: rgba(243, 192, 74, 0.7);\n  background: rgba(243, 192, 74, 0.18);\n  color: #fff4dc;\n}\n\n.euler-disk-spinner-button:focus-visible {\n  outline: 2px solid rgba(243, 192, 74, 0.8);\n  outline-offset: 2px;\n}\n\n.euler-disk-spinner-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(246, 241, 230, 0.26);\n  pointer-events: none;\n}\n\n.euler-disk-spinner-button:disabled {\n  border-color: rgba(246, 241, 230, 0.16);\n  background: rgba(246, 241, 230, 0.04);\n  color: rgba(246, 241, 230, 0.4);\n  cursor: default;\n}\n\n/*\n * The loop never starts, so what is switched off is the run-down itself: the coin is\n * painted at alpha = 0, omega = 0 — the exact fixed point of the solver — with the job\n * reported finished. The spin-up control is disabled and the press hint removed rather\n * than left as an affordance that cannot move, and the button transitions go too.\n */\n@media (prefers-reduced-motion: reduce) {\n  .euler-disk-spinner-button {\n    transition: none;\n  }\n\n  .euler-disk-spinner-hint {\n    display: none;\n  }\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled.\n * The copy comes off the plate and down to a single strip along the bottom edge, and\n * the coin is given the middle of the box to rattle in.\n */\n.euler-disk-spinner-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  /* The card frame rounds and clips already. */\n  border-radius: 0;\n}\n\n/*\n * `viewFor` in the tsx puts the plate at 0.32 of the canvas height and bounds the coin's\n * radius by 0.19 of it — proportions for a 20rem marketing stage, which inside a 240px\n * card is a 46px radius sitting in the top third with the bottom half of the frame empty.\n * Nothing is scaled to fix that: the surface is handed a taller box and the empty tail of\n * it is cropped by the stage's own `overflow: hidden`. 240 / 0.64 = 375, so 0.32 of the\n * box lands at exactly half the visible height — at any frame height, since the extension\n * is a percentage of it — and the radius bound becomes 0.297 of it: a 71px radius, and\n * the plate centred. The canvas stays 1:1 with CSS pixels; below the plate there is\n * nothing to lose but the outer 2% of the halo gradient, which is what the crop takes.\n *\n * `pan-y`, not `none`: a full-bleed drag surface that claims every touch traps the page\n * inside a scrolling grid of cards, and this mechanism only ever asked for a press —\n * which still lands, along with every horizontal drag.\n */\n.euler-disk-spinner-stage[data-compact='true'] .euler-disk-spinner-surface {\n  bottom: -56.25%;\n  touch-action: pan-y;\n  /* Three sides of a 1px inset ring, the fourth cropped away, reads as a seam. */\n  box-shadow: none;\n}\n\n/*\n * The text layer off the plate and onto the bottom edge. The strip stands 45px tall and\n * the plate's rim reaches 168px of the 240, so the two never meet. Still deaf to the\n * pointer, so a press through the strip spins the coin up; the button goes on taking its\n * own clicks back.\n */\n.euler-disk-spinner-stage[data-compact='true'] .euler-disk-spinner-content {\n  position: absolute;\n  inset: auto 0 0 0;\n  min-height: 0;\n  padding: 0.75rem;\n  pointer-events: none;\n}\n\n.euler-disk-spinner-stage[data-compact='true'] .euler-disk-spinner-card {\n  display: flex;\n  align-items: center;\n  gap: 0.5rem;\n  width: 100%;\n}\n\n/* The paragraph, the two numeric readouts the meter already gives visually, and a hint\n   the button makes redundant: a second and a third line of text, all of it. */\n.euler-disk-spinner-stage[data-compact='true'] .euler-disk-spinner-detail,\n.euler-disk-spinner-stage[data-compact='true'] .euler-disk-spinner-row,\n.euler-disk-spinner-stage[data-compact='true'] .euler-disk-spinner-hint {\n  display: none;\n}\n\n/* The one line that stays, at a fixed 13px and held to a single line. It is the heading\n   because the heading is the state readout — it turns over to 'Bundle ready' the instant\n   the coin lies flat, which is the event the mechanism exists to show. */\n.euler-disk-spinner-stage[data-compact='true'] .euler-disk-spinner-status {\n  flex: 1 1 auto;\n  min-width: 0;\n  overflow: hidden;\n  font-size: 0.8125rem;\n  line-height: 1.2;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n}\n\n/* The meter out of the row and full-bleed along the very bottom edge, 3px of it: still\n   the live progressbar, but it no longer spends any of the line's width. */\n.euler-disk-spinner-stage[data-compact='true'] .euler-disk-spinner-meter {\n  position: absolute;\n  inset: auto 0 0 0;\n  height: 0.1875rem;\n  border-radius: 0;\n}\n\n/* Kept, and kept clickable: it is the one control that re-runs the spin-up from the top.\n   `tabIndex={-1}` in the component keeps it out of the tab order under the card frame's\n   `aria-hidden`. */\n.euler-disk-spinner-stage[data-compact='true'] .euler-disk-spinner-button {\n  flex: none;\n  margin: 0;\n  padding: 0.25rem 0.625rem;\n  font-size: 0.6875rem;\n  line-height: 1;\n  pointer-events: auto;\n}\n","type":"registry:file"},{"path":"hooks/use-canvas-scene.ts","target":"hooks/use-canvas-scene.ts","content":"\"use client\"\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\n\n/**\n * The canvas preamble every 2D scene needs, in one place: a DPR-scaled backing\n * store, a rebuild on resize, a loop that stops when the stage scrolls out of\n * view, pointer tracking with per-frame deltas, and teardown.\n *\n * A scene supplies two functions. `setup` builds whatever mutable state the\n * animation owns and is re-run whenever the stage changes size, so the state can\n * be sized to the stage without ever being resized in place. `draw` paints one\n * frame from that state — it is called with the transform already scaled to\n * device pixels, so every coordinate in it is a CSS pixel.\n */\n\nexport type ScenePointer = {\n  x: number\n  y: number\n  /** Position at the previous painted frame, so `x - lastX` is a frame delta. */\n  lastX: number\n  lastY: number\n  down: boolean\n  inside: boolean\n}\n\nexport type SceneSetupContext = {\n  context: CanvasRenderingContext2D\n  width: number\n  height: number\n  dpr: number\n}\n\nexport type SceneDrawContext<State> = SceneSetupContext & {\n  state: State\n  pointer: ScenePointer\n  /** Painted frames since the last rebuild. Useful for every-Nth-frame work. */\n  frame: number\n}\n\nexport type CanvasSceneOptions<State> = {\n  setup: (context: SceneSetupContext) => State\n  draw: (context: SceneDrawContext<State>) => void\n}\n\nexport type CanvasScene = {\n  /** The sizing element. Owns the pointer listeners and is what is observed. */\n  stageRef: (node: HTMLDivElement | null) => void\n  canvasRef: (node: HTMLCanvasElement | null) => void\n  /** Paint one frame now. The escape hatch for a paused or reduced-motion loop. */\n  requestRender: () => void\n}\n\n/** Live `prefers-reduced-motion`. False during SSR and the first paint. */\nexport function useReducedMotion() {\n  const [reduced, setReduced] = useState(false)\n\n  useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    setReduced(query.matches)\n    const onChange = () => setReduced(query.matches)\n    query.addEventListener(\"change\", onChange)\n    return () => query.removeEventListener(\"change\", onChange)\n  }, [])\n\n  return reduced\n}\n\nexport function useCanvasScene<State>(options: CanvasSceneOptions<State>): CanvasScene {\n  const reduced = useReducedMotion()\n\n  /*\n   * `draw` is usually an inline closure, so it is a new function on every\n   * render. Reading it through a ref keeps the loop from being torn down and\n   * the scene from being rebuilt each time the component re-renders.\n   */\n  const optionsRef = useRef(options)\n  optionsRef.current = options\n\n  const stage = useRef<HTMLDivElement | null>(null)\n  const canvas = useRef<HTMLCanvasElement | null>(null)\n\n  /*\n   * Plain ref assignment, with no state behind it. React attaches refs during\n   * the commit phase, before passive effects run, so the effect below already\n   * sees both nodes on the first mount — which is why these used to bump a\n   * `mounted` counter for nothing: the two `setMounted` calls batched into one\n   * re-render, the counter went 0 → 2, and the effect's dependency on it tore\n   * the live scene down and rebuilt it. Every scene was constructed, measured\n   * and warmed twice on every mount, four times under StrictMode in dev.\n   *\n   * The requirement this trades for that: a consumer must render the stage and\n   * the canvas unconditionally, in the same commit as the component itself. All\n   * thirteen do. Gating the canvas behind a flag would leave the effect bailing\n   * on the null guard with nothing to re-run it.\n   */\n  const stageRef = useCallback((node: HTMLDivElement | null) => {\n    stage.current = node\n  }, [])\n  const canvasRef = useCallback((node: HTMLCanvasElement | null) => {\n    canvas.current = node\n  }, [])\n\n  /** Set once the scene is live, so `requestRender` before that is a no-op. */\n  const render = useRef<(() => void) | null>(null)\n  const requestRender = useCallback(() => render.current?.(), [])\n\n  useEffect(() => {\n    const stageNode = stage.current\n    const canvasNode = canvas.current\n    if (!stageNode || !canvasNode) return\n\n    const context = canvasNode.getContext(\"2d\")\n    if (!context) return\n\n    const pointer: ScenePointer = {\n      x: 0,\n      y: 0,\n      lastX: 0,\n      lastY: 0,\n      down: false,\n      inside: false,\n    }\n\n    let state: State | null = null\n    let width = 0\n    let height = 0\n    let dpr = 1\n    let frame = 0\n    let loop = 0\n    let pending = 0\n    let visible = true\n\n    /** Rebuild the backing store and the scene state for the current size. */\n    const measure = () => {\n      // `offsetWidth`/`offsetHeight`, not `getBoundingClientRect()`: the rect is\n      // post-transform, so a scene sitting inside a scaled ancestor measured its\n      // own frame at the scaled size, sized the backing store to that, and then\n      // had CSS scale the result a second time — the scene ran at a fraction of\n      // the box it was drawn into. The catalogue's scaled-poster branch is the\n      // one place that happens, and it is reachable again the moment an\n      // animation is registered without a card composition. These two properties\n      // are the untransformed layout box; both are integers, which is what the\n      // rounding below already reduced the rect to.\n      const nextWidth = Math.max(1, stageNode.offsetWidth)\n      const nextHeight = Math.max(1, stageNode.offsetHeight)\n      const nextDpr = Math.min(2, window.devicePixelRatio || 1)\n      if (nextWidth === width && nextHeight === height && nextDpr === dpr && state) return\n\n      width = nextWidth\n      height = nextHeight\n      dpr = nextDpr\n      canvasNode.width = Math.round(width * dpr)\n      canvasNode.height = Math.round(height * dpr)\n      canvasNode.style.width = `${width}px`\n      canvasNode.style.height = `${height}px`\n      frame = 0\n      state = optionsRef.current.setup({ context, width, height, dpr })\n    }\n\n    const paint = () => {\n      if (!state) return\n      // Re-applied every frame: a scene is free to install its own transform\n      // for a cell or a sprite, and most do.\n      context.setTransform(dpr, 0, 0, dpr, 0, 0)\n      optionsRef.current.draw({ context, width, height, dpr, state, pointer, frame })\n      pointer.lastX = pointer.x\n      pointer.lastY = pointer.y\n      frame += 1\n    }\n\n    /** One frame on the next tick, coalescing however many were asked for. */\n    const paintOnce = () => {\n      if (pending) return\n      pending = requestAnimationFrame(() => {\n        pending = 0\n        measure()\n        paint()\n      })\n    }\n    render.current = paintOnce\n\n    const tick = () => {\n      loop = requestAnimationFrame(tick)\n      if (visible) paint()\n    }\n\n    const start = () => {\n      if (loop || reduced) return\n      loop = requestAnimationFrame(tick)\n    }\n    const stop = () => {\n      if (!loop) return\n      cancelAnimationFrame(loop)\n      loop = 0\n    }\n\n    const at = (event: PointerEvent) => {\n      const rect = stageNode.getBoundingClientRect()\n      // The rect is the right thing to subtract here — `clientX` is viewport\n      // space and so is the rect — but the difference comes back in *rendered*\n      // pixels, and a scene reads `pointer` in the scene pixels `measure()` set\n      // up from the untransformed box. Under a CSS scale those two disagree, so\n      // divide the transform back out. `rect.width / offsetWidth` is the scale\n      // actually in force, whatever produced it, and it is exactly 1 when there\n      // is none.\n      const scale = stageNode.offsetWidth > 0 ? rect.width / stageNode.offsetWidth : 1\n      pointer.x = (event.clientX - rect.left) / (scale || 1)\n      pointer.y = (event.clientY - rect.top) / (scale || 1)\n      // A frozen loop still owes the user feedback for a drag.\n      if (reduced) paintOnce()\n    }\n\n    const onEnter = (event: PointerEvent) => {\n      pointer.inside = true\n      at(event)\n      pointer.lastX = pointer.x\n      pointer.lastY = pointer.y\n    }\n    const onMove = (event: PointerEvent) => {\n      pointer.inside = true\n      at(event)\n    }\n    const onDown = (event: PointerEvent) => {\n      pointer.down = true\n      at(event)\n      // Capture keeps a drag alive past the edge of the stage, which is where\n      // a hard throw naturally ends up.\n      stageNode.setPointerCapture(event.pointerId)\n    }\n    const onUp = (event: PointerEvent) => {\n      pointer.down = false\n      at(event)\n      if (stageNode.hasPointerCapture(event.pointerId)) {\n        stageNode.releasePointerCapture(event.pointerId)\n      }\n    }\n    const onLeave = () => {\n      pointer.inside = false\n      pointer.down = false\n      if (reduced) paintOnce()\n    }\n\n    stageNode.addEventListener(\"pointerenter\", onEnter)\n    stageNode.addEventListener(\"pointermove\", onMove)\n    stageNode.addEventListener(\"pointerdown\", onDown)\n    stageNode.addEventListener(\"pointerup\", onUp)\n    stageNode.addEventListener(\"pointercancel\", onUp)\n    stageNode.addEventListener(\"pointerleave\", onLeave)\n\n    const resizes = new ResizeObserver(() => paintOnce())\n    resizes.observe(stageNode)\n\n    /*\n     * An animation nobody can see is heat. The observer both pauses the loop\n     * and, on the way back in, repaints immediately rather than waiting a frame.\n     */\n    const views = new IntersectionObserver(\n      (entries) => {\n        visible = entries.some((entry) => entry.isIntersecting)\n        if (visible) {\n          start()\n          paintOnce()\n        } else {\n          stop()\n        }\n      },\n      { rootMargin: \"120px\" },\n    )\n    views.observe(stageNode)\n\n    measure()\n    paint()\n    start()\n\n    return () => {\n      render.current = null\n      stop()\n      if (pending) cancelAnimationFrame(pending)\n      resizes.disconnect()\n      views.disconnect()\n      stageNode.removeEventListener(\"pointerenter\", onEnter)\n      stageNode.removeEventListener(\"pointermove\", onMove)\n      stageNode.removeEventListener(\"pointerdown\", onDown)\n      stageNode.removeEventListener(\"pointerup\", onUp)\n      stageNode.removeEventListener(\"pointercancel\", onUp)\n      stageNode.removeEventListener(\"pointerleave\", onLeave)\n    }\n  }, [reduced])\n\n  return { stageRef, canvasRef, requestRender }\n}\n","type":"registry:hook"}],"meta":{"kind":"animations","categories":["loaders","three-d"],"docs":"https://ui.artbloom.tech/artbloom/animations/euler-disk-spinner"}}