{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "background-02",
  "title": "Cube field",
  "description": "A floor of small isometric cubes behind a hero, where the cubes under the pointer rise on their own springs and take the hue as they lift.",
  "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-02.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 {\n  clamp,\n  dampingOf,\n  smoothstep,\n  springSpeedOf,\n  type Point,\n} from './_parts/math';\nimport type { Scene } from './_parts/stage';\nimport { StageCanvas } from './_parts/stage-canvas';\nimport { wipeSurface, type Inks, type Surface } from './_parts/surface';\n\nconst DEFAULT_PITCH = 20;\n\nconst MIN_PITCH = 10;\n\n/**\n * A cube stands half a pitch at most. At that height it reaches the edge of its own cell and no\n * further, so one cube is wiped and drawn again without touching a neighbour.\n */\nconst MAX_HEIGHT_SHARE = 0.5;\n\nconst REST_HEIGHT_SHARE = 0.1;\n\n/** How far above its base the cell of a cube starts, as a share of the pitch. */\nconst CELL_ABOVE_SHARE = 0.75;\n\nconst POINTER_REACH_PX = 120;\n\nconst STIFFNESS = 150;\n\nconst DAMPING = dampingOf(STIFFNESS);\n\nconst SETTLED_PX = 0.02;\n\n/** A cube is drawn again once it has moved this far from where it was last drawn. */\nconst REDRAW_PX = 0.08;\n\nconst WAVE_SPEED_PX = 240;\n\nconst WAVE_WIDTH_PX = 72;\n\n/** Past this many widths from its crest the wave lifts nothing. */\nconst WAVE_REACH = 3;\n\nconst WAVE_MARGIN_PX = WAVE_WIDTH_PX * WAVE_REACH;\n\n/** The idle wave lifts a cube to this share of what the pointer does. */\nconst WAVE_SHARE = 0.55;\n\nconst WAVE_PAUSE_MS = 2200;\n\n/** The wave crosses the field at twenty degrees from the horizontal. */\nconst WAVE_ACROSS = 0.94;\n\nconst WAVE_DOWN = 0.34;\n\nconst LEFT_ALPHA = 0.1;\n\nconst RIGHT_ALPHA = 0.17;\n\nconst TOP_ALPHA = 0.06;\n\n/** The hue a top wears at full height. Text sits above the field, so it stays a tint. */\nconst TOP_HUE_ALPHA = 0.55;\n\nconst MIN_LIFT = 0.02;\n\n/** Where the cubes stand risen in the still picture, across and down the box. */\nconst STILL_AT: Point = { x: 0.7, y: 0.42 };\n\nconst NO_INKS: Inks = { hue: 'transparent', line: 'transparent' };\n\ntype CubeField = {\n  pitch: number;\n  half: number;\n  halfWidth: number;\n  halfDepth: number;\n  restHeight: number;\n  maxRise: number;\n  isStill: boolean;\n  inks: Inks;\n  count: number;\n  centreX: Float32Array;\n  centreY: Float32Array;\n  /** How far along the path of the wave each cube sits. */\n  along: Float32Array;\n  rise: Float32Array;\n  speed: Float32Array;\n  drawn: Float32Array;\n  pointer: Point;\n  hasPointer: boolean;\n  isWaving: boolean;\n  waveFront: number;\n  waveEnd: number;\n  nextWaveAt: number;\n};\n\nfunction fieldOf(size: number): CubeField {\n  const pitch = Math.max(MIN_PITCH, size);\n  const half = pitch / 2;\n  const halfWidth = half - 1;\n  const halfDepth = halfWidth / 2;\n  const maxHeight = pitch * MAX_HEIGHT_SHARE;\n  const restHeight = pitch * REST_HEIGHT_SHARE;\n  const maxRise = maxHeight - restHeight;\n  return {\n    pitch,\n    half,\n    halfWidth,\n    halfDepth,\n    restHeight,\n    maxRise,\n    isStill: false,\n    inks: NO_INKS,\n    count: 0,\n    centreX: new Float32Array(0),\n    centreY: new Float32Array(0),\n    along: new Float32Array(0),\n    rise: new Float32Array(0),\n    speed: new Float32Array(0),\n    drawn: new Float32Array(0),\n    pointer: { x: 0, y: 0 },\n    hasPointer: false,\n    isWaving: false,\n    waveFront: 0,\n    waveEnd: 0,\n    nextWaveAt: 0,\n  };\n}\n\nfunction bumpOf(field: CubeField, index: number, pointer: Point) {\n  const offsetX = field.centreX[index] - pointer.x;\n  const offsetY = field.centreY[index] - pointer.y;\n  const isFarAcross = Math.abs(offsetX) > POINTER_REACH_PX;\n  const isFarDown = Math.abs(offsetY) > POINTER_REACH_PX;\n  if (isFarAcross || isFarDown) {\n    return 0;\n  }\n  const distance = Math.hypot(offsetX, offsetY);\n  const nearness = smoothstep(POINTER_REACH_PX, 0, distance);\n  return nearness * field.maxRise;\n}\n\nfunction waveOf(field: CubeField, index: number) {\n  const behind = field.along[index] - field.waveFront;\n  const reach = behind / WAVE_WIDTH_PX;\n  const isOutOfReach = Math.abs(reach) > WAVE_REACH;\n  if (isOutOfReach) {\n    return 0;\n  }\n  const squared = reach * reach;\n  const ridge = Math.exp(-squared);\n  const lift = ridge * WAVE_SHARE;\n  return lift * field.maxRise;\n}\n\nfunction targetOf(field: CubeField, index: number) {\n  let target = 0;\n  if (field.hasPointer) {\n    target += bumpOf(field, index, field.pointer);\n  }\n  if (field.isWaving) {\n    target += waveOf(field, index);\n  }\n  return Math.min(target, field.maxRise);\n}\n\n/** The still picture: the cubes stand as if a pointer rested on them. */\nfunction poseStill(field: CubeField, surface: Surface) {\n  const x = surface.width * STILL_AT.x;\n  const y = surface.height * STILL_AT.y;\n  const pointer = { x, y };\n  for (let index = 0; index < field.count; index += 1) {\n    field.rise[index] = bumpOf(field, index, pointer);\n  }\n}\n\n/**\n * Cubes sit on every other crossing of a grid one pitch wide and half a pitch tall, so their cells\n * tile the box: each cube owns a square of one pitch that no other cube enters.\n */\nfunction layCubes(field: CubeField, surface: Surface, isStill: boolean) {\n  const { pitch, half } = field;\n  const columns = Math.ceil(surface.width / pitch) + 2;\n  const rows = Math.ceil(surface.height / half) + 3;\n  const capacity = columns * rows;\n  const across = surface.width * WAVE_ACROSS;\n  const down = surface.height * WAVE_DOWN;\n  field.centreX = new Float32Array(capacity);\n  field.centreY = new Float32Array(capacity);\n  field.along = new Float32Array(capacity);\n  field.rise = new Float32Array(capacity);\n  field.speed = new Float32Array(capacity);\n  field.drawn = new Float32Array(capacity);\n  let count = 0;\n  for (let row = 0; row < rows; row += 1) {\n    const firstColumn = row % 2;\n    for (let column = firstColumn; column < columns; column += 2) {\n      const x = column * pitch;\n      const y = row * half;\n      const alongX = x * WAVE_ACROSS;\n      const alongY = y * WAVE_DOWN;\n      field.centreX[count] = x;\n      field.centreY[count] = y;\n      field.along[count] = alongX + alongY;\n      count += 1;\n    }\n  }\n  field.count = count;\n  field.isStill = isStill;\n  field.hasPointer = false;\n  field.waveEnd = across + down + WAVE_MARGIN_PX;\n  if (isStill) {\n    poseStill(field, surface);\n  }\n}\n\nfunction traceLeft(\n  pen: CanvasRenderingContext2D,\n  field: CubeField,\n  index: number\n) {\n  const x = field.centreX[index];\n  const base = field.centreY[index];\n  const height = field.restHeight + field.rise[index];\n  const top = base - height;\n  const left = x - field.halfWidth;\n  const topFront = top + field.halfDepth;\n  const baseFront = base + field.halfDepth;\n  pen.moveTo(left, top);\n  pen.lineTo(x, topFront);\n  pen.lineTo(x, baseFront);\n  pen.lineTo(left, base);\n  pen.closePath();\n}\n\nfunction traceRight(\n  pen: CanvasRenderingContext2D,\n  field: CubeField,\n  index: number\n) {\n  const x = field.centreX[index];\n  const base = field.centreY[index];\n  const height = field.restHeight + field.rise[index];\n  const top = base - height;\n  const right = x + field.halfWidth;\n  const topFront = top + field.halfDepth;\n  const baseFront = base + field.halfDepth;\n  pen.moveTo(x, topFront);\n  pen.lineTo(right, top);\n  pen.lineTo(right, base);\n  pen.lineTo(x, baseFront);\n  pen.closePath();\n}\n\nfunction traceTop(\n  pen: CanvasRenderingContext2D,\n  field: CubeField,\n  index: number\n) {\n  const x = field.centreX[index];\n  const height = field.restHeight + field.rise[index];\n  const top = field.centreY[index] - height;\n  const back = top - field.halfDepth;\n  const front = top + field.halfDepth;\n  const left = x - field.halfWidth;\n  const right = x + field.halfWidth;\n  pen.moveTo(x, back);\n  pen.lineTo(right, top);\n  pen.lineTo(x, front);\n  pen.lineTo(left, top);\n  pen.closePath();\n}\n\ntype FaceTracer = typeof traceLeft;\n\n/** The top takes the hue as the cube rises: the neutral says floor, the hue says lifted. */\nfunction tintTop(\n  pen: CanvasRenderingContext2D,\n  field: CubeField,\n  index: number\n) {\n  const lift = field.rise[index] / field.maxRise;\n  if (lift < MIN_LIFT) {\n    return;\n  }\n  pen.fillStyle = field.inks.hue;\n  pen.globalAlpha = lift * TOP_HUE_ALPHA;\n  pen.beginPath();\n  traceTop(pen, field, index);\n  pen.fill();\n}\n\nfunction fillFace(\n  pen: CanvasRenderingContext2D,\n  field: CubeField,\n  index: number,\n  alpha: number,\n  trace: FaceTracer\n) {\n  pen.globalAlpha = alpha;\n  pen.beginPath();\n  trace(pen, field, index);\n  pen.fill();\n}\n\n/** One cube, in its own cell: the cell is wiped, then the three faces are drawn where it now stands. */\nfunction paintCube(\n  pen: CanvasRenderingContext2D,\n  field: CubeField,\n  index: number\n) {\n  const cellLeft = field.centreX[index] - field.half;\n  const cellAbove = field.pitch * CELL_ABOVE_SHARE;\n  const cellTop = field.centreY[index] - cellAbove;\n  pen.globalAlpha = 1;\n  pen.clearRect(cellLeft, cellTop, field.pitch, field.pitch);\n  pen.fillStyle = field.inks.line;\n  fillFace(pen, field, index, LEFT_ALPHA, traceLeft);\n  fillFace(pen, field, index, RIGHT_ALPHA, traceRight);\n  fillFace(pen, field, index, TOP_ALPHA, traceTop);\n  tintTop(pen, field, index);\n  field.drawn[index] = field.rise[index];\n}\n\n/** The same face of every cube in one fill: cubes never overlap, so one path holds them all. */\nfunction fillFaces(\n  pen: CanvasRenderingContext2D,\n  field: CubeField,\n  alpha: number,\n  trace: FaceTracer\n) {\n  pen.globalAlpha = alpha;\n  pen.beginPath();\n  for (let index = 0; index < field.count; index += 1) {\n    trace(pen, field, index);\n  }\n  pen.fill();\n}\n\nfunction drawCubes(field: CubeField, surface: Surface) {\n  const { pen } = surface;\n  wipeSurface(surface);\n  pen.fillStyle = field.inks.line;\n  fillFaces(pen, field, LEFT_ALPHA, traceLeft);\n  fillFaces(pen, field, RIGHT_ALPHA, traceRight);\n  fillFaces(pen, field, TOP_ALPHA, traceTop);\n  for (let index = 0; index < field.count; index += 1) {\n    tintTop(pen, field, index);\n  }\n  field.drawn.set(field.rise);\n}\n\n/** A wave crosses the field, then the field rests for a while before the next one. */\nfunction advanceWave(field: CubeField, seconds: number, now: number) {\n  if (!field.isWaving) {\n    const isDue = now >= field.nextWaveAt;\n    if (isDue) {\n      field.isWaving = true;\n      field.waveFront = -WAVE_MARGIN_PX;\n    }\n    return;\n  }\n  field.waveFront += WAVE_SPEED_PX * seconds;\n  const hasCrossed = field.waveFront > field.waveEnd;\n  if (hasCrossed) {\n    field.isWaving = false;\n    field.nextWaveAt = now + WAVE_PAUSE_MS;\n  }\n}\n\n/** Moves one cube towards its target on a spring that does not bounce. It answers whether it still moves. */\nfunction moveCube(field: CubeField, index: number, seconds: number) {\n  const target = targetOf(field, index);\n  const gap = target - field.rise[index];\n  const speed = springSpeedOf(\n    gap,\n    field.speed[index],\n    STIFFNESS,\n    DAMPING,\n    seconds\n  );\n  const isClose = Math.abs(gap) < SETTLED_PX;\n  const isSlow = Math.abs(speed) < SETTLED_PX;\n  if (isClose && isSlow) {\n    field.rise[index] = target;\n    field.speed[index] = 0;\n    return false;\n  }\n  const travel = speed * seconds;\n  const moved = field.rise[index] + travel;\n  field.rise[index] = clamp(moved, 0, field.maxRise);\n  field.speed[index] = speed;\n  return true;\n}\n\n/** Only the cubes that moved are drawn again. With nothing moving, the loop sleeps until the next wave is due. */\nfunction stepCubes(\n  field: CubeField,\n  surface: Surface,\n  seconds: number,\n  now: number\n) {\n  if (field.isStill) {\n    return AT_REST;\n  }\n  advanceWave(field, seconds, now);\n  let isMoving = false;\n  for (let index = 0; index < field.count; index += 1) {\n    const stillMoves = moveCube(field, index, seconds);\n    const change = field.rise[index] - field.drawn[index];\n    const isStale = Math.abs(change) > REDRAW_PX;\n    if (isStale) {\n      paintCube(surface.pen, field, index);\n    }\n    isMoving = isMoving || stillMoves;\n  }\n  if (isMoving || field.isWaving) {\n    return NEXT_FRAME;\n  }\n  const untilWave = field.nextWaveAt - now;\n  return Math.max(NEXT_FRAME, untilWave);\n}\n\nfunction createCubeScene(size: number): Scene {\n  const field = fieldOf(size);\n  return {\n    ink(inks) {\n      field.inks = inks;\n    },\n    fit(surface, isStill) {\n      layCubes(field, surface, isStill);\n    },\n    draw(surface) {\n      drawCubes(field, surface);\n    },\n    step(surface, seconds, now) {\n      return stepCubes(field, surface, seconds, now);\n    },\n    pointerMoved(x, y) {\n      field.pointer.x = x;\n      field.pointer.y = y;\n      field.hasPointer = true;\n    },\n    pointerLeft() {\n      field.hasPointer = false;\n    },\n  };\n}\n\nexport type CubeFieldProps = {\n  className?: string;\n  /** Any colour token, such as `var(--primary)` or `var(--series-2)`. A cube takes it as it rises. */\n  hue?: string;\n  /** The distance between two cubes, in pixels. */\n  size?: number;\n  /** Cubes rise under the pointer. Without it, only the idle wave crosses the field. */\n  interactive?: boolean;\n};\n\n/**\n * A floor of small isometric cubes on one canvas. They rise under the pointer, each on its own\n * spring, and a slow wave crosses them while nobody touches them. Only the cubes that move are\n * drawn again, and nothing is drawn while they rest. It fills its parent, which must be\n * positioned. Under reduced motion the cubes stand still.\n */\nexport function CubeField({\n  className,\n  hue = DEFAULT_HUE,\n  size = DEFAULT_PITCH,\n  interactive = true,\n}: CubeFieldProps) {\n  const scene = useMemo(() => createCubeScene(size), [size]);\n\n  return (\n    <StageCanvas\n      scene={scene}\n      hue={hue}\n      isInteractive={interactive}\n      className={className}\n    />\n  );\n}\n\nexport default function Background02() {\n  const { hue, nextHue } = useHueCycle();\n\n  return (\n    <DemoBand\n      title='A floor of cubes that rises under the pointer'\n      background={<CubeField hue={hue} />}\n    >\n      <Button onClick={nextHue}>Change hue</Button>\n    </DemoBand>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/background/background-02.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/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"
}