{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "background-06",
  "title": "Light floor",
  "description": "A floor of light rising from the foot of a band, drifting and breathing, where a press sends a wave across it from the point it came from.",
  "dependencies": [
    "not showing"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/demonstration.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-autoplay.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-06.tsx",
      "content": "'use client';\n\nimport {\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  type CSSProperties,\n  type Ref,\n} from 'react';\n\nimport {\n  useAutoplay,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport {\n  PRESS_LEAD_MS,\n  pressedQuietOf,\n  usePress,\n} from '@/registry/ndeko/lib/demonstration';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Button } from '@/registry/ndeko/ui/button';\n\nimport { DemoBand } from './_parts/demo-band';\nimport { DEFAULT_HUE } from './_parts/hue';\nimport { runWhileShowing } from './_parts/loops';\nimport { clamp } from './_parts/math';\nimport { softFieldOf } from './_parts/soft-field';\n\n/** A dome of light that stands on the bottom edge of its box and fades out before the other three. */\nconst DOME = 'ellipse 50% 100% at 50% 100%';\n\ntype Loop = {\n  from: string;\n  to: string;\n  /** Milliseconds one way. The four differ, so the two layers never move in step. */\n  periodMs: number;\n};\n\ntype Layer = {\n  id: string;\n  box: CSSProperties;\n  light: CSSProperties;\n  drift: Loop;\n  breath: Loop;\n};\n\nfunction lightOf(strength: number): CSSProperties {\n  return { backgroundImage: softFieldOf(DOME, strength) };\n}\n\nconst LAYERS: Layer[] = [\n  {\n    id: 'wide',\n    box: { left: '-10%', width: '120%', height: '72%' },\n    light: lightOf(26),\n    drift: {\n      from: 'translateX(-3%)',\n      to: 'translateX(3%)',\n      periodMs: 23000,\n    },\n    breath: { from: 'scaleY(1)', to: 'scaleY(1.14)', periodMs: 9000 },\n  },\n  {\n    id: 'tall',\n    box: { left: '22%', width: '56%', height: '92%' },\n    light: lightOf(18),\n    drift: {\n      from: 'translateX(8%)',\n      to: 'translateX(-8%)',\n      periodMs: 31000,\n    },\n    breath: { from: 'scaleY(0.94)', to: 'scaleY(1.08)', periodMs: 13000 },\n  },\n];\n\n/** A ring of light near the rim of the same dome: scaled up from a point, it crosses the floor. */\nconst WAVE_IMAGE =\n  'radial-gradient(ellipse 50% 100% at 50% 100%, transparent 58%, color-mix(in oklab, currentColor 30%, transparent) 80%, transparent)';\n\n/** The wave stands on the bottom edge, centred on the point it starts from, and is not seen until it runs. */\nconst WAVE_CLASS =\n  'absolute bottom-0 -ml-[70%] h-full w-[140%] origin-bottom opacity-0';\n\nconst WAVE_FRAMES: Keyframe[] = [\n  { transform: 'scale(0.06)', opacity: 0 },\n  { opacity: 1, offset: 0.18 },\n  { transform: 'scale(1)', opacity: 0 },\n];\n\nconst WAVE_MS = 1600;\n\nconst WAVE_EASE = 'cubic-bezier(0.23, 1, 0.32, 1)';\n\n/** Under reduced motion a pulse keeps its size and only fades in and out. */\nconst STILL_WAVE_FRAMES: Keyframe[] = [\n  { transform: 'scale(0.7)', opacity: 0 },\n  { transform: 'scale(0.7)', opacity: 1, offset: 0.3 },\n  { transform: 'scale(0.7)', opacity: 0 },\n];\n\nconst STILL_WAVE_MS = 900;\n\n/** Pulses sent faster than they fade do not pile up: past this many, the oldest goes. */\nconst MAX_WAVES = 4;\n\ntype Waves = Map<Animation, HTMLElement>;\n\nfunction loopOf(element: Element, loop: Loop) {\n  const frames = [{ transform: loop.from }, { transform: loop.to }];\n  return element.animate(frames, {\n    duration: loop.periodMs,\n    direction: 'alternate',\n    easing: 'ease-in-out',\n    iterations: Infinity,\n  });\n}\n\nfunction loopsOf(host: HTMLElement) {\n  const loops: Animation[] = [];\n  LAYERS.forEach((layer, index) => {\n    const box = host.children.item(index);\n    const light = box?.firstElementChild;\n    if (!box || !light) {\n      return;\n    }\n    const drift = loopOf(box, layer.drift);\n    const breath = loopOf(light, layer.breath);\n    loops.push(drift, breath);\n  });\n  return loops;\n}\n\nfunction dropWave(waves: Waves, run: Animation) {\n  const wave = waves.get(run);\n  wave?.remove();\n  waves.delete(run);\n}\n\nfunction dropOldest(waves: Waves) {\n  const oldest = waves.keys().next().value;\n  if (!oldest) {\n    return;\n  }\n  oldest.cancel();\n  dropWave(waves, oldest);\n}\n\nfunction dropAll(waves: Waves) {\n  const runs = Array.from(waves.keys());\n  for (const run of runs) {\n    run.cancel();\n    dropWave(waves, run);\n  }\n}\n\nfunction waveFramesOf(shouldReduceMotion: boolean) {\n  if (shouldReduceMotion) {\n    return STILL_WAVE_FRAMES;\n  }\n  return WAVE_FRAMES;\n}\n\nfunction waveMsOf(shouldReduceMotion: boolean) {\n  if (shouldReduceMotion) {\n    return STILL_WAVE_MS;\n  }\n  return WAVE_MS;\n}\n\n/** One wave is one element that lives as long as its animation, then leaves the page. */\nfunction sendWave(\n  host: HTMLElement,\n  waves: Waves,\n  across: number,\n  shouldReduceMotion: boolean\n) {\n  const isFull = waves.size >= MAX_WAVES;\n  if (isFull) {\n    dropOldest(waves);\n  }\n  const wave = document.createElement('div');\n  const percent = across * 100;\n  wave.className = WAVE_CLASS;\n  wave.style.left = `${percent}%`;\n  wave.style.backgroundImage = WAVE_IMAGE;\n  host.append(wave);\n  const run = wave.animate(waveFramesOf(shouldReduceMotion), {\n    duration: waveMsOf(shouldReduceMotion),\n    easing: WAVE_EASE,\n  });\n  waves.set(run, wave);\n  run.onfinish = () => dropWave(waves, run);\n}\n\nexport type LightFloorHandle = {\n  /** Sends a wave across the floor from a point of its width: 0 is the left edge, 1 the right edge. */\n  pulse: (x?: number) => void;\n};\n\nexport type LightFloorProps = {\n  className?: string;\n  /** Any colour token, such as `var(--primary)` or `var(--series-2)`. The floor is lit in it. */\n  hue?: string;\n  handleRef?: Ref<LightFloorHandle>;\n};\n\n/**\n * A floor of light that rises from the bottom edge: two soft layers of one hue that drift and\n * breathe, each moved by transforms alone. `pulse(x)`, through `handleRef`, sends a wave across it\n * from a point. A pulse sent while the floor is off screen is dropped, and so is a wave still\n * running when it leaves the screen. It fills its parent, which must be positioned. Under reduced\n * motion the floor stands still and a pulse only fades in and out.\n */\nexport function LightFloor({\n  className,\n  hue = DEFAULT_HUE,\n  handleRef,\n}: LightFloorProps) {\n  const hostRef = useRef<HTMLDivElement>(null);\n  const isShowing = useRef(false);\n  const waves = useRef<Waves>(new Map());\n  const shouldReduceMotion = usePrefersReducedMotion();\n\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!host) {\n      return;\n    }\n    const live = waves.current;\n    const loops: Animation[] = [];\n    if (!shouldReduceMotion) {\n      loops.push(...loopsOf(host));\n    }\n    const stopLoops = runWhileShowing(host, loops, (isNowShowing) => {\n      isShowing.current = isNowShowing;\n      if (!isNowShowing) {\n        dropAll(live);\n      }\n    });\n    return () => {\n      stopLoops();\n      isShowing.current = false;\n      dropAll(live);\n    };\n  }, [shouldReduceMotion]);\n\n  useImperativeHandle(\n    handleRef,\n    () => ({\n      pulse(x = 0.5) {\n        const host = hostRef.current;\n        if (!host || !isShowing.current) {\n          return;\n        }\n        const across = clamp(x, 0, 1);\n        sendWave(host, waves.current, across, shouldReduceMotion);\n      },\n    }),\n    [shouldReduceMotion]\n  );\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        className\n      )}\n    >\n      {LAYERS.map((layer) => (\n        <div key={layer.id} style={layer.box} className='absolute bottom-0'>\n          <div style={layer.light} className='absolute inset-0 origin-bottom' />\n        </div>\n      ))}\n    </div>\n  );\n}\n\ntype Side = 'left' | 'centre' | 'right';\n\ntype Spot = {\n  side: Side;\n  label: string;\n  across: number;\n};\n\nconst SPOTS: Spot[] = [\n  { side: 'left', label: 'Pulse left', across: 0.2 },\n  { side: 'centre', label: 'Pulse centre', across: 0.5 },\n  { side: 'right', label: 'Pulse right', across: 0.8 },\n];\n\n/** Long enough for a wave to cross and fade before the demonstration sends the next one. */\nconst BETWEEN_PULSES_MS = 1700;\n\nexport default function Background06() {\n  const floor = useRef<LightFloorHandle>(null);\n  const { pressed, press } = usePress<Side>();\n\n  function pulseFrom(spot: Spot) {\n    floor.current?.pulse(spot.across);\n  }\n\n  function stepsOf(spot: Spot): AutoplayStep[] {\n    return [\n      { after: BETWEEN_PULSES_MS, run: () => press(spot.side) },\n      { after: PRESS_LEAD_MS, run: () => pulseFrom(spot) },\n    ];\n  }\n\n  const steps = SPOTS.flatMap(stepsOf);\n  const { ref } = useAutoplay<HTMLElement>(steps, { loopAfter: 0 });\n\n  return (\n    <DemoBand\n      ref={ref}\n      title='A floor of light that answers a press'\n      background={<LightFloor handleRef={floor} />}\n    >\n      {SPOTS.map((spot) => (\n        <Button\n          key={spot.side}\n          variant='outline'\n          onClick={() => pulseFrom(spot)}\n          className={pressedQuietOf(pressed === spot.side)}\n        >\n          {spot.label}\n        </Button>\n      ))}\n    </DemoBand>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/background/background-06.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/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/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/soft-field.ts",
      "content": "/** A fall from full to nothing with no visible edge: the share of the strength left at each quarter of the way out. */\nconst FALLOFF = [1, 0.85, 0.5, 0.15, 0];\n\nconst QUARTER = 25;\n\nfunction stopOf(strength: number, share: number, index: number) {\n  const exact = strength * share;\n  const scaled = Math.round(exact * 100);\n  const percent = scaled / 100;\n  const position = index * QUARTER;\n  return `color-mix(in oklab, currentColor ${percent}%, transparent) ${position}%`;\n}\n\n/**\n * A radial gradient of the current colour that fades to nothing with a soft edge, as a blur would\n * draw it, without a filter. `shape` is the shape and the place of the gradient, `strength` the\n * per cent of the colour at its heart.\n */\nexport function softFieldOf(shape: string, strength: number) {\n  const stops = FALLOFF.map((share, index) => stopOf(strength, share, index));\n  const ramp = stops.join(', ');\n  return `radial-gradient(${shape}, ${ramp})`;\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/soft-field.ts"
    }
  ],
  "categories": [
    "components",
    "background"
  ],
  "type": "registry:component"
}