{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-glow-button",
  "type": "registry:ui",
  "title": "Glow Button",
  "description": "Lit momentary or latching buttons in four styles: large round and pill performance controls with an LED halo, plus compact console-round and console-square controls with a machined bezel and recessed face. The glow can idle, hit on the beat, or follow a host-provided audio level.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/glow-button.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/glow-button\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * GlowButton — one big lit button, the way a club installation would build it.\n *\n * A large round (or pill) cap on a radial-gradient body, ringed by an LED\n * bezel and wrapped in a layered box-shadow halo that blooms when the button\n * is lit and presses in under the pointer. It is designed to look spectacular\n * at 120–200 px — the \"start the show\" control — but scales down to ~48 px.\n *\n * Two behaviours from one component: \"momentary\" is lit while held and fires\n * `onPress`/`onRelease`, \"toggle\" latches with a proper `aria-pressed`. Both\n * are real buttons underneath — Space and Enter work, and the depression shows\n * for keyboard users too.\n *\n * The halo has three drivers, in priority order. A `glow` prop (0..1) hands\n * the intensity to the host on every render — feed it an audio level and the\n * light side-chains to the music. Without `glow`, a `pulse={{ bpm }}` makes\n * the idle halo hit on every beat via a rAF loop that writes styles directly,\n * so the breathing costs no re-renders (and respects reduced-motion). With\n * neither, the halo sits at a dim idle floor and jumps to full when lit.\n *\n * The glow maths — the beat waveform and the intensity→shadow-stack mapping —\n * are exported pure functions, pinned by the colocated test.\n *\n * Styling comes from CSS custom properties — see `lib/theme.ts`.\n */\n\nimport { useEffect, useRef, useState } from \"react\"\nimport type { CSSProperties, ReactNode, SyntheticEvent } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  useFirstInteraction,\n  type InteractionEvent,\n} from \"maruaudio/ui/shared/use-first-interaction\"\n\n// ---------------------------------------------------------------------------\n// The pure core: phase → intensity → shadow layers. No React, no DOM — the\n// same maths drives the render path, the rAF pulse loop, and the Node test.\n// ---------------------------------------------------------------------------\n\n/** Halo intensity of an unlit, unpulsed button — dark, but visibly powered. */\nexport const IDLE_GLOW = 0.18\n\nfunction clamp01(value: number): number {\n  if (!Number.isFinite(value)) return 0\n  return value < 0 ? 0 : value > 1 ? 1 : value\n}\n\n/** One decimal place of pixel — enough for the eye, stable for the test. */\nfunction px(value: number): string {\n  return `${Math.round(value * 10) / 10}px`\n}\n\n/**\n * Fold any phase into [0, 1). Negative phases wrap forward rather than\n * mirroring, so a caller can integrate an ever-increasing (or rewinding)\n * beat clock without pre-folding it.\n */\nexport function wrapPhase(phase: number): number {\n  if (!Number.isFinite(phase)) return 0\n  const wrapped = phase % 1\n  return wrapped < 0 ? wrapped + 1 : wrapped\n}\n\n/**\n * The beat waveform: full brightness exactly on the downbeat, exponential\n * decay toward `floor` across the rest of the beat. An exponential rather\n * than a sine because that is what a lighting rig does — the hit is\n * instantaneous and the tail does the breathing.\n */\nexport function pulseIntensity(phase: number, floor = 0.2): number {\n  const f = clamp01(floor)\n  return f + (1 - f) * Math.exp(-5 * wrapPhase(phase))\n}\n\n/**\n * The layered halo for a given intensity, as one box-shadow string.\n *\n * Four layers, inside out: the seat (a plain dark drop shadow that flips to\n * an inset well while pressed — the cap sinks into it), a tight rim wash, an\n * inner bloom, and a wide outer halo whose blur *and* spread grow with\n * intensity. Alphas go through `color-mix` so the colour can be a raw value\n * or a `var(--uikit-accent)` token alike.\n */\nexport function glowShadowStack(\n  intensity: number,\n  diameter: number,\n  color: string,\n  pressed = false\n): string {\n  const t = clamp01(intensity)\n  const d = Math.max(1, diameter)\n  const mix = (percent: number) =>\n    `color-mix(in srgb, ${color} ${Math.round(percent)}%, transparent)`\n\n  const seat = pressed\n    ? `inset 0 ${px(d * 0.045)} ${px(d * 0.1)} rgba(0,0,0,0.6)`\n    : `0 ${px(d * 0.035)} ${px(d * 0.09)} rgba(0,0,0,0.5)`\n\n  return [\n    seat,\n    `0 0 ${px(d * 0.02)} ${mix(20 + 40 * t)}`,\n    `0 0 ${px(d * (0.06 + 0.16 * t))} ${mix(30 + 50 * t)}`,\n    `0 0 ${px(d * (0.14 + 0.42 * t))} ${px(d * (0.015 + 0.05 * t))} ${mix(10 + 40 * t)}`,\n  ].join(\", \")\n}\n\n/**\n * The LED bezel at a given intensity: its border colour and its own small\n * glow (outward plus inset, so the ring lights the cap edge it sits on).\n */\nexport function ledRingGlow(\n  intensity: number,\n  diameter: number,\n  color: string\n): { borderColor: string; boxShadow: string } {\n  const t = clamp01(intensity)\n  const d = Math.max(1, diameter)\n  const mix = (percent: number) =>\n    `color-mix(in srgb, ${color} ${Math.round(percent)}%, transparent)`\n\n  return {\n    borderColor: mix(35 + 65 * t),\n    boxShadow: `0 0 ${px(d * (0.02 + 0.08 * t))} ${mix(20 + 60 * t)}, inset 0 0 ${px(d * (0.015 + 0.05 * t))} ${mix(15 + 45 * t)}`,\n  }\n}\n\n// ---------------------------------------------------------------------------\n// The component.\n// ---------------------------------------------------------------------------\n\nexport type GlowButtonMode = \"momentary\" | \"toggle\"\n\nexport type GlowButtonVariant =\n  \"round\" | \"pill\" | \"console-round\" | \"console-square\"\n\n/** Beat sync for the idle halo. */\nexport interface GlowButtonPulse {\n  /** Tempo of the hit, in beats per minute. */\n  bpm: number\n}\n\nexport interface GlowButtonProps {\n  /** Lit while held, or latching. */\n  mode?: GlowButtonMode\n\n  /** Controlled latched state (toggle mode). Omit for uncontrolled operation. */\n  pressed?: boolean\n  /** Initial latched state when uncontrolled (toggle mode). */\n  defaultPressed?: boolean\n  /** Fires on every latch change (toggle mode), controlled or not. */\n  onPressedChange?: (pressed: boolean) => void\n\n  /** Momentary mode: the push landed (pointer down / Space down). */\n  onPress?: (event: SyntheticEvent) => void\n  /** Momentary mode: the push ended — including a pointer dragged off the cap. */\n  onRelease?: (event: SyntheticEvent) => void\n  /**\n   * Fired once, on the first gesture of any kind, synchronously inside the\n   * event — the place to resume a suspended AudioContext.\n   */\n  onInteraction?: (event: InteractionEvent) => void\n\n  /**\n   * External halo intensity, 0..1. When supplied it takes over the bloom\n   * completely — pass an audio level on every frame and the light follows\n   * the music. Overrides both the lit/idle logic and `pulse`.\n   */\n  glow?: number\n  /** Make the idle halo hit on the beat. Off by default. */\n  pulse?: GlowButtonPulse\n\n  /** Text or glyph centred on the cap. Doubles as the accessible name. */\n  children?: ReactNode\n  variant?: GlowButtonVariant\n  /** Cap diameter (round/square) or height (pill), in pixels. */\n  size?: number\n  /** Halo and LED colour. Defaults to the accent token. */\n  color?: string\n\n  disabled?: boolean\n  className?: string\n  style?: CSSProperties\n  /** Accessible name when the children are not text. */\n  \"aria-label\"?: string\n}\n\nexport function GlowButton({\n  mode = \"momentary\",\n  pressed,\n  defaultPressed = false,\n  onPressedChange,\n  onPress,\n  onRelease,\n  onInteraction,\n  glow,\n  pulse,\n  children,\n  variant = \"round\",\n  size,\n  color,\n  disabled = false,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: GlowButtonProps) {\n  const isControlled = pressed !== undefined\n  const [internal, setInternal] = useState(defaultPressed)\n  const latched = isControlled ? pressed : internal\n  /** Physically down right now — pointer or Space/Enter held. */\n  const [held, setHeld] = useState(false)\n\n  const { notify } = useFirstInteraction(onInteraction)\n\n  // Momentary buttons light while held; toggles light while latched (and\n  // while held, so the push itself reads immediately).\n  const lit = mode === \"toggle\" ? latched || held : held\n\n  const accent = color ?? token.accent\n  const consoleStyle =\n    variant === \"console-round\" || variant === \"console-square\"\n  const diameter = Math.max(24, size ?? (consoleStyle ? 52 : 140))\n  const width = variant === \"pill\" ? Math.round(diameter * 2.25) : diameter\n\n  const intensity = disabled\n    ? 0\n    : glow !== undefined\n      ? clamp01(glow)\n      : lit\n        ? 1\n        : IDLE_GLOW\n\n  const bodyRef = useRef<HTMLButtonElement | null>(null)\n  const ringRef = useRef<HTMLSpanElement | null>(null)\n\n  // The beat loop only owns the halo while nothing louder is speaking: no\n  // external `glow`, not lit, not disabled.\n  const bpm = pulse?.bpm\n  const pulsing =\n    bpm !== undefined && bpm > 0 && glow === undefined && !lit && !disabled\n\n  useEffect(() => {\n    if (!pulsing || bpm === undefined) return\n    // A user who asked for reduced motion gets the steady idle halo instead.\n    if (window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches) return\n\n    const beatMs = 60000 / bpm\n    const start = performance.now()\n    let raf = 0\n\n    const tick = (now: number) => {\n      const t = pulseIntensity((now - start) / beatMs)\n      const body = bodyRef.current\n      const ring = ringRef.current\n      // Written straight to the elements: sixty updates a second is a style\n      // mutation's job, not a re-render's.\n      if (body)\n        body.style.boxShadow = glowShadowStack(t, diameter, accent, false)\n      if (ring) {\n        const led = ledRingGlow(t, diameter, accent)\n        ring.style.borderColor = led.borderColor\n        ring.style.boxShadow = led.boxShadow\n      }\n      raf = requestAnimationFrame(tick)\n    }\n    raf = requestAnimationFrame(tick)\n\n    return () => {\n      cancelAnimationFrame(raf)\n      // Settle back onto the idle values the render path would have drawn.\n      const body = bodyRef.current\n      const ring = ringRef.current\n      if (body)\n        body.style.boxShadow = glowShadowStack(\n          IDLE_GLOW,\n          diameter,\n          accent,\n          false\n        )\n      if (ring) {\n        const led = ledRingGlow(IDLE_GLOW, diameter, accent)\n        ring.style.borderColor = led.borderColor\n        ring.style.boxShadow = led.boxShadow\n      }\n    }\n  }, [pulsing, bpm, diameter, accent])\n\n  const press = (event: SyntheticEvent) => {\n    if (disabled || held) return\n    setHeld(true)\n    if (mode === \"momentary\") onPress?.(event)\n  }\n\n  const release = (event: SyntheticEvent) => {\n    if (!held) return\n    setHeld(false)\n    if (mode === \"momentary\") onRelease?.(event)\n  }\n\n  const toggle = () => {\n    if (disabled || mode !== \"toggle\") return\n    const next = !latched\n    if (!isControlled) setInternal(next)\n    onPressedChange?.(next)\n  }\n\n  const led = ledRingGlow(intensity, diameter, accent)\n  const capTint = `color-mix(in srgb, ${accent} ${Math.round(8 + 30 * intensity)}%, ${token.cap})`\n  const ringInset = Math.max(3, Math.round(diameter * 0.06))\n  const fontSize = Math.max(11, Math.min(24, diameter * 0.13))\n\n  // The rAF loop repaints the shadows every frame while pulsing (and the host\n  // does while driving `glow`), so a box-shadow transition would only smear\n  // the hit; it is reserved for the discrete press/latch changes.\n  const animatedShadow = !pulsing && glow === undefined\n\n  const consoleRound = variant === \"console-round\"\n  const consoleSquare = variant === \"console-square\"\n\n  if (consoleRound || consoleSquare) {\n    const corner = consoleRound ? 999 : Math.max(5, diameter * 0.12)\n    const bezel = Math.max(3, diameter * 0.075)\n    const consoleIntensity = disabled ? 0 : intensity\n    const litMix = Math.round(18 + consoleIntensity * 70)\n    const capColor = `color-mix(in srgb, ${accent} ${litMix}%, #eeeade)`\n    const consoleShadow =\n      held && !disabled\n        ? `inset 0 ${px(diameter * 0.06)} ${px(diameter * 0.12)} rgba(38,35,29,0.42), 0 ${px(diameter * 0.025)} ${px(diameter * 0.04)} rgba(38,35,29,0.22)`\n        : `inset 0 1px 1px rgba(255,255,255,0.88), inset 0 -${px(diameter * 0.06)} ${px(diameter * 0.12)} rgba(73,66,51,0.2), 0 ${px(diameter * 0.09)} ${px(diameter * 0.13)} rgba(42,39,33,0.3)`\n\n    return (\n      <button\n        ref={bodyRef}\n        type=\"button\"\n        className={`uikit-control${className ? ` ${className}` : \"\"}`}\n        aria-pressed={mode === \"toggle\" ? latched : undefined}\n        aria-label={ariaLabel}\n        aria-disabled={disabled || undefined}\n        disabled={disabled}\n        onClick={toggle}\n        onPointerDown={(event) => {\n          notify(event)\n          press(event)\n        }}\n        onPointerUp={release}\n        onPointerLeave={release}\n        onPointerCancel={release}\n        onKeyDown={(event: React.KeyboardEvent) => {\n          if (event.key !== \" \" && event.key !== \"Enter\") return\n          notify(event)\n          if (!event.repeat) press(event)\n        }}\n        onKeyUp={release}\n        onBlur={release}\n        data-variant={variant}\n        style={{\n          position: \"relative\",\n          width,\n          height: diameter,\n          padding: bezel,\n          border: \"1px solid rgba(70,67,58,0.72)\",\n          borderRadius: corner,\n          background:\n            \"linear-gradient(145deg, #f8f7f1 0%, #b9b5aa 42%, #79776f 55%, #e4e1d8 100%)\",\n          boxShadow:\n            \"inset 0 1px 1px rgba(255,255,255,0.9), 0 1px 0 rgba(255,255,255,0.65), 0 5px 8px rgba(54,51,45,0.25)\",\n          transform:\n            held && !disabled\n              ? `translateY(${px(diameter * 0.025)}) scale(0.985)`\n              : \"none\",\n          transition: \"transform 70ms ease-out\",\n          cursor: disabled ? \"not-allowed\" : \"pointer\",\n          fontFamily: token.font,\n          ...style,\n        }}\n      >\n        <span\n          ref={ringRef}\n          aria-hidden\n          style={{\n            position: \"absolute\",\n            inset: bezel,\n            borderRadius: consoleRound ? 999 : Math.max(3, corner - bezel / 2),\n            border: \"1px solid rgba(119,113,99,0.62)\",\n            background: `radial-gradient(circle at 45% 28%, rgba(255,255,255,0.92) 0%, ${capColor} 48%, color-mix(in srgb, ${accent} ${Math.round(consoleIntensity * 72)}%, #aaa496) 100%)`,\n            boxShadow: `${consoleShadow}${\n              consoleIntensity > IDLE_GLOW\n                ? `, 0 0 ${px(diameter * 0.22)} color-mix(in srgb, ${accent} ${Math.round(consoleIntensity * 45)}%, transparent)`\n                : \"\"\n            }`,\n            transition: animatedShadow\n              ? \"background 140ms ease-out, box-shadow 140ms ease-out\"\n              : undefined,\n            pointerEvents: \"none\",\n          }}\n        />\n        {children ? (\n          <span\n            style={{\n              position: \"relative\",\n              display: \"inline-flex\",\n              alignItems: \"center\",\n              justifyContent: \"center\",\n              width: \"100%\",\n              height: \"100%\",\n              color: consoleIntensity > 0.55 ? \"#fffaf0\" : \"#4c4942\",\n              fontSize: Math.max(9, Math.min(14, diameter * 0.2)),\n              fontWeight: 700,\n              lineHeight: 1,\n              letterSpacing: \"0.08em\",\n              textTransform: \"uppercase\",\n              textShadow:\n                consoleIntensity > 0.55\n                  ? \"0 1px 2px rgba(71,40,20,0.7)\"\n                  : undefined,\n              userSelect: \"none\",\n              pointerEvents: \"none\",\n            }}\n          >\n            {children}\n          </span>\n        ) : null}\n      </button>\n    )\n  }\n\n  return (\n    <button\n      ref={bodyRef}\n      type=\"button\"\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      aria-pressed={mode === \"toggle\" ? latched : undefined}\n      aria-label={ariaLabel}\n      aria-disabled={disabled || undefined}\n      disabled={disabled}\n      onClick={toggle}\n      onPointerDown={(event) => {\n        notify(event)\n        press(event)\n      }}\n      onPointerUp={release}\n      onPointerLeave={release}\n      onPointerCancel={release}\n      // Space/Enter fire click natively; mirror the physical push for keyboards.\n      onKeyDown={(event: React.KeyboardEvent) => {\n        if (event.key !== \" \" && event.key !== \"Enter\") return\n        notify(event)\n        if (!event.repeat) press(event)\n      }}\n      onKeyUp={release}\n      onBlur={release}\n      style={{\n        position: \"relative\",\n        width,\n        height: diameter,\n        padding: 0,\n        border: `1px solid ${token.border}`,\n        borderRadius: 999,\n        background: `radial-gradient(circle at 50% 30%, ${capTint}, rgba(0,0,0,0.55))`,\n        boxShadow: glowShadowStack(\n          intensity,\n          diameter,\n          accent,\n          held && !disabled\n        ),\n        transform:\n          held && !disabled\n            ? `translateY(${px(diameter * 0.02)}) scale(0.97)`\n            : \"none\",\n        transition: animatedShadow\n          ? \"transform 70ms ease-out, box-shadow 140ms ease-out, background 140ms ease-out\"\n          : \"transform 70ms ease-out\",\n        cursor: disabled ? \"not-allowed\" : \"pointer\",\n        display: \"inline-flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        fontFamily: token.font,\n        ...style,\n      }}\n    >\n      {/* LED bezel */}\n      <span\n        ref={ringRef}\n        aria-hidden\n        style={{\n          position: \"absolute\",\n          inset: ringInset,\n          borderRadius: 999,\n          border: `${px(Math.max(1.5, diameter * 0.02))} solid ${led.borderColor}`,\n          boxShadow: led.boxShadow,\n          transition: animatedShadow\n            ? \"border-color 140ms ease-out, box-shadow 140ms ease-out\"\n            : undefined,\n          pointerEvents: \"none\",\n        }}\n      />\n      {/* Cap label */}\n      <span\n        aria-hidden={children == null || undefined}\n        style={{\n          position: \"relative\",\n          fontSize,\n          fontWeight: 700,\n          letterSpacing: \"0.14em\",\n          textTransform: \"uppercase\",\n          color: lit && !disabled ? token.text : token.textMuted,\n          textShadow:\n            intensity > 0\n              ? `0 0 ${px(diameter * 0.08 * intensity)} color-mix(in srgb, ${accent} ${Math.round(70 * intensity)}%, transparent)`\n              : undefined,\n          transition: \"color 140ms ease-out\",\n          userSelect: \"none\",\n          padding: `0 ${px(diameter * 0.12)}`,\n        }}\n      >\n        {children}\n      </span>\n    </button>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/glow-button.tsx"
    }
  ]
}
