{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "background-07",
  "title": "Grid pattern",
  "description": "A crisp hairline grid behind a section, dashed, tilted or faded at will, where four lit cells fade out and land somewhere new in turn.",
  "dependencies": [
    "not showing"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/chat-motion.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-07.tsx",
      "content": "'use client';\n\nimport { useEffect, useId, useRef, useState, type CSSProperties } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport { Swap } from '@/registry/ndeko/ui/chat-motion';\n\nimport type { Aim } from './_parts/aim';\nimport { DemoBand } from './_parts/demo-band';\nimport { DEFAULT_HUE } from './_parts/hue';\nimport { landTransitions } from './_parts/loops';\nimport { watchShowing } from './_parts/showing';\nimport { useCells } from './_parts/use-cells';\n\nconst DEFAULT_CELL = 40;\n\nconst MIN_CELL = 8;\n\n/**\n * A line is one device pixel wide and lies wholly inside its cell: the path runs along the top and\n * left edges, shifted in by half its own width, so it never straddles two pixels.\n */\nconst LINE_STYLE: CSSProperties = {\n  strokeWidth: 'var(--hairline, 1px)',\n  transform:\n    'translate(calc(var(--hairline, 1px) / 2), calc(var(--hairline, 1px) / 2))',\n};\n\n/** Five dashes to a cell, so a dashed line runs on from one cell to the next without a seam. */\nconst DASHES_PER_CELL = 5;\n\nconst DASH_PARTS = DASHES_PER_CELL * 2;\n\nconst FADE_MASK =\n  '[mask-image:radial-gradient(ellipse_at_center,black_30%,transparent_78%)]';\n\n/** How strongly a lit cell wears the hue. Text sits above the grid, so it stays a tint. */\nconst LIT_FILL = 'color-mix(in oklab, currentColor 16%, transparent)';\n\n/**\n * A lit cell is a box laid over the pattern. A box that fades its opacity on its own layer is\n * composited, where a shape inside the drawing would repaint the grid on every frame of its fade.\n * The layer is kept between fades, so starting or ending one repaints nothing either.\n */\nconst LIT_CELL =\n  'absolute top-px left-px opacity-0 transition-opacity duration-700 ease-(--ease-out-strong) will-change-[opacity]';\n\n/** Where the four lit cells rest, across and down the box. */\nconst RESTS: Aim[] = [\n  { x: 0.2, y: 0.32 },\n  { x: 0.42, y: 0.7 },\n  { x: 0.63, y: 0.24 },\n  { x: 0.82, y: 0.62 },\n];\n\n/** A cell fades out, lands on its new place and fades in, then the next one takes its turn. */\nconst FADE_MS = 700;\n\nconst REST_MS = 1100;\n\n/** A new place is drawn inside this margin, so a lit cell is never cut by the edge of the box. */\nconst EDGE_MARGIN = 0.06;\n\nconst BOTH_MARGINS = EDGE_MARGIN * 2;\n\nconst PLACE_ROOM = 1 - BOTH_MARGINS;\n\nconst PLACE_TRIES = 8;\n\nconst RADIANS_PER_DEGREE = Math.PI / 180;\n\ntype Place = {\n  column: number;\n  row: number;\n};\n\nfunction dashesOf(isDashed: boolean, cell: number) {\n  if (!isDashed) {\n    return undefined;\n  }\n  const dash = cell / DASH_PARTS;\n  return `${dash} ${dash}`;\n}\n\n/**\n * The cell under a point of the box. The grid is twice as wide as the box, starts half a box to\n * its left and leans around its own middle, so the point is brought back into the grid first.\n */\nfunction cellAt(\n  host: HTMLElement,\n  aim: Aim,\n  cell: number,\n  skew: number\n): Place {\n  const width = host.clientWidth;\n  const height = host.clientHeight;\n  const halfWidth = width / 2;\n  const halfHeight = height / 2;\n  const x = aim.x * width;\n  const y = aim.y * height;\n  const radians = skew * RADIANS_PER_DEGREE;\n  const lean = Math.tan(radians);\n  const fromMiddle = y - halfHeight;\n  const leaned = fromMiddle * lean;\n  const fromGridEdge = x + halfWidth;\n  const along = fromGridEdge - leaned;\n  const column = Math.floor(along / cell);\n  const row = Math.floor(y / cell);\n  return { column, row };\n}\n\nfunction litStyleOf(side: number): CSSProperties {\n  return { width: side, height: side, backgroundColor: LIT_FILL };\n}\n\nfunction put(square: HTMLElement, place: Place, cell: number) {\n  const x = place.column * cell;\n  const y = place.row * cell;\n  square.style.transform = `translate(${x}px, ${y}px)`;\n}\n\nfunction show(square: HTMLElement) {\n  square.style.opacity = '1';\n}\n\nfunction hide(square: HTMLElement) {\n  square.style.opacity = '0';\n}\n\nfunction isSamePlace(first: Place, second: Place) {\n  const isSameColumn = first.column === second.column;\n  const isSameRow = first.row === second.row;\n  return isSameColumn && isSameRow;\n}\n\nfunction shareWithinMargin() {\n  const share = Math.random() * PLACE_ROOM;\n  return EDGE_MARGIN + share;\n}\n\ntype Trade = {\n  host: HTMLElement;\n  squares: HTMLElement[];\n  places: Place[];\n  cell: number;\n  skewOf: () => number;\n};\n\n/** A place no lit cell holds. After a few unlucky draws the last one is taken as it is. */\nfunction freePlaceOf(trade: Trade) {\n  let place: Place = { column: 0, row: 0 };\n  for (let attempt = 0; attempt < PLACE_TRIES; attempt += 1) {\n    const aim = { x: shareWithinMargin(), y: shareWithinMargin() };\n    const candidate = cellAt(trade.host, aim, trade.cell, trade.skewOf());\n    const isTaken = trade.places.some((taken) => isSamePlace(taken, candidate));\n    place = candidate;\n    if (!isTaken) {\n      return place;\n    }\n  }\n  return place;\n}\n\nfunction settle(trade: Trade) {\n  trade.squares.forEach((square, index) => {\n    const rest = RESTS[index];\n    if (!rest) {\n      return;\n    }\n    const place = cellAt(trade.host, rest, trade.cell, trade.skewOf());\n    trade.places[index] = place;\n    put(square, place, trade.cell);\n    show(square);\n  });\n}\n\n/**\n * The lit cells trade places while the grid shows: one timer, and an opacity transition between\n * two of its ticks. Hidden, the timer is gone and every cell is put back in sight where it stands,\n * at once.\n */\nfunction tradeWhileShowing(trade: Trade) {\n  let turn = 0;\n  let timer: ReturnType<typeof setTimeout> | undefined;\n\n  function land() {\n    const square = trade.squares[turn];\n    const place = freePlaceOf(trade);\n    trade.places[turn] = place;\n    if (square) {\n      put(square, place, trade.cell);\n      show(square);\n    }\n    const next = turn + 1;\n    turn = next % trade.squares.length;\n    timer = setTimeout(leave, REST_MS);\n  }\n\n  function leave() {\n    const square = trade.squares[turn];\n    if (square) {\n      hide(square);\n    }\n    timer = setTimeout(land, FADE_MS);\n  }\n\n  function stop() {\n    clearTimeout(timer);\n    timer = undefined;\n    trade.squares.forEach(show);\n    landTransitions(trade.host);\n  }\n\n  const stopWatching = watchShowing(trade.host, (isShowing) => {\n    if (isShowing) {\n      timer = setTimeout(leave, REST_MS);\n      return;\n    }\n    stop();\n  });\n\n  return () => {\n    stopWatching();\n    stop();\n  };\n}\n\nexport type GridPatternProps = {\n  className?: string;\n  /** Any colour token, such as `var(--primary)` or `var(--series-2)`. The lit cells wear it. */\n  hue?: string;\n  /** The side of a cell, in pixels. */\n  size?: number;\n  dashed?: boolean;\n  /** How far the grid leans, in degrees. Keep it between -30 and 30. */\n  skew?: number;\n  /** Fades the grid out towards the edges of the box. */\n  fade?: boolean;\n};\n\n/**\n * A crisp grid of hairlines in the border colour, drawn by one SVG pattern on whole pixels, with\n * four cells lit in the hue that trade places every few seconds. The lines can be dashed, the grid\n * can lean and fade out towards its edges. It fills its parent, which must be positioned. Under\n * reduced motion the four cells stay where they are.\n */\nexport function GridPattern({\n  className,\n  hue = DEFAULT_HUE,\n  size = DEFAULT_CELL,\n  dashed = false,\n  skew = 0,\n  fade = false,\n}: GridPatternProps) {\n  const patternId = useId();\n  const hostRef = useRef<HTMLDivElement>(null);\n  const skewRef = useRef(skew);\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const cell = Math.max(MIN_CELL, size);\n  const { columns, rows } = useCells(hostRef, cell);\n  const inner = cell - 1;\n\n  useEffect(() => {\n    skewRef.current = skew;\n  }, [skew]);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    const hasCells = columns > 0 && rows > 0;\n    if (!host || !hasCells) {\n      return;\n    }\n    const lit = host.querySelectorAll<HTMLElement>('[data-lit]');\n    const squares = Array.from(lit);\n    const trade: Trade = {\n      host,\n      squares,\n      places: [],\n      cell,\n      skewOf: () => skewRef.current,\n    };\n    settle(trade);\n    if (shouldReduceMotion) {\n      return;\n    }\n    return tradeWhileShowing(trade);\n  }, [columns, rows, cell, shouldReduceMotion]);\n\n  return (\n    <div\n      ref={hostRef}\n      aria-hidden='true'\n      style={{ color: hue }}\n      className={cn(\n        'pointer-events-none absolute inset-0 overflow-hidden transition-colors duration-500',\n        fade && FADE_MASK,\n        className\n      )}\n    >\n      <div\n        style={{ transform: `skewX(${skew}deg)` }}\n        className='absolute -inset-x-1/2 inset-y-0 transition-transform duration-500 ease-(--ease-out-strong)'\n      >\n        <svg aria-hidden='true' className='absolute inset-0 size-full'>\n          <defs>\n            <pattern\n              id={patternId}\n              width={cell}\n              height={cell}\n              patternUnits='userSpaceOnUse'\n            >\n              <path\n                d={`M${cell} 0H0V${cell}`}\n                fill='none'\n                stroke='currentColor'\n                strokeDasharray={dashesOf(dashed, cell)}\n                shapeRendering='crispEdges'\n                style={LINE_STYLE}\n                className='text-border'\n              />\n            </pattern>\n          </defs>\n          <rect width='100%' height='100%' fill={`url(#${patternId})`} />\n        </svg>\n        {RESTS.map((rest) => (\n          <div\n            key={rest.x}\n            data-lit=''\n            style={litStyleOf(inner)}\n            className={LIT_CELL}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n\n/** A lean that shows the option without bending the grid out of the band. */\nconst DEMO_SKEW = -14;\n\nfunction skewOf(isLeaning: boolean) {\n  if (isLeaning) {\n    return DEMO_SKEW;\n  }\n  return 0;\n}\n\nfunction labelOf(isLeaning: boolean) {\n  if (isLeaning) {\n    return 'Level the grid';\n  }\n  return 'Tilt the grid';\n}\n\nexport default function Background07() {\n  const [isLeaning, setIsLeaning] = useState(false);\n  const label = labelOf(isLeaning);\n\n  return (\n    <DemoBand\n      title='A grid where four cells trade places'\n      background={<GridPattern skew={skewOf(isLeaning)} fade />}\n    >\n      <Button onClick={() => setIsLeaning((current) => !current)}>\n        <Swap swapKey={label}>{label}</Swap>\n      </Button>\n    </DemoBand>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/background/background-07.tsx"
    },
    {
      "path": "registry/ndeko/components/background/_parts/aim.ts",
      "content": "import { landTransitions } from './loops';\nimport { watchPointer } from './pointer';\nimport { watchShowing } from './showing';\n\n/** A place in a box: from 0 at its left or top edge to 1 at its right or bottom edge. */\nexport type Aim = {\n  x: number;\n  y: number;\n};\n\nexport type AimOptions = {\n  /** The round the aim walks while no pointer leads it. The first one is where it rests. */\n  waypoints: readonly Aim[];\n  /** Milliseconds between two waypoints. */\n  stepMs: number;\n  /** Milliseconds of the glide to a waypoint: `stepMs` for a walk without a rest, less for one with rests. */\n  glideMs: number;\n  /** Milliseconds for the aim to reach a pointer. */\n  followMs: number;\n  isInteractive: boolean;\n  /**\n   * Sends the picture to `aim` over `durationMs`. A CSS transition carries it there, so nothing is\n   * computed on the way and no frame is asked for.\n   */\n  onAim: (aim: Aim, durationMs: number, isFollowing: boolean) => void;\n};\n\nfunction idle() {}\n\n/**\n * An aim that walks from waypoint to waypoint while the host shows, and goes to the pointer while\n * one is over the host. One timer steps the walk. Hidden, the timer is gone and the glide in flight\n * lands at once where it was going. The function returned ends the watch.\n */\nexport function watchAim(host: HTMLElement, options: AimOptions) {\n  const { waypoints, stepMs, glideMs, followMs, isInteractive, onAim } =\n    options;\n  const first = waypoints[0];\n  if (!first) {\n    return idle;\n  }\n  let index = 0;\n  let latest = first;\n  let timer: ReturnType<typeof setTimeout> | undefined;\n  let isShowing = false;\n  let stopPointer = idle;\n\n  function clearStep() {\n    clearTimeout(timer);\n    timer = undefined;\n  }\n\n  function stepOn() {\n    const next = index + 1;\n    index = next % waypoints.length;\n    latest = waypoints[index] ?? first;\n    onAim(latest, glideMs, false);\n    timer = setTimeout(stepOn, stepMs);\n  }\n\n  function follow(x: number, y: number) {\n    clearStep();\n    const across = x / host.offsetWidth;\n    const down = y / host.offsetHeight;\n    latest = { x: across, y: down };\n    onAim(latest, followMs, true);\n  }\n\n  function release() {\n    if (isShowing) {\n      stepOn();\n    }\n  }\n\n  function show() {\n    isShowing = true;\n    if (isInteractive) {\n      stopPointer = watchPointer(host, follow, release);\n    }\n    stepOn();\n  }\n\n  function hide() {\n    isShowing = false;\n    clearStep();\n    stopPointer();\n    stopPointer = idle;\n    onAim(latest, 0, false);\n    landTransitions(host);\n  }\n\n  const stopShowing = watchShowing(host, (isNowShowing) => {\n    if (isNowShowing) {\n      show();\n      return;\n    }\n    hide();\n  });\n\n  return () => {\n    stopShowing();\n    hide();\n  };\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/aim.ts"
    },
    {
      "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/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/loops.ts",
      "content": "import { watchShowing } from './showing';\n\nfunction hold(animations: Animation[]) {\n  for (const animation of animations) {\n    animation.pause();\n  }\n}\n\nfunction resume(animations: Animation[]) {\n  for (const animation of animations) {\n    animation.play();\n  }\n}\n\nfunction cancel(animations: Animation[]) {\n  for (const animation of animations) {\n    animation.cancel();\n  }\n}\n\n/**\n * Lands every transition still running under `host` at its end value. A transition asks for frames\n * until it ends, on screen or not, and a host that stops showing must ask for none.\n */\nexport function landTransitions(host: Element) {\n  const animations = host.getAnimations({ subtree: true });\n  for (const animation of animations) {\n    const isTransition = animation instanceof CSSTransition;\n    if (isTransition) {\n      animation.finish();\n    }\n  }\n}\n\n/**\n * Endless animations that run only while their host shows. Off screen and in a hidden tab they are\n * paused: they keep their place and ask for no frame. They start paused, and `onShowing` is told\n * each time the host starts or stops showing. The function returned removes them.\n */\nexport function runWhileShowing(\n  host: Element,\n  animations: Animation[],\n  onShowing?: (isShowing: boolean) => void\n) {\n  hold(animations);\n  const stopWatching = watchShowing(host, (isShowing) => {\n    onShowing?.(isShowing);\n    if (isShowing) {\n      resume(animations);\n      return;\n    }\n    hold(animations);\n  });\n  return () => {\n    stopWatching();\n    cancel(animations);\n  };\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/loops.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/use-cells.ts",
      "content": "'use client';\n\nimport { useEffect, useState, type RefObject } from 'react';\n\nexport type Cells = {\n  columns: number;\n  rows: number;\n};\n\nconst NO_CELLS: Cells = { columns: 0, rows: 0 };\n\nfunction cellsOf(element: HTMLElement, cell: number): Cells {\n  const side = Math.max(1, cell);\n  const across = element.clientWidth / side;\n  const down = element.clientHeight / side;\n  const columns = Math.ceil(across);\n  const rows = Math.ceil(down);\n  return { columns, rows };\n}\n\nfunction keptIfSame(current: Cells, next: Cells) {\n  const hasSameColumns = current.columns === next.columns;\n  const hasSameRows = current.rows === next.rows;\n  if (hasSameColumns && hasSameRows) {\n    return current;\n  }\n  return next;\n}\n\n/**\n * How many cells of `cell` pixels it takes to cover the element, counted again when its box\n * changes. Both counts are 0 until the box has been measured. The state only changes when a count\n * does, so a resize that stays inside the same cells renders nothing again.\n */\nexport function useCells<Target extends HTMLElement>(\n  ref: RefObject<Target | null>,\n  cell: number\n) {\n  const [cells, setCells] = useState(NO_CELLS);\n\n  useEffect(() => {\n    const element = ref.current;\n    if (!element) {\n      return;\n    }\n    const measured = element;\n\n    function count() {\n      const next = cellsOf(measured, cell);\n      setCells((current) => keptIfSame(current, next));\n    }\n\n    const observer = new ResizeObserver(count);\n    observer.observe(measured);\n    return () => observer.disconnect();\n  }, [ref, cell]);\n\n  return cells;\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/use-cells.ts"
    }
  ],
  "categories": [
    "components",
    "background"
  ],
  "type": "registry:component"
}