{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "background-08",
  "title": "Node field",
  "description": "Drifting nodes behind a hero that link with hairlines as they come close, where the pointer joins as a node and draws its neighbours to it.",
  "dependencies": [
    "not showing"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/background/background-08.tsx",
      "content": "'use client';\n\nimport { useMemo } from 'react';\n\nimport { Button } from '@/registry/ndeko/ui/button';\n\nimport { DemoBand } from './_parts/demo-band';\nimport { AT_REST, NEXT_FRAME } from './_parts/frame-loop';\nimport { DEFAULT_HUE, useHueCycle } from './_parts/hue';\nimport { clamp, shareOf, towards, type Point } from './_parts/math';\nimport { randomOf } from './_parts/seeded';\nimport type { Scene } from './_parts/stage';\nimport { StageCanvas } from './_parts/stage-canvas';\nimport { wipeSurface, type Inks, type Surface } from './_parts/surface';\n\n/** One node for this many square pixels: a modest count, whatever the size of the box. */\nconst AREA_PER_NODE = 12000;\n\nconst MIN_NODES = 10;\n\n/** Every pair of nodes is compared each frame, so the count has a ceiling. */\nconst MAX_NODES = 140;\n\nconst LINK_PX = 130;\n\n/** The ink of a link between two nodes that touch. It falls to nothing at the reach of a link. */\nconst LINK_ALPHA = 0.26;\n\nconst NODE_ALPHA = 0.7;\n\nconst NODE_RADIUS = 1.6;\n\nconst MIN_SPEED_PX = 4;\n\nconst MAX_SPEED_PX = 10;\n\nconst SPEED_SPREAD_PX = MAX_SPEED_PX - MIN_SPEED_PX;\n\nconst POINTER_REACH_PX = 190;\n\nconst POINTER_ALPHA = 0.5;\n\n/** A node near the pointer is drawn this much closer to it, as if the pointer tugged at the web. */\nconst POINTER_PULL_PX = 7;\n\nconst POINTER_RADIUS = 2.4;\n\nconst PRESENCE_EASE_S = 0.2;\n\nconst GONE = 0.01;\n\nconst FULL_TURN = Math.PI * 2;\n\nconst SEED = 8;\n\nconst NO_INKS: Inks = { hue: 'transparent', line: 'transparent' };\n\ntype Web = {\n  density: number;\n  isStill: boolean;\n  inks: Inks;\n  width: number;\n  height: number;\n  count: number;\n  x: Float32Array;\n  y: Float32Array;\n  speedX: Float32Array;\n  speedY: Float32Array;\n  /** Where each node is drawn: its place, moved a little towards the pointer when one is near. */\n  shownX: Float32Array;\n  shownY: Float32Array;\n  pointer: Point;\n  hasPointer: boolean;\n  /** How much the pointer is there, from 0 to 1: its links fade in and out with it. */\n  presence: number;\n  random: () => number;\n};\n\nfunction webOf(density: number): Web {\n  return {\n    density: Math.max(0, density),\n    isStill: false,\n    inks: NO_INKS,\n    width: 0,\n    height: 0,\n    count: 0,\n    x: new Float32Array(MAX_NODES),\n    y: new Float32Array(MAX_NODES),\n    speedX: new Float32Array(MAX_NODES),\n    speedY: new Float32Array(MAX_NODES),\n    shownX: new Float32Array(MAX_NODES),\n    shownY: new Float32Array(MAX_NODES),\n    pointer: { x: 0, y: 0 },\n    hasPointer: false,\n    presence: 0,\n    random: randomOf(SEED),\n  };\n}\n\nfunction countFor(web: Web) {\n  const area = web.width * web.height;\n  if (area === 0) {\n    return 0;\n  }\n  const roomy = area / AREA_PER_NODE;\n  const exact = roomy * web.density;\n  const whole = Math.round(exact);\n  return clamp(whole, MIN_NODES, MAX_NODES);\n}\n\nfunction placeNode(web: Web, index: number) {\n  const heading = web.random() * FULL_TURN;\n  const spread = web.random() * SPEED_SPREAD_PX;\n  const speed = MIN_SPEED_PX + spread;\n  web.x[index] = web.random() * web.width;\n  web.y[index] = web.random() * web.height;\n  web.speedX[index] = Math.cos(heading) * speed;\n  web.speedY[index] = Math.sin(heading) * speed;\n}\n\n/** A new size keeps the nodes it has: those left outside come back in, and only the missing ones are placed. */\nfunction layWeb(web: Web, surface: Surface, isStill: boolean) {\n  web.width = surface.width;\n  web.height = surface.height;\n  web.isStill = isStill;\n  web.hasPointer = false;\n  web.presence = 0;\n  const count = countFor(web);\n  for (let index = 0; index < count; index += 1) {\n    const isNew = index >= web.count;\n    if (isNew) {\n      placeNode(web, index);\n    }\n    web.x[index] = clamp(web.x[index], 0, web.width);\n    web.y[index] = clamp(web.y[index], 0, web.height);\n  }\n  web.count = count;\n}\n\n/** One step along one axis: a node that reaches an edge turns back. */\nfunction driftAlong(\n  places: Float32Array,\n  speeds: Float32Array,\n  index: number,\n  extent: number,\n  seconds: number\n) {\n  const travel = speeds[index] * seconds;\n  const moved = places[index] + travel;\n  const isOutside = moved < 0 || moved > extent;\n  if (isOutside) {\n    speeds[index] = -speeds[index];\n  }\n  places[index] = clamp(moved, 0, extent);\n}\n\nfunction moveNodes(web: Web, seconds: number) {\n  for (let index = 0; index < web.count; index += 1) {\n    driftAlong(web.x, web.speedX, index, web.width, seconds);\n    driftAlong(web.y, web.speedY, index, web.height, seconds);\n  }\n}\n\nfunction followPointer(web: Web, seconds: number) {\n  const share = shareOf(seconds, PRESENCE_EASE_S);\n  const goal = Number(web.hasPointer);\n  web.presence = towards(web.presence, goal, share);\n}\n\n/** How near the pointer something at this distance is: 1 on it, 0 at its reach and beyond. */\nfunction nearnessAt(distance: number) {\n  const farness = distance / POINTER_REACH_PX;\n  const nearness = 1 - farness;\n  return Math.max(0, nearness);\n}\n\nfunction nearnessOf(web: Web, index: number) {\n  const offsetX = web.pointer.x - web.x[index];\n  const offsetY = web.pointer.y - web.y[index];\n  const distance = Math.hypot(offsetX, offsetY);\n  return nearnessAt(distance);\n}\n\n/** A node is drawn where it is, or a little closer to the pointer, never past it. */\nfunction placeShown(web: Web, index: number) {\n  const x = web.x[index];\n  const y = web.y[index];\n  web.shownX[index] = x;\n  web.shownY[index] = y;\n  const isPointerGone = web.presence < GONE;\n  if (isPointerGone) {\n    return;\n  }\n  const offsetX = web.pointer.x - x;\n  const offsetY = web.pointer.y - y;\n  const distance = Math.hypot(offsetX, offsetY);\n  const nearness = nearnessAt(distance);\n  const eased = nearness * nearness;\n  const strength = eased * web.presence;\n  const wanted = strength * POINTER_PULL_PX;\n  const pull = Math.min(distance, wanted);\n  const reach = Math.max(distance, 1);\n  const share = pull / reach;\n  const pulledX = offsetX * share;\n  const pulledY = offsetY * share;\n  web.shownX[index] = x + pulledX;\n  web.shownY[index] = y + pulledY;\n}\n\nfunction strokeLink(\n  pen: CanvasRenderingContext2D,\n  fromX: number,\n  fromY: number,\n  toX: number,\n  toY: number,\n  alpha: number\n) {\n  pen.globalAlpha = alpha;\n  pen.beginPath();\n  pen.moveTo(fromX, fromY);\n  pen.lineTo(toX, toY);\n  pen.stroke();\n}\n\nfunction drawLinkBetween(\n  pen: CanvasRenderingContext2D,\n  web: Web,\n  first: number,\n  second: number\n) {\n  const offsetX = web.shownX[second] - web.shownX[first];\n  const offsetY = web.shownY[second] - web.shownY[first];\n  const isFarAcross = Math.abs(offsetX) > LINK_PX;\n  const isFarDown = Math.abs(offsetY) > LINK_PX;\n  if (isFarAcross || isFarDown) {\n    return;\n  }\n  const distance = Math.hypot(offsetX, offsetY);\n  const farness = distance / LINK_PX;\n  const nearness = 1 - farness;\n  if (nearness <= 0) {\n    return;\n  }\n  const alpha = nearness * LINK_ALPHA;\n  strokeLink(\n    pen,\n    web.shownX[first],\n    web.shownY[first],\n    web.shownX[second],\n    web.shownY[second],\n    alpha\n  );\n}\n\n/** Links between the nodes, in the neutral of the theme: the closer two nodes, the more ink. */\nfunction drawLinks(pen: CanvasRenderingContext2D, web: Web) {\n  pen.strokeStyle = web.inks.line;\n  for (let first = 0; first < web.count; first += 1) {\n    for (let second = first + 1; second < web.count; second += 1) {\n      drawLinkBetween(pen, web, first, second);\n    }\n  }\n}\n\n/** The pointer is a node too: it links, in the hue, to every node within its reach. */\nfunction drawPointer(pen: CanvasRenderingContext2D, web: Web) {\n  const isPointerGone = web.presence < GONE;\n  if (isPointerGone) {\n    return;\n  }\n  const { pointer } = web;\n  const fullAlpha = POINTER_ALPHA * web.presence;\n  pen.strokeStyle = web.inks.hue;\n  for (let index = 0; index < web.count; index += 1) {\n    const nearness = nearnessOf(web, index);\n    if (nearness > 0) {\n      const alpha = nearness * fullAlpha;\n      strokeLink(\n        pen,\n        pointer.x,\n        pointer.y,\n        web.shownX[index],\n        web.shownY[index],\n        alpha\n      );\n    }\n  }\n  pen.fillStyle = web.inks.hue;\n  pen.globalAlpha = web.presence * NODE_ALPHA;\n  pen.beginPath();\n  pen.arc(pointer.x, pointer.y, POINTER_RADIUS, 0, FULL_TURN);\n  pen.fill();\n}\n\nfunction drawNodes(pen: CanvasRenderingContext2D, web: Web) {\n  pen.fillStyle = web.inks.hue;\n  pen.globalAlpha = NODE_ALPHA;\n  pen.beginPath();\n  for (let index = 0; index < web.count; index += 1) {\n    const x = web.shownX[index];\n    const y = web.shownY[index];\n    const start = x + NODE_RADIUS;\n    pen.moveTo(start, y);\n    pen.arc(x, y, NODE_RADIUS, 0, FULL_TURN);\n  }\n  pen.fill();\n}\n\n/** Every node moves a little each frame, so the whole web is drawn again: a few dozen lines and dots. */\nfunction drawWeb(web: Web, surface: Surface) {\n  const { pen } = surface;\n  wipeSurface(surface);\n  for (let index = 0; index < web.count; index += 1) {\n    placeShown(web, index);\n  }\n  pen.lineWidth = 1 / surface.pixelRatio;\n  drawLinks(pen, web);\n  drawPointer(pen, web);\n  drawNodes(pen, web);\n}\n\nfunction stepWeb(web: Web, surface: Surface, seconds: number) {\n  if (web.isStill) {\n    return AT_REST;\n  }\n  moveNodes(web, seconds);\n  followPointer(web, seconds);\n  drawWeb(web, surface);\n  return NEXT_FRAME;\n}\n\nfunction createNodeScene(density: number): Scene {\n  const web = webOf(density);\n  return {\n    ink(inks) {\n      web.inks = inks;\n    },\n    fit(surface, isStill) {\n      layWeb(web, surface, isStill);\n    },\n    draw(surface) {\n      drawWeb(web, surface);\n    },\n    step(surface, seconds) {\n      return stepWeb(web, surface, seconds);\n    },\n    pointerMoved(x, y) {\n      web.pointer.x = x;\n      web.pointer.y = y;\n      web.hasPointer = true;\n    },\n    pointerLeft() {\n      web.hasPointer = false;\n    },\n  };\n}\n\nexport type NodeFieldProps = {\n  className?: string;\n  /** Any colour token, such as `var(--primary)` or `var(--series-2)`. The nodes and the links of the pointer wear it. */\n  hue?: string;\n  /** How crowded the field is: 1 is one node for every 12000 square pixels, 2 is twice as many. */\n  density?: number;\n  /** The pointer joins the field as a node. */\n  interactive?: boolean;\n};\n\n/**\n * Nodes that drift on one canvas and link with a hairline when two of them come close: the closer\n * they are, the more ink the link takes. The pointer joins as a node, links to its neighbours in\n * the hue and tugs them a little. It fills its parent, which must be positioned. Under reduced\n * motion the nodes stand still, their links drawn.\n */\nexport function NodeField({\n  className,\n  hue = DEFAULT_HUE,\n  density = 1,\n  interactive = true,\n}: NodeFieldProps) {\n  const scene = useMemo(() => createNodeScene(density), [density]);\n\n  return (\n    <StageCanvas\n      scene={scene}\n      hue={hue}\n      isInteractive={interactive}\n      className={className}\n    />\n  );\n}\n\nexport default function Background08() {\n  const { hue, nextHue } = useHueCycle();\n\n  return (\n    <DemoBand\n      title='Nodes that link when they come close'\n      background={<NodeField hue={hue} />}\n    >\n      <Button onClick={nextHue}>Change hue</Button>\n    </DemoBand>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/background/background-08.tsx"
    },
    {
      "path": "registry/ndeko/components/background/_parts/demo-band.tsx",
      "content": "import { useId, type ReactNode, type Ref } from 'react';\n\nimport { cn } from '@/registry/ndeko/lib/utils';\n\ntype BandAlign = 'center' | 'end';\n\nconst ALIGNMENTS: Record<BandAlign, string> = {\n  center: 'justify-center',\n  end: 'justify-end pb-10',\n};\n\ntype DemoBandProps = {\n  ref?: Ref<HTMLElement>;\n  title: string;\n  /** The background under test. It fills the band, behind the headline and the actions. */\n  background: ReactNode;\n  /** `end` sets the headline at the foot of the band and leaves its middle to the background. */\n  align?: BandAlign;\n  children: ReactNode;\n};\n\n/**\n * The band every background is shown in: the page colour, a headline in the foreground colour and\n * the actions laid straight over the background, to prove that text stays readable above it.\n */\nexport function DemoBand({\n  ref,\n  title,\n  background,\n  align = 'center',\n  children,\n}: DemoBandProps) {\n  const titleId = useId();\n\n  return (\n    <section\n      ref={ref}\n      aria-labelledby={titleId}\n      className={cn(\n        'bg-background @container relative isolate flex h-90 w-full flex-col items-center overflow-hidden rounded-xl',\n        ALIGNMENTS[align]\n      )}\n    >\n      {background}\n      <div className='relative flex max-w-lg flex-col items-center gap-5 px-6 text-center'>\n        <h3\n          id={titleId}\n          className='text-2xl font-semibold text-balance @lg:text-3xl'\n        >\n          {title}\n        </h3>\n        <div className='flex flex-wrap items-center justify-center gap-2'>\n          {children}\n        </div>\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/demo-band.tsx"
    },
    {
      "path": "registry/ndeko/components/background/_parts/frame-loop.ts",
      "content": "const FRAMES_PER_SECOND = 30;\n\nconst FRAME_MS = 1000 / FRAMES_PER_SECOND;\n\n/**\n * The timer between two frames ends this long before the next one is due, so the frame it asks for\n * lands on the screen refresh that is due and not on the one after.\n */\nconst REFRESH_LEAD_MS = 5;\n\nconst TIMER_MS = FRAME_MS - REFRESH_LEAD_MS;\n\n/** A frame that comes late, after a long task, moves the picture no further than this long would. */\nconst MAX_STEP_S = 0.05;\n\n/** What a frame answers while the picture still moves: the next one comes as soon as the cap allows. */\nexport const NEXT_FRAME = 0;\n\n/** What a frame answers once the picture has come to rest: nothing is asked for until `wake`. */\nexport const AT_REST = Number.POSITIVE_INFINITY;\n\n/** Draws one frame, `seconds` after the last one, and answers how many milliseconds it can wait for the next. */\nexport type FrameHandler = (seconds: number, now: number) => number;\n\nexport type FrameLoop = {\n  start: () => void;\n  stop: () => void;\n  wake: () => void;\n};\n\n/**\n * Frames at thirty a second at most. Between two frames there is one timer and no animation frame,\n * so a 120 Hz screen does not run four rounds of main-thread work for one frame drawn. Stopped or\n * at rest, the loop holds no timer and asks for no frame.\n */\nexport function createFrameLoop(onFrame: FrameHandler): FrameLoop {\n  let frame = 0;\n  let timer: ReturnType<typeof setTimeout> | undefined;\n  let isStarted = false;\n  let lastFrameAt = 0;\n\n  function clearPending() {\n    cancelAnimationFrame(frame);\n    clearTimeout(timer);\n    frame = 0;\n    timer = undefined;\n  }\n\n  function askForFrame() {\n    timer = undefined;\n    frame = requestAnimationFrame(tick);\n  }\n\n  function schedule(wait: number) {\n    clearPending();\n    const now = performance.now();\n    const sinceFrame = now - lastFrameAt;\n    const untilCap = TIMER_MS - sinceFrame;\n    const delay = Math.max(wait, untilCap);\n    if (delay <= 0) {\n      askForFrame();\n      return;\n    }\n    timer = setTimeout(askForFrame, delay);\n  }\n\n  function tick(now: number) {\n    frame = 0;\n    const elapsedMs = now - lastFrameAt;\n    const elapsed = elapsedMs / 1000;\n    const forward = Math.max(0, elapsed);\n    const seconds = Math.min(forward, MAX_STEP_S);\n    lastFrameAt = now;\n    const wait = onFrame(seconds, now);\n    const isResting = wait === AT_REST;\n    if (isResting || !isStarted) {\n      return;\n    }\n    schedule(wait);\n  }\n\n  function start() {\n    if (isStarted) {\n      return;\n    }\n    const now = performance.now();\n    isStarted = true;\n    lastFrameAt = now - FRAME_MS;\n    schedule(NEXT_FRAME);\n  }\n\n  function stop() {\n    isStarted = false;\n    clearPending();\n  }\n\n  function wake() {\n    const isFrameComing = frame !== 0;\n    if (!isStarted || isFrameComing) {\n      return;\n    }\n    schedule(NEXT_FRAME);\n  }\n\n  return { start, stop, wake };\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/frame-loop.ts"
    },
    {
      "path": "registry/ndeko/components/background/_parts/hue.ts",
      "content": "'use client';\n\nimport { useState } from 'react';\n\n/** A background wears one hue: the brand unless it is told otherwise. */\nexport const DEFAULT_HUE = 'var(--primary)';\n\n/** The brand first, then the series hues that differ from it. */\nconst DEMO_HUES = [\n  DEFAULT_HUE,\n  'var(--series-2)',\n  'var(--series-3)',\n  'var(--series-4)',\n  'var(--series-5)',\n];\n\n/** The hue of a demo, and the step to the next one, to show a background in each hue it accepts. */\nexport function useHueCycle() {\n  const [index, setIndex] = useState(0);\n  const hue = DEMO_HUES[index] ?? DEFAULT_HUE;\n\n  function nextHue() {\n    setIndex((current) => {\n      const next = current + 1;\n      return next % DEMO_HUES.length;\n    });\n  }\n\n  return { hue, nextHue };\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/hue.ts"
    },
    {
      "path": "registry/ndeko/components/background/_parts/math.ts",
      "content": "export type Point = {\n  x: number;\n  y: number;\n};\n\nexport function clamp(value: number, low: number, high: number) {\n  const raised = Math.max(low, value);\n  return Math.min(high, raised);\n}\n\n/** How much of the way left is covered in `seconds`: the same glide whatever the frame rate. */\nexport function shareOf(seconds: number, easeSeconds: number) {\n  const ratio = seconds / easeSeconds;\n  const decay = Math.exp(-ratio);\n  return 1 - decay;\n}\n\nexport function towards(from: number, to: number, share: number) {\n  const gap = to - from;\n  const step = gap * share;\n  return from + step;\n}\n\n/** From 0 at `low` to 1 at `high`, leaving and arriving without a corner. */\nexport function smoothstep(low: number, high: number, value: number) {\n  const span = high - low;\n  const offset = value - low;\n  const raw = offset / span;\n  const share = clamp(raw, 0, 1);\n  const doubled = 2 * share;\n  const curve = 3 - doubled;\n  const squared = share * share;\n  return squared * curve;\n}\n\n/** The damping that brings a spring of this stiffness to its target without passing it. */\nexport function dampingOf(stiffness: number) {\n  const root = Math.sqrt(stiffness);\n  return 2 * root;\n}\n\n/**\n * The speed of a spring one step later. The caller then moves the value by that speed over the\n * same step, so the value follows its target and settles on it.\n */\nexport function springSpeedOf(\n  gap: number,\n  speed: number,\n  stiffness: number,\n  damping: number,\n  seconds: number\n) {\n  const pull = gap * stiffness;\n  const drag = speed * damping;\n  const push = pull - drag;\n  const gain = push * seconds;\n  return speed + gain;\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/math.ts"
    },
    {
      "path": "registry/ndeko/components/background/_parts/pointer.ts",
      "content": "import type { Point } from './math';\n\n/**\n * Where a point of the window falls inside `host`, in the pixels of its own box, or null when it\n * falls outside. A host that an ancestor scales still answers in its own pixels.\n */\nexport function placeIn(\n  host: HTMLElement,\n  clientX: number,\n  clientY: number\n): Point | null {\n  const box = host.getBoundingClientRect();\n  const isEmpty = box.width === 0 || box.height === 0;\n  if (isEmpty) {\n    return null;\n  }\n  const isInsideX = clientX >= box.left && clientX <= box.right;\n  const isInsideY = clientY >= box.top && clientY <= box.bottom;\n  if (!isInsideX || !isInsideY) {\n    return null;\n  }\n  const scaleX = host.offsetWidth / box.width;\n  const scaleY = host.offsetHeight / box.height;\n  const offsetX = clientX - box.left;\n  const offsetY = clientY - box.top;\n  const x = offsetX * scaleX;\n  const y = offsetY * scaleY;\n  return { x, y };\n}\n\n/** Where the centre of `element` falls inside `host`, or null when it falls outside. */\nexport function centreIn(host: HTMLElement, element: Element) {\n  const box = element.getBoundingClientRect();\n  const halfWidth = box.width / 2;\n  const halfHeight = box.height / 2;\n  const centreX = box.left + halfWidth;\n  const centreY = box.top + halfHeight;\n  return placeIn(host, centreX, centreY);\n}\n\n/**\n * Follows a pointer that can hover, from the window: content laid over a background takes the\n * pointer events of its own box, so a listener on the background would go blind under a headline.\n * The place is read once a frame at most, and only on a frame where the pointer moved. `onMove`\n * gets the place inside `host`; `onLeave` is called once when the pointer goes out of it. The\n * function returned ends the watch, and lets go of a pointer that was still over the host.\n */\nexport function watchPointer(\n  host: HTMLElement,\n  onMove: (x: number, y: number) => void,\n  onLeave: () => void\n) {\n  let clientX = 0;\n  let clientY = 0;\n  let frame = 0;\n  let isOver = false;\n\n  function leave() {\n    if (!isOver) {\n      return;\n    }\n    isOver = false;\n    onLeave();\n  }\n\n  function read() {\n    frame = 0;\n    const place = placeIn(host, clientX, clientY);\n    if (!place) {\n      leave();\n      return;\n    }\n    isOver = true;\n    onMove(place.x, place.y);\n  }\n\n  function onPointerMove(event: PointerEvent) {\n    const isTouch = event.pointerType === 'touch';\n    if (isTouch) {\n      return;\n    }\n    clientX = event.clientX;\n    clientY = event.clientY;\n    const isReadComing = frame !== 0;\n    if (isReadComing) {\n      return;\n    }\n    frame = requestAnimationFrame(read);\n  }\n\n  function onPointerGone() {\n    cancelAnimationFrame(frame);\n    frame = 0;\n    leave();\n  }\n\n  const root = document.documentElement;\n  window.addEventListener('pointermove', onPointerMove, { passive: true });\n  root.addEventListener('pointerleave', onPointerGone, { passive: true });\n\n  return () => {\n    window.removeEventListener('pointermove', onPointerMove);\n    root.removeEventListener('pointerleave', onPointerGone);\n    onPointerGone();\n  };\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/pointer.ts"
    },
    {
      "path": "registry/ndeko/components/background/_parts/seeded.ts",
      "content": "const STREAM_STEP = 0x6d2b79f5;\n\nconst UINT32_RANGE = 4294967296;\n\nconst FIRST_PRIME = 73856093;\n\nconst SECOND_PRIME = 19349663;\n\n/**\n * A stream of numbers from 0 to 1, the same for the same seed on the server and in the browser, so\n * a generated picture never changes between two renders.\n */\nexport function randomOf(seed: number) {\n  let state = seed >>> 0;\n\n  function next() {\n    const stepped = state + STREAM_STEP;\n    state = stepped >>> 0;\n    const shifted = state >>> 15;\n    const folded = state ^ shifted;\n    const odd = state | 1;\n    const first = Math.imul(folded, odd);\n    const reshifted = first >>> 7;\n    const refolded = first ^ reshifted;\n    const salted = first | 61;\n    const second = Math.imul(refolded, salted);\n    const summed = first + second;\n    const mixed = first ^ summed;\n    const lastShift = mixed >>> 14;\n    const settled = mixed ^ lastShift;\n    const positive = settled >>> 0;\n    return positive / UINT32_RANGE;\n  }\n\n  return next;\n}\n\n/** One seed out of two numbers, so each part of a picture draws from a stream of its own. */\nexport function seedOf(first: number, second: number) {\n  const firstHash = Math.imul(first, FIRST_PRIME);\n  const secondHash = Math.imul(second, SECOND_PRIME);\n  const mixed = firstHash ^ secondHash;\n  return mixed >>> 0;\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/seeded.ts"
    },
    {
      "path": "registry/ndeko/components/background/_parts/showing.ts",
      "content": "/**\n * A background sits behind content for as long as the page is open, so everything it runs hangs on\n * this answer: its box touches the screen and the tab is the one looked at. `onChange` is called\n * each time the answer changes, starting from \"not showing\". The function returned ends the watch.\n */\nexport function watchShowing(\n  element: Element,\n  onChange: (isShowing: boolean) => void\n) {\n  let isOnScreen = false;\n  let wasShowing = false;\n\n  function report() {\n    const isTabShown = document.visibilityState === 'visible';\n    const isShowing = isOnScreen && isTabShown;\n    if (isShowing === wasShowing) {\n      return;\n    }\n    wasShowing = isShowing;\n    onChange(isShowing);\n  }\n\n  function onCrossing(entries: IntersectionObserverEntry[]) {\n    const latest = entries.at(-1);\n    if (!latest) {\n      return;\n    }\n    isOnScreen = latest.isIntersecting;\n    report();\n  }\n\n  const observer = new IntersectionObserver(onCrossing);\n  observer.observe(element);\n  document.addEventListener('visibilitychange', report);\n\n  return () => {\n    observer.disconnect();\n    document.removeEventListener('visibilitychange', report);\n  };\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/showing.ts"
    },
    {
      "path": "registry/ndeko/components/background/_parts/stage-canvas.tsx",
      "content": "'use client';\n\nimport { useEffect, useImperativeHandle, useRef, type Ref } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nimport type { Point } from './math';\nimport { mountStage, type Scene, type Stage } from './stage';\n\nexport type StageHandle = {\n  /** Something outside the loop changed the scene: it is drawn again, and its loop is woken. */\n  refresh: () => void;\n  /** Where the centre of an element sits on the canvas, or null when it is outside. */\n  centreOf: (element: Element) => Point | null;\n};\n\ntype StageCanvasProps = {\n  scene: Scene;\n  hue: string;\n  isInteractive?: boolean;\n  className?: string;\n  handleRef?: Ref<StageHandle>;\n};\n\n/**\n * The one canvas of a background. It fills its parent, lets every pointer event through and is\n * hidden from assistive technology, since it is decoration. A canvas cannot take a colour from a\n * class: the hue rides on its `color` and the neutral of lines on `decoration-foreground`, and the\n * stage reads both from the computed style. No transition may sit on those two, or the stage\n * would read a colour halfway through it.\n */\nexport function StageCanvas({\n  scene,\n  hue,\n  isInteractive = false,\n  className,\n  handleRef,\n}: StageCanvasProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const stage = useRef<Stage | null>(null);\n  const shouldReduceMotion = usePrefersReducedMotion();\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) {\n      return;\n    }\n    const mounted = mountStage(canvas, scene, {\n      isStill: shouldReduceMotion,\n      isInteractive,\n    });\n    stage.current = mounted;\n    return () => {\n      mounted?.dispose();\n      stage.current = null;\n    };\n  }, [scene, isInteractive, shouldReduceMotion]);\n\n  useEffect(() => {\n    stage.current?.reink();\n  }, [hue]);\n\n  useImperativeHandle(\n    handleRef,\n    () => ({\n      refresh: () => stage.current?.refresh(),\n      centreOf: (element) => stage.current?.centreOf(element) ?? null,\n    }),\n    []\n  );\n\n  return (\n    <canvas\n      ref={canvasRef}\n      aria-hidden='true'\n      style={{ color: hue }}\n      className={cn(\n        'decoration-foreground pointer-events-none absolute inset-0 size-full',\n        className\n      )}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/stage-canvas.tsx"
    },
    {
      "path": "registry/ndeko/components/background/_parts/stage.ts",
      "content": "import { createFrameLoop } from './frame-loop';\nimport type { Point } from './math';\nimport { centreIn, watchPointer } from './pointer';\nimport { watchShowing } from './showing';\nimport {\n  createSurface,\n  fitSurface,\n  hasSameInks,\n  inksOf,\n  isFitted,\n  watchSurface,\n  type Inks,\n  type Surface,\n} from './surface';\n\n/** What a background draws on its canvas. The stage decides when; the scene only knows how. */\nexport type Scene = {\n  /** The colours changed: the theme, or the hue. */\n  ink: (inks: Inks) => void;\n  /**\n   * The box changed, and the canvas was wiped: what depends on its size is built again. `isStill`\n   * is the reduced motion setting: the scene then holds its most telling picture and never moves.\n   */\n  fit: (surface: Surface, isStill: boolean) => void;\n  /** The whole picture as it stands. */\n  draw: (surface: Surface) => void;\n  /**\n   * Moves the picture on by `seconds` and draws what changed. It answers how long it can wait for\n   * its next frame: `NEXT_FRAME`, `AT_REST`, or a number of milliseconds.\n   */\n  step: (surface: Surface, seconds: number, now: number) => number;\n  pointerMoved?: (x: number, y: number) => void;\n  pointerLeft?: () => void;\n};\n\nexport type Stage = {\n  /** Something outside the loop changed the scene: it is drawn again, and its loop is woken. */\n  refresh: () => void;\n  /** Reads the colours again, and draws again when they changed. */\n  reink: () => void;\n  /** Where the centre of an element sits on the canvas, or null when it is outside. */\n  centreOf: (element: Element) => Point | null;\n  dispose: () => void;\n};\n\nexport type StageOptions = {\n  /** Reduced motion: no pointer is followed and nothing moves by itself. */\n  isStill: boolean;\n  isInteractive: boolean;\n};\n\nfunction idle() {}\n\nfunction stageOf(\n  surface: Surface,\n  scene: Scene,\n  { isStill, isInteractive }: StageOptions\n): Stage {\n  const { canvas } = surface;\n  const followsPointer = isInteractive && !isStill;\n  let isShowing = false;\n  let stopPointer = idle;\n  let inks = inksOf(canvas);\n\n  const loop = createFrameLoop((seconds, now) =>\n    scene.step(surface, seconds, now)\n  );\n\n  function wake() {\n    if (isShowing) {\n      loop.wake();\n    }\n  }\n\n  function lay() {\n    fitSurface(surface);\n    scene.fit(surface, isStill);\n    scene.draw(surface);\n  }\n\n  function onPointerMove(x: number, y: number) {\n    scene.pointerMoved?.(x, y);\n    wake();\n  }\n\n  function onPointerLeave() {\n    scene.pointerLeft?.();\n    wake();\n  }\n\n  function show() {\n    isShowing = true;\n    if (followsPointer) {\n      stopPointer = watchPointer(canvas, onPointerMove, onPointerLeave);\n    }\n    loop.start();\n  }\n\n  function hide() {\n    isShowing = false;\n    stopPointer();\n    stopPointer = idle;\n    loop.stop();\n  }\n\n  function onShowing(isNowShowing: boolean) {\n    if (isNowShowing) {\n      show();\n      return;\n    }\n    hide();\n  }\n\n  function onResize() {\n    if (isFitted(surface)) {\n      return;\n    }\n    lay();\n    wake();\n  }\n\n  function reink() {\n    const latest = inksOf(canvas);\n    if (hasSameInks(inks, latest)) {\n      return;\n    }\n    inks = latest;\n    scene.ink(inks);\n    scene.draw(surface);\n    wake();\n  }\n\n  /** In the loop, the next frame draws the change. Out of it, nothing would, so it is drawn here. */\n  function refresh() {\n    const isOutOfLoop = isStill || !isShowing;\n    if (isOutOfLoop) {\n      scene.draw(surface);\n    }\n    wake();\n  }\n\n  scene.ink(inks);\n  lay();\n  const stopSurface = watchSurface(canvas, onResize, reink);\n  const stopShowing = watchShowing(canvas, onShowing);\n\n  return {\n    refresh,\n    reink,\n    centreOf: (element) => centreIn(canvas, element),\n    dispose() {\n      stopShowing();\n      stopSurface();\n      hide();\n    },\n  };\n}\n\n/**\n * Puts a scene on a canvas. The stage sizes the canvas for the screen, reads its colours, and runs\n * the loop of the scene only while the canvas is on screen in a visible tab. It draws one frame\n * when it is set up, so the picture is there before anything moves.\n */\nexport function mountStage(\n  canvas: HTMLCanvasElement,\n  scene: Scene,\n  options: StageOptions\n) {\n  const surface = createSurface(canvas);\n  if (!surface) {\n    return null;\n  }\n  return stageOf(surface, scene, options);\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/stage.ts"
    },
    {
      "path": "registry/ndeko/components/background/_parts/surface.ts",
      "content": "/** A backing store above two device pixels a point costs four times the fill for a picture nobody sees sharper. */\nconst MAX_PIXEL_RATIO = 2;\n\nconst THEME_WATCH = {\n  attributes: true,\n  attributeFilter: ['class', 'data-theme'],\n};\n\nconst DARK_SCHEME = '(prefers-color-scheme: dark)';\n\nexport type Surface = {\n  canvas: HTMLCanvasElement;\n  pen: CanvasRenderingContext2D;\n  /** The box of the canvas in CSS pixels: one unit drawn is one of them, whatever the screen. */\n  width: number;\n  height: number;\n  pixelRatio: number;\n};\n\nexport type Inks = {\n  /** The hue of the background, resolved by the browser for the current theme. */\n  hue: string;\n  /** The neutral of lines: the foreground of the theme. */\n  line: string;\n};\n\nexport function createSurface(canvas: HTMLCanvasElement): Surface | null {\n  const pen = canvas.getContext('2d');\n  if (!pen) {\n    return null;\n  }\n  return { canvas, pen, width: 0, height: 0, pixelRatio: 1 };\n}\n\nfunction pixelRatioOf() {\n  const devicePixelRatio = window.devicePixelRatio || 1;\n  return Math.min(devicePixelRatio, MAX_PIXEL_RATIO);\n}\n\n/** Sizes the backing store to the box of the canvas and to the screen. It also wipes the canvas. */\nexport function fitSurface(surface: Surface) {\n  const { canvas, pen } = surface;\n  const pixelRatio = pixelRatioOf();\n  const width = canvas.clientWidth;\n  const height = canvas.clientHeight;\n  const deviceWidth = width * pixelRatio;\n  const deviceHeight = height * pixelRatio;\n  surface.width = width;\n  surface.height = height;\n  surface.pixelRatio = pixelRatio;\n  canvas.width = Math.round(deviceWidth);\n  canvas.height = Math.round(deviceHeight);\n  pen.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);\n}\n\nexport function isFitted(surface: Surface) {\n  const { canvas } = surface;\n  const hasWidth = surface.width === canvas.clientWidth;\n  const hasHeight = surface.height === canvas.clientHeight;\n  const hasRatio = surface.pixelRatio === pixelRatioOf();\n  const hasSize = hasWidth && hasHeight;\n  return hasSize && hasRatio;\n}\n\nexport function wipeSurface(surface: Surface) {\n  surface.pen.globalAlpha = 1;\n  surface.pen.clearRect(0, 0, surface.width, surface.height);\n}\n\n/**\n * A canvas cannot take a colour from a class, so it reads its colours from its own computed style,\n * where the browser has already resolved the tokens for the current theme. The hue rides on\n * `color` and the neutral of lines on `text-decoration-color`, which paints nothing on a canvas.\n */\nexport function inksOf(canvas: HTMLCanvasElement): Inks {\n  const style = getComputedStyle(canvas);\n  return { hue: style.color, line: style.textDecorationColor };\n}\n\nexport function hasSameInks(first: Inks, second: Inks) {\n  const hasSameHue = first.hue === second.hue;\n  const hasSameLine = first.line === second.line;\n  return hasSameHue && hasSameLine;\n}\n\nfunction densityQueryOf() {\n  return `(resolution: ${window.devicePixelRatio}dppx)`;\n}\n\n/**\n * Calls `onResize` when the box of the canvas or the density of the screen changes, and `onTheme`\n * when the page may have changed theme: a class or an attribute on the root or the body, or the\n * system setting. The function returned ends the watch.\n */\nexport function watchSurface(\n  canvas: HTMLCanvasElement,\n  onResize: () => void,\n  onTheme: () => void\n) {\n  const sizeObserver = new ResizeObserver(() => onResize());\n  const themeObserver = new MutationObserver(() => onTheme());\n  const scheme = window.matchMedia(DARK_SCHEME);\n  let density = window.matchMedia(densityQueryOf());\n\n  function onScheme() {\n    onTheme();\n  }\n\n  /** The query names one density: once the screen has left it, a new query watches the next one. */\n  function onDensity() {\n    density.removeEventListener('change', onDensity);\n    density = window.matchMedia(densityQueryOf());\n    density.addEventListener('change', onDensity);\n    onResize();\n  }\n\n  sizeObserver.observe(canvas);\n  themeObserver.observe(document.documentElement, THEME_WATCH);\n  themeObserver.observe(document.body, THEME_WATCH);\n  scheme.addEventListener('change', onScheme);\n  density.addEventListener('change', onDensity);\n\n  return () => {\n    sizeObserver.disconnect();\n    themeObserver.disconnect();\n    scheme.removeEventListener('change', onScheme);\n    density.removeEventListener('change', onDensity);\n  };\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/surface.ts"
    }
  ],
  "categories": [
    "components",
    "background"
  ],
  "type": "registry:component"
}