{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "particles-background",
  "title": "Particles Background",
  "description": "Points on one canvas behind a section: floating, joined by faint lines, rising, or an even field with a clearing that follows the pointer.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/particles-background.tsx",
      "content": "import type * as React from \"react\"\nimport { useEffect, useRef } from \"react\"\nimport { LIGHT_TOKEN_CLASS, useBackgroundPlayback } from \"@/registry/miseui/ui/background-loop\"\nimport type { ParticleVariant } from \"@/registry/miseui/ui/particles-background-engine\"\nimport { startParticlePainter, type ParticlePainter } from \"@/registry/miseui/ui/particles-background-painter\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype ParticlesBackgroundEffect = \"pointer\"\n\n/** Rising sparks have nothing to say about a pointer. A field exists to bend, so it does with or without the prop. */\nfunction followsPointer(variant: ParticleVariant, effect?: ParticlesBackgroundEffect) {\n  if (variant === \"field\") return true\n  return effect === \"pointer\" && variant !== \"rise\"\n}\n\ntype ParticlesBackgroundProps = Omit<React.ComponentProps<\"div\">, \"ref\"> & {\n  /** `drift` points floating, `constellation` near points joined by faint lines, `rise` points going up and fading, `field` an even dot field with a clearing that follows the pointer. */\n  variant?: ParticleVariant\n  /** `pointer` parts the `drift` points and links the `constellation` to the pointer. Off on touch. */\n  effect?: ParticlesBackgroundEffect\n  /** Holds the motion. Wire it to a control: motion past five seconds needs a way to stop it. */\n  paused?: boolean\n  /** Another seed gives another layout. Points are only drawn in the browser, so hydration is not at stake. */\n  seed?: number\n}\n\nfunction ParticlesBackground({ variant = \"drift\", effect, paused = false, seed = 1, className, children, onPointerMove, onPointerLeave, ...props }: ParticlesBackgroundProps) {\n  const rootRef = useRef<HTMLDivElement>(null)\n  const canvasRef = useRef<HTMLCanvasElement>(null)\n  const painterRef = useRef<ParticlePainter | null>(null)\n  const { still, playing } = useBackgroundPlayback(rootRef, { paused })\n  const following = followsPointer(variant, effect)\n\n  useEffect(() => {\n    const canvas = canvasRef.current\n    if (!canvas) return\n    const painter = startParticlePainter(canvas, { variant, seed })\n    painterRef.current = painter\n    return () => {\n      painter.stop()\n      painterRef.current = null\n    }\n  }, [variant, seed])\n\n  useEffect(() => {\n    painterRef.current?.setPlaying(playing)\n  }, [playing, variant, seed])\n\n  return (\n    <div\n      {...props}\n      ref={rootRef}\n      data-slot=\"particles-background\"\n      data-variant={variant}\n      data-effect={effect}\n      data-playing={still ? undefined : String(playing)}\n      className={cn(\"relative isolate min-w-0 overflow-clip\", className)}\n      // On the root and not on window: the events bubble up from whatever sits on top, and nothing listens while off screen.\n      onPointerMove={(event) => {\n        onPointerMove?.(event)\n        if (following && event.pointerType !== \"touch\") painterRef.current?.setPointer({ x: event.clientX, y: event.clientY })\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event)\n        if (following) painterRef.current?.setPointer(null)\n      }}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        data-slot=\"particles-background-canvas\"\n        className={cn(\"pointer-events-none absolute inset-0 -z-10 size-full rounded-[inherit] [color:var(--mise-light)] [border-color:var(--muted-foreground)]\", LIGHT_TOKEN_CLASS)}\n      />\n      <div data-slot=\"particles-background-content\" className=\"relative h-full\">\n        {children}\n      </div>\n    </div>\n  )\n}\n\nexport { ParticlesBackground, type ParticlesBackgroundProps, type ParticlesBackgroundEffect, type ParticleVariant as ParticlesBackgroundVariant }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/particles-background-engine.ts",
      "content": "import { seededRandom } from \"@/registry/miseui/ui/background-seed\"\n\nexport type ParticleVariant = \"drift\" | \"constellation\" | \"rise\" | \"field\"\n\n/**\n * The state is typed arrays changed in place, against the usual rule in this code base. This runs at 60 fps, and fresh\n * objects per frame are what produce collection pauses in an animation. Nothing outside the painter holds a reference.\n */\nexport interface ParticleField {\n  variant: ParticleVariant\n  width: number\n  height: number\n  count: number\n  /** One slot past `count` holds the pointer, so a link can end there without a special case. */\n  x: Float32Array\n  y: Float32Array\n  vx: Float32Array\n  vy: Float32Array\n  size: Float32Array\n  phase: Float32Array\n  /** rise: 0 to 1 through a lifetime. field: closeness to the dent, 0 to 1. */\n  life: Float32Array\n  homeX: Float32Array\n  homeY: Float32Array\n  random: () => number\n  dentX: number\n  dentY: number\n  heat: number\n}\n\nexport interface PointerAt {\n  x: number\n  y: number\n}\n\nconst AREA_PER_POINT: Record<Exclude<ParticleVariant, \"field\">, number> = { drift: 3000, constellation: 6000, rise: 3600 }\nconst FLOOR: Record<Exclude<ParticleVariant, \"field\">, number> = { drift: 16, constellation: 16, rise: 14 }\nexport const PARTICLE_CEILING: Record<ParticleVariant, number> = { drift: 120, constellation: 90, rise: 90, field: 1400 }\n\nconst FIELD_SPACING = 24\n\n/** Even rows and columns that stay under the ceiling on a huge box, by opening the spacing rather than cutting the field. */\nexport function fieldGrid(width: number, height: number) {\n  const fitted = Math.sqrt((width * height) / PARTICLE_CEILING.field)\n  const spacing = Math.max(FIELD_SPACING, Math.ceil(fitted) + 1)\n  const cols = Math.max(1, Math.floor(width / spacing))\n  const rows = Math.max(1, Math.floor(height / spacing))\n  return { spacing, cols, rows, offsetX: (width - (cols - 1) * spacing) / 2, offsetY: (height - (rows - 1) * spacing) / 2 }\n}\n\nexport function particleCount(variant: ParticleVariant, width: number, height: number) {\n  if (variant === \"field\") {\n    const { cols, rows } = fieldGrid(width, height)\n    return cols * rows\n  }\n  const wanted = Math.round((width * height) / AREA_PER_POINT[variant])\n  return Math.min(PARTICLE_CEILING[variant], Math.max(FLOOR[variant], wanted))\n}\n\nfunction dentRadius(field: ParticleField) {\n  return Math.min(field.width, field.height) * 0.6\n}\n\n/** Dots move out of the dent and fade inside it, so the sentence on top gets a clearing at rest. */\nexport function applyDent(field: ParticleField) {\n  const radius = dentRadius(field)\n  const maxPush = radius * 0.22\n  for (let i = 0; i < field.count; i += 1) {\n    const dx = (field.homeX[i] as number) - field.dentX\n    const dy = (field.homeY[i] as number) - field.dentY\n    const distance = Math.hypot(dx, dy) || 1\n    const closeness = Math.max(0, 1 - distance / radius)\n    const push = closeness * closeness * maxPush\n    field.x[i] = (field.homeX[i] as number) + (dx / distance) * push\n    field.y[i] = (field.homeY[i] as number) + (dy / distance) * push\n    field.life[i] = closeness\n  }\n}\n\nfunction seedField(field: ParticleField) {\n  const { cols, spacing, offsetX, offsetY } = fieldGrid(field.width, field.height)\n  for (let i = 0; i < field.count; i += 1) {\n    field.homeX[i] = offsetX + (i % cols) * spacing\n    field.homeY[i] = offsetY + Math.floor(i / cols) * spacing\n    field.size[i] = 1.1\n  }\n  applyDent(field)\n}\n\nfunction seedPoint(field: ParticleField, i: number) {\n  const { random, width, height, variant } = field\n  const heading = random() * Math.PI * 2\n  const speed = variant === \"constellation\" ? 6 + random() * 8 : 4 + random() * 6\n  field.x[i] = random() * width\n  field.y[i] = random() * height\n  field.vx[i] = Math.cos(heading) * speed\n  field.vy[i] = variant === \"rise\" ? 8 + random() * 16 : Math.sin(heading) * speed\n  field.size[i] = 1 + random() * 1.2\n  field.phase[i] = random() * Math.PI * 2\n  field.life[i] = random()\n}\n\nexport function seedParticles(variant: ParticleVariant, width: number, height: number, seed: number): ParticleField {\n  const count = particleCount(variant, width, height)\n  const slots = count + 1\n  const field: ParticleField = {\n    variant,\n    width,\n    height,\n    count,\n    x: new Float32Array(slots),\n    y: new Float32Array(slots),\n    vx: new Float32Array(slots),\n    vy: new Float32Array(slots),\n    size: new Float32Array(slots),\n    phase: new Float32Array(slots),\n    life: new Float32Array(slots),\n    homeX: new Float32Array(variant === \"field\" ? slots : 0),\n    homeY: new Float32Array(variant === \"field\" ? slots : 0),\n    random: seededRandom(seed),\n    dentX: width / 2,\n    dentY: height / 2,\n    heat: 0,\n  }\n  if (variant === \"field\") seedField(field)\n  else for (let i = 0; i < count; i += 1) seedPoint(field, i)\n  return field\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/particles-background-step.ts",
      "content": "import { applyDent, particleCount, seedParticles, type ParticleField, type PointerAt } from \"@/registry/miseui/ui/particles-background-engine\"\n\nconst WRAP_MARGIN = 8\nconst BOUNCE_MARGIN = 12\nconst PUSH_RADIUS = 96\nconst PUSH_SPEED = 36\nconst DENT_RATE = 8\n\nfunction wrap(value: number, size: number, margin: number) {\n  if (value < -margin) return size + margin\n  if (value > size + margin) return -margin\n  return value\n}\n\nfunction pushAway(field: ParticleField, i: number, pointer: PointerAt, dt: number) {\n  const dx = (field.x[i] as number) - pointer.x\n  const dy = (field.y[i] as number) - pointer.y\n  const distance = Math.hypot(dx, dy)\n  if (distance >= PUSH_RADIUS || distance === 0) return\n  const force = (1 - distance / PUSH_RADIUS) * PUSH_SPEED * dt\n  field.x[i] = (field.x[i] as number) + (dx / distance) * force\n  field.y[i] = (field.y[i] as number) + (dy / distance) * force\n}\n\nfunction stepDrift(field: ParticleField, dt: number, pointer: PointerAt | null) {\n  for (let i = 0; i < field.count; i += 1) {\n    const phase = (field.phase[i] as number) + dt * 0.4\n    field.phase[i] = phase\n    field.x[i] = wrap((field.x[i] as number) + ((field.vx[i] as number) + Math.sin(phase) * 3) * dt, field.width, WRAP_MARGIN)\n    field.y[i] = wrap((field.y[i] as number) + ((field.vy[i] as number) + Math.cos(phase) * 3) * dt, field.height, WRAP_MARGIN)\n    if (pointer) pushAway(field, i, pointer, dt)\n  }\n}\n\n/** Bounces rather than wraps: a point jumping to the far side would snap its links away in one frame. */\nfunction bounce(position: Float32Array, velocity: Float32Array, i: number, size: number) {\n  const value = position[i] as number\n  if (value < -BOUNCE_MARGIN) velocity[i] = Math.abs(velocity[i] as number)\n  else if (value > size + BOUNCE_MARGIN) velocity[i] = -Math.abs(velocity[i] as number)\n}\n\nfunction stepConstellation(field: ParticleField, dt: number) {\n  for (let i = 0; i < field.count; i += 1) {\n    field.x[i] = (field.x[i] as number) + (field.vx[i] as number) * dt\n    field.y[i] = (field.y[i] as number) + (field.vy[i] as number) * dt\n    bounce(field.x, field.vx, i, field.width)\n    bounce(field.y, field.vy, i, field.height)\n  }\n}\n\nfunction stepRise(field: ParticleField, dt: number) {\n  for (let i = 0; i < field.count; i += 1) {\n    const phase = (field.phase[i] as number) + dt * 0.8\n    const life = (field.life[i] as number) + dt / 9\n    field.phase[i] = phase\n    field.x[i] = (field.x[i] as number) + Math.sin(phase) * 6 * dt\n    field.y[i] = (field.y[i] as number) - (field.vy[i] as number) * dt\n    field.life[i] = life\n    if (life >= 1) {\n      field.life[i] = 0\n      field.x[i] = field.random() * field.width\n      field.y[i] = field.height * (0.55 + field.random() * 0.5)\n    }\n  }\n}\n\n/** Returns false once settled, so a resting field stops asking for frames and costs nothing. */\nfunction stepDent(field: ParticleField, dt: number, pointer: PointerAt | null) {\n  const ease = 1 - Math.exp(-dt * DENT_RATE)\n  const moveX = ((pointer?.x ?? field.width / 2) - field.dentX) * ease\n  const moveY = ((pointer?.y ?? field.height / 2) - field.dentY) * ease\n  const warm = ((pointer ? 1 : 0) - field.heat) * ease\n  if (Math.abs(moveX) < 0.02 && Math.abs(moveY) < 0.02 && Math.abs(warm) < 0.002) return false\n  field.dentX += moveX\n  field.dentY += moveY\n  field.heat += warm\n  applyDent(field)\n  return true\n}\n\n/** Advances one frame. Returns whether anything moved. */\nexport function stepParticles(field: ParticleField, dt: number, pointer: PointerAt | null) {\n  if (pointer) {\n    field.x[field.count] = pointer.x\n    field.y[field.count] = pointer.y\n  }\n  if (field.variant === \"field\") return stepDent(field, dt, pointer)\n  if (field.variant === \"drift\") stepDrift(field, dt, pointer)\n  else if (field.variant === \"rise\") stepRise(field, dt)\n  else stepConstellation(field, dt)\n  return true\n}\n\n/** Carries the current state over to a new box, keeping points in place by proportion, unless the count must change. */\nexport function resizeParticles(field: ParticleField, width: number, height: number, seed: number): ParticleField {\n  if (field.variant === \"field\" || particleCount(field.variant, width, height) !== field.count) return seedParticles(field.variant, width, height, seed)\n  const scaleX = width / field.width\n  const scaleY = height / field.height\n  return { ...field, width, height, x: field.x.map((value) => value * scaleX), y: field.y.map((value) => value * scaleY) }\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/particles-background-links.ts",
      "content": "import type { ParticleField, ParticleVariant } from \"@/registry/miseui/ui/particles-background-engine\"\n\nconst LINK_LEVELS = 4\nconst MAX_LINKS_PER_POINT = 3\n\n/** Links per frame are stored in reused arrays: same reason as the engine. */\nexport interface LinkBuffer {\n  a: Uint16Array\n  b: Uint16Array\n  level: Uint8Array\n  used: Uint8Array\n  head: Int32Array\n  next: Int32Array\n  cols: number\n  rows: number\n  cell: number\n  count: number\n}\n\n/** A little past the average gap between points, so each sees about four neighbours whatever the box and the count. */\nexport function linkDistance(width: number, height: number, count: number) {\n  return Math.min(200, Math.max(64, Math.sqrt((width * height) / Math.max(1, count)) * 1.2))\n}\n\nexport function createLinkBuffer(field: ParticleField, distance: number): LinkBuffer {\n  const capacity = (field.count + 1) * MAX_LINKS_PER_POINT\n  const cols = Math.max(1, Math.ceil(field.width / distance))\n  const rows = Math.max(1, Math.ceil(field.height / distance))\n  return {\n    a: new Uint16Array(capacity),\n    b: new Uint16Array(capacity),\n    level: new Uint8Array(capacity),\n    used: new Uint8Array(field.count + 1),\n    head: new Int32Array(cols * rows),\n    next: new Int32Array(field.count),\n    cols,\n    rows,\n    cell: distance,\n    count: 0,\n  }\n}\n\nfunction fillGrid(field: ParticleField, buffer: LinkBuffer) {\n  const { head, next, cols, rows, cell } = buffer\n  head.fill(-1)\n  for (let i = 0; i < field.count; i += 1) {\n    // Points can sit a little past the edge before they bounce; the outer cells take them.\n    const col = Math.min(cols - 1, Math.max(0, Math.floor((field.x[i] as number) / cell)))\n    const row = Math.min(rows - 1, Math.max(0, Math.floor((field.y[i] as number) / cell)))\n    const index = row * cols + col\n    next[i] = head[index] as number\n    head[index] = i\n  }\n}\n\nfunction tryLink(field: ParticleField, buffer: LinkBuffer, i: number, j: number, distance: number, maxPerPoint: number) {\n  if (buffer.count >= buffer.a.length) return\n  if ((buffer.used[i] as number) >= maxPerPoint || (buffer.used[j] as number) >= maxPerPoint) return\n  const gap = Math.hypot((field.x[i] as number) - (field.x[j] as number), (field.y[i] as number) - (field.y[j] as number))\n  if (gap >= distance) return\n  buffer.a[buffer.count] = i\n  buffer.b[buffer.count] = j\n  buffer.level[buffer.count] = Math.min(LINK_LEVELS - 1, Math.floor((1 - gap / distance) * LINK_LEVELS))\n  buffer.used[i] = (buffer.used[i] as number) + 1\n  buffer.used[j] = (buffer.used[j] as number) + 1\n  buffer.count += 1\n}\n\n/** Half the neighbours, so each pair is met once: same cell, right, and the row below. */\nconst FORWARD_CELLS = [\n  [1, 0],\n  [-1, 1],\n  [0, 1],\n  [1, 1],\n] as const\n\nfunction linkCell(field: ParticleField, buffer: LinkBuffer, col: number, row: number, distance: number, maxPerPoint: number) {\n  const { head, next, cols, rows } = buffer\n  for (let i = head[row * cols + col] as number; i !== -1; i = next[i] as number) {\n    for (let j = next[i] as number; j !== -1; j = next[j] as number) tryLink(field, buffer, i, j, distance, maxPerPoint)\n    for (const [stepCol, stepRow] of FORWARD_CELLS) {\n      const otherCol = col + stepCol\n      const otherRow = row + stepRow\n      if (otherCol < 0 || otherCol >= cols || otherRow >= rows) continue\n      for (let j = head[otherRow * cols + otherCol] as number; j !== -1; j = next[j] as number) tryLink(field, buffer, i, j, distance, maxPerPoint)\n    }\n  }\n}\n\n/** Fills the buffer with near pairs, O(n) through the grid. `pointer` true links the slot past the last point as well. */\nexport function linkParticles(field: ParticleField, buffer: LinkBuffer, distance: number, pointer = false, maxPerPoint = MAX_LINKS_PER_POINT) {\n  buffer.count = 0\n  buffer.used.fill(0)\n  fillGrid(field, buffer)\n  for (let row = 0; row < buffer.rows; row += 1) {\n    for (let col = 0; col < buffer.cols; col += 1) linkCell(field, buffer, col, row, distance, maxPerPoint)\n  }\n  if (pointer) for (let i = 0; i < field.count; i += 1) tryLink(field, buffer, field.count, i, distance * 1.2, Number.MAX_SAFE_INTEGER)\n  return buffer.count\n}\n\nexport interface Shade {\n  alpha: number\n  tone: \"accent\" | \"neutral\"\n}\n\nconst ACCENT_STEPS: readonly Shade[] = [0.14, 0.26, 0.38, 0.5, 0.62, 0.74].map((alpha) => ({ alpha, tone: \"accent\" }))\n\n/** Few alphas, so the painter fills one path per alpha instead of one fill per point. */\nexport const SHADES: Record<ParticleVariant, readonly Shade[]> = {\n  drift: ACCENT_STEPS,\n  constellation: ACCENT_STEPS,\n  rise: ACCENT_STEPS,\n  field: [\n    { alpha: 0.08, tone: \"neutral\" },\n    { alpha: 0.2, tone: \"neutral\" },\n    { alpha: 0.36, tone: \"neutral\" },\n    { alpha: 0.75, tone: \"accent\" },\n  ],\n}\n\nexport const LINK_ALPHAS = [0.08, 0.14, 0.2, 0.26] as const\n\n/** Which entry of SHADES a point gets this frame. */\nexport function shadeOf(field: ParticleField, i: number) {\n  const life = field.life[i] as number\n  if (field.variant === \"field\") {\n    if (field.heat * Math.sin(Math.PI * life) > 0.5) return 3\n    if (life > 0.55) return 0\n    return life > 0.25 ? 1 : 2\n  }\n  const strength = field.variant === \"rise\" ? Math.sin(Math.PI * life) : ((field.size[i] as number) - 1) / 1.2\n  return Math.min(ACCENT_STEPS.length - 1, Math.max(0, Math.floor(strength * ACCENT_STEPS.length)))\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/particles-background-painter.ts",
      "content": "import { seedParticles, type ParticleField, type ParticleVariant, type PointerAt } from \"@/registry/miseui/ui/particles-background-engine\"\nimport { createLinkBuffer, LINK_ALPHAS, linkDistance, linkParticles, SHADES, shadeOf, type LinkBuffer } from \"@/registry/miseui/ui/particles-background-links\"\nimport { resizeParticles, stepParticles } from \"@/registry/miseui/ui/particles-background-step\"\n\nconst MAX_PIXEL_RATIO = 2\n/** A long gap, such as coming back from another tab, must not teleport anything. */\nconst MAX_STEP_SECONDS = 0.05\nconst FALLBACK = { accent: \"#888888\", neutral: \"#888888\" }\n\nexport interface ParticleColors {\n  accent: string\n  neutral: string\n}\n\n/** The canvas carries both tokens as `color` and `border-color`, so the browser resolves relative colours for us. */\nexport function readParticleColors(canvas: HTMLCanvasElement): ParticleColors {\n  const styles = getComputedStyle(canvas)\n  return { accent: styles.color || FALLBACK.accent, neutral: styles.borderTopColor || FALLBACK.neutral }\n}\n\ninterface Scene {\n  field: ParticleField | null\n  links: LinkBuffer | null\n  distance: number\n  colors: ParticleColors\n}\n\nfunction paintPoints(ctx: CanvasRenderingContext2D, field: ParticleField, colors: ParticleColors) {\n  SHADES[field.variant].forEach((shade, level) => {\n    ctx.beginPath()\n    for (let i = 0; i < field.count; i += 1) {\n      if (shadeOf(field, i) !== level) continue\n      const x = field.x[i] as number\n      const y = field.y[i] as number\n      const radius = field.size[i] as number\n      ctx.moveTo(x + radius, y)\n      ctx.arc(x, y, radius, 0, Math.PI * 2)\n    }\n    ctx.globalAlpha = shade.alpha\n    ctx.fillStyle = colors[shade.tone]\n    ctx.fill()\n  })\n}\n\nfunction paintLinks(ctx: CanvasRenderingContext2D, field: ParticleField, links: LinkBuffer, colors: ParticleColors) {\n  ctx.lineWidth = 1\n  ctx.strokeStyle = colors.neutral\n  LINK_ALPHAS.forEach((alpha, level) => {\n    ctx.beginPath()\n    for (let n = 0; n < links.count; n += 1) {\n      if (links.level[n] !== level) continue\n      const a = links.a[n] as number\n      const b = links.b[n] as number\n      ctx.moveTo(field.x[a] as number, field.y[a] as number)\n      ctx.lineTo(field.x[b] as number, field.y[b] as number)\n    }\n    ctx.globalAlpha = alpha\n    ctx.stroke()\n  })\n}\n\nfunction paintScene(ctx: CanvasRenderingContext2D, { field, links, colors }: Scene) {\n  if (!field) return\n  ctx.clearRect(0, 0, field.width, field.height)\n  if (links) paintLinks(ctx, field, links, colors)\n  paintPoints(ctx, field, colors)\n}\n\n/**\n * Matches the canvas to its box. Returns false when nothing changed: the observer also fires once on observe, and\n * assigning canvas.width wipes it.\n */\nfunction fitScene(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, scene: Scene, { variant, seed }: PainterOptions) {\n  const width = canvas.clientWidth\n  const height = canvas.clientHeight\n  if (width <= 0 || height <= 0 || (scene.field?.width === width && scene.field.height === height)) return false\n  const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO)\n  canvas.width = Math.round(width * ratio)\n  canvas.height = Math.round(height * ratio)\n  ctx.setTransform(ratio, 0, 0, ratio, 0, 0)\n  scene.field = scene.field ? resizeParticles(scene.field, width, height, seed) : seedParticles(variant, width, height, seed)\n  if (variant === \"constellation\") {\n    scene.distance = linkDistance(width, height, scene.field.count)\n    scene.links = createLinkBuffer(scene.field, scene.distance)\n    linkParticles(scene.field, scene.links, scene.distance)\n  }\n  return true\n}\n\nfunction stepScene({ field, links, distance }: Scene, dt: number, pointer: PointerAt | null) {\n  if (!field) return false\n  const moved = stepParticles(field, dt, pointer)\n  if (links) linkParticles(field, links, distance, pointer !== null)\n  return moved\n}\n\nfunction watchTheme(onChange: () => void) {\n  const theme = new MutationObserver(onChange)\n  theme.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"style\", \"data-theme\", \"data-palette\"] })\n  const scheme = window.matchMedia(\"(prefers-color-scheme: dark)\")\n  scheme.addEventListener(\"change\", onChange)\n  return () => {\n    theme.disconnect()\n    scheme.removeEventListener(\"change\", onChange)\n  }\n}\n\n/** One outstanding frame at a time. The callback says whether it wants another. */\nfunction createFrameLoop(onFrame: (time: number) => boolean) {\n  let frame = 0\n  const request = () => {\n    if (frame) return\n    frame = requestAnimationFrame((time) => {\n      frame = 0\n      if (onFrame(time)) request()\n    })\n  }\n  const cancel = () => {\n    if (frame) cancelAnimationFrame(frame)\n    frame = 0\n  }\n  return { request, cancel }\n}\n\nexport interface ParticlePainter {\n  setPlaying: (playing: boolean) => void\n  /** Client coordinates, or null when the pointer left. */\n  setPointer: (client: { x: number; y: number } | null) => void\n  stop: () => void\n}\n\ninterface PainterOptions {\n  variant: ParticleVariant\n  seed: number\n}\n\nconst NOOP_PAINTER: ParticlePainter = { setPlaying: () => {}, setPointer: () => {}, stop: () => {} }\n\nfunction pointerInBox(canvas: HTMLCanvasElement, client: { x: number; y: number } | null): PointerAt | null {\n  if (!client) return null\n  const box = canvas.getBoundingClientRect()\n  return { x: client.x - box.left, y: client.y - box.top }\n}\n\n/** One canvas, one loop. Frames run only while playing, and a settled field stops asking for them. */\nexport function startParticlePainter(canvas: HTMLCanvasElement, options: PainterOptions): ParticlePainter {\n  const ctx = canvas.getContext(\"2d\")\n  if (!ctx) return NOOP_PAINTER\n  const scene: Scene = { field: null, links: null, distance: 0, colors: readParticleColors(canvas) }\n  let playing = false\n  let client: { x: number; y: number } | null = null\n  let lastTime: number | null = null\n\n  const loop = createFrameLoop((time) => {\n    if (!playing) return false\n    const dt = lastTime === null ? 0 : Math.min(MAX_STEP_SECONDS, (time - lastTime) / 1000)\n    lastTime = time\n    const moved = stepScene(scene, dt, pointerInBox(canvas, client))\n    paintScene(ctx, scene)\n    // A first frame has no time step, so a resting field cannot tell yet whether it has settled.\n    return moved || dt === 0\n  })\n  const refit = () => {\n    if (!fitScene(canvas, ctx, scene, options)) return\n    paintScene(ctx, scene)\n    if (playing) loop.request()\n  }\n  const size = new ResizeObserver(refit)\n  size.observe(canvas)\n  const stopTheme = watchTheme(() => {\n    scene.colors = readParticleColors(canvas)\n    paintScene(ctx, scene)\n  })\n  refit()\n\n  return {\n    setPlaying: (next) => {\n      playing = next\n      lastTime = null\n      if (next) loop.request()\n      else loop.cancel()\n    },\n    setPointer: (next) => {\n      client = next\n      if (playing) loop.request()\n    },\n    stop: () => {\n      playing = false\n      loop.cancel()\n      size.disconnect()\n      stopTheme()\n    },\n  }\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/background-loop.ts",
      "content": "import type * as React from \"react\"\nimport { useEffect, useState, useSyncExternalStore } from \"react\"\nimport { useReducedMotion } from \"motion/react\"\n\n/**\n * The accent as a light source, held in a lightness band per theme. A near-black accent would read as smoke on a light\n * page and a dark blue would pull muted text under 4.5:1 where a beam crosses it, so the band's floor is 0.6.\n * Older browsers keep plain --mise, which is only a stronger tint.\n */\nexport const LIGHT_TOKEN_CLASS = [\n  \"[--mise-light:var(--mise)]\",\n  \"supports-[color:oklch(from_red_l_c_h)]:[--mise-light:oklch(from_var(--mise)_clamp(0.6,l,0.76)_c_h)]\",\n  \"dark:supports-[color:oklch(from_red_l_c_h)]:[--mise-light:oklch(from_var(--mise)_clamp(0.62,l,0.82)_c_h)]\",\n].join(\" \")\n\n/** Before hydration the hook cannot know the preference yet. A paused animation holds its seeded frame. */\nexport const LOOP_CLASS = \"motion-reduce:[animation-play-state:paused]!\"\n\nconst subscribeToVisibility = (onChange: () => void) => {\n  document.addEventListener(\"visibilitychange\", onChange)\n  return () => document.removeEventListener(\"visibilitychange\", onChange)\n}\n\nfunction useTabVisible() {\n  return useSyncExternalStore(\n    subscribeToVisibility,\n    () => !document.hidden,\n    () => true,\n  )\n}\n\nfunction useOnScreen(rootRef: React.RefObject<HTMLElement | null>, watching: boolean) {\n  const [onScreen, setOnScreen] = useState(true)\n  useEffect(() => {\n    const root = rootRef.current\n    if (!watching || !root || typeof IntersectionObserver === \"undefined\") return\n    const view = new IntersectionObserver(([entry]) => setOnScreen(entry?.isIntersecting ?? true))\n    view.observe(root)\n    return () => view.disconnect()\n  }, [rootRef, watching])\n  return onScreen\n}\n\ninterface PlaybackOptions {\n  /** The owner's stop control. */\n  paused?: boolean\n  /** False when this look never moves, so nothing is watched at all. */\n  loops?: boolean\n}\n\n/** `still` draws one finished frame. `playing` is only true on screen, in a visible tab and not paused. */\nexport function useBackgroundPlayback(rootRef: React.RefObject<HTMLElement | null>, { paused = false, loops = true }: PlaybackOptions) {\n  const reduceMotion = useReducedMotion() ?? false\n  const moving = loops && !reduceMotion\n  const onScreen = useOnScreen(rootRef, moving)\n  const tabVisible = useTabVisible()\n  return { still: !moving, playing: moving && !paused && onScreen && tabVisible }\n}\n\n/** Longhands, because the shorthand would reset the play state each element reads from the layer. */\nexport function loopStyle(name: string, seconds: number, delay: number, timing = \"linear\", direction = \"normal\"): React.CSSProperties {\n  return {\n    animationName: name,\n    animationDuration: `${seconds}s`,\n    animationDelay: `${delay}s`,\n    animationTimingFunction: timing,\n    animationDirection: direction,\n    animationIterationCount: \"infinite\",\n    animationPlayState: \"var(--mise-bg-play)\",\n  }\n}\n\n/** Set once on the layer, so pausing writes one property however many elements are moving. */\nexport function playStyle(playing: boolean) {\n  return { \"--mise-bg-play\": playing ? \"running\" : \"paused\" } as React.CSSProperties\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/background-seed.ts",
      "content": "/** Mulberry32. Server and client walk the same numbers, so a seeded layout hydrates without a mismatch. */\nexport function seededRandom(seed: number) {\n  let state = seed >>> 0\n  return () => {\n    state = (state + 0x6d2b79f5) >>> 0\n    let mixed = state\n    mixed = Math.imul(mixed ^ (mixed >>> 15), mixed | 1)\n    mixed ^= mixed + Math.imul(mixed ^ (mixed >>> 7), mixed | 61)\n    return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296\n  }\n}\n\n/** Two decimals: enough for a percent or a second, and short in the inline style. */\nexport function between(random: () => number, min: number, max: number) {\n  return Math.round((min + random() * (max - min)) * 100) / 100\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "backgrounds"
  ],
  "type": "registry:ui"
}