{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pulse-beam","type":"registry:ui","title":"PulseBeam","description":"Breathing border glow that wraps any card: colour blobs drift along the edges as a crisp 1px ring, a feathered inner glow and a blurred bloom, with slow hue rotation, fade in/out on an active flag, four palettes and an outside halo variant","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["effects"],"registryDependencies":["https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/pulse-beam/PulseBeam.tsx","content":"\"use client\";\n\nimport {\n\tforwardRef,\n\tuseEffect,\n\tuseMemo,\n\tuseRef,\n\tuseState,\n\ttype CSSProperties,\n\ttype HTMLAttributes,\n\ttype ReactNode,\n\ttype TransitionEvent as ReactTransitionEvent,\n} from \"react\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { cn } from \"../../utils\";\nimport {\n\tLAYER_PRESETS,\n\tbuildLayerBackgrounds,\n\tbuildOscillators,\n\tmotionPreset,\n\ttype PulseBeamPalette,\n\ttype PulseBeamTone,\n\ttype PulseBeamVariant,\n} from \"./pulse-beam-data\";\nimport { registerPulse } from \"./pulse-beam-loop\";\nimport \"./pulse-beam.css\";\n\nexport type { PulseBeamPalette, PulseBeamTone, PulseBeamVariant };\n\n/**\n * PulseBeam — a breathing, colour-shifting border glow.\n *\n * Wrap any card or control. Three masked gradient layers (a 1px ring, a\n * feathered inner glow and a blurred bloom) are driven by a shared\n * animation loop through CSS custom properties, so the effect stays on the\n * compositor and costs one style recalc per frame per instance.\n */\nexport interface PulseBeamProps extends HTMLAttributes<HTMLDivElement> {\n\t/** Additional classes on the wrapper */\n\tclassName?: string;\n\t/** Content the glow wraps — give it an opaque background and the same radius */\n\tchildren?: ReactNode;\n\t/** Show the glow. Off fades out (500ms) and stops the loop; on fades in (600ms). */\n\tactive?: boolean;\n\t/** `inner` paints inside the box; `outside` adds a blurred halo behind the content */\n\tvariant?: PulseBeamVariant;\n\t/** Built-in nine-slot colour set. `mono` halves opacity and disables hue drift. */\n\tpalette?: PulseBeamPalette;\n\t/** Up to nine CSS colours overriding the palette slots in order */\n\tcolors?: string[];\n\t/** Overall intensity, 0–1 */\n\tstrength?: number;\n\t/** Corner radius in px, applied to the wrapper and every layer */\n\tradius?: number;\n\t/** Multiplier for the breathing and drift periods */\n\tspeed?: number;\n\t/** Opacity / brightness / saturation preset for the surface the card sits on */\n\ttone?: PulseBeamTone;\n\t/** Slowly rotate every hue over a 14–16s cycle */\n\thueShift?: boolean;\n\t/** Override the preset brightness filter */\n\tbrightness?: number;\n\t/** Override the preset saturation filter */\n\tsaturation?: number;\n\t/** Called once the fade-in has completed */\n\tonfadein?: () => void;\n\t/** Called once the fade-out has completed and the loop has stopped */\n\tonfadeout?: () => void;\n}\n\nconst FADE_IN_MS = 600;\nconst FADE_OUT_MS = 500;\n/** Slack after the CSS duration before the JS fallback settles the fade. */\nconst FADE_SLACK_MS = 80;\n\ntype Phase = \"idle\" | \"active\" | \"fading\";\n\nexport const PulseBeam = forwardRef<HTMLDivElement, PulseBeamProps>(function PulseBeam(\n\t{\n\t\tclassName,\n\t\tchildren,\n\t\tactive = true,\n\t\tvariant = \"inner\",\n\t\tpalette = \"colorful\",\n\t\tcolors,\n\t\tstrength = 1,\n\t\tradius = 16,\n\t\tspeed = 1,\n\t\ttone = \"dark\",\n\t\thueShift = true,\n\t\tbrightness,\n\t\tsaturation,\n\t\tonfadein,\n\t\tonfadeout,\n\t\tstyle,\n\t\t...restProps\n\t},\n\tforwardedRef\n) {\n\t// Starts idle even when `active` is true so the first activation is a real\n\t// fade-in, and SSR output never flashes a fully lit ring before hydration.\n\tconst [phase, setPhaseState] = useState<Phase>(\"idle\");\n\tconst [reducedMotion, setReducedMotion] = useState(false);\n\n\t// Refs mirrored for timers and event handlers: they must read the latest\n\t// value without re-arming the fade effect (the Svelte side untracks them).\n\tconst phaseRef = useRef<Phase>(\"idle\");\n\tconst reducedMotionRef = useRef(false);\n\tconst pendingRef = useRef<\"in\" | \"out\" | null>(null);\n\tconst fadeTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\tconst onfadeinRef = useRef(onfadein);\n\tonfadeinRef.current = onfadein;\n\tconst onfadeoutRef = useRef(onfadeout);\n\tonfadeoutRef.current = onfadeout;\n\n\tconst hostRef = useRef<HTMLDivElement | null>(null);\n\t// The shared composer owns the version-correct attach/detach handling, so a\n\t// consumer callback ref that hands back a teardown gets it run.\n\tconst setHostRef = useComposedRefs<HTMLDivElement>(forwardedRef, hostRef);\n\n\tconst setPhase = (next: Phase) => {\n\t\tphaseRef.current = next;\n\t\tsetPhaseState(next);\n\t};\n\n\t// --- derived configuration -------------------------------------------------\n\n\tconst preset = LAYER_PRESETS[variant][tone];\n\tconst isMono = palette === \"mono\" && !colors?.length;\n\tconst monoFactor = isMono ? 0.5 : 1;\n\t// Memoised: the oscillator set keys the loop effect, so its identity may only\n\t// change when the motion configuration actually does.\n\tconst motion = useMemo(() => motionPreset(variant, tone, speed), [variant, tone, speed]);\n\tconst oscillators = useMemo(() => buildOscillators(motion), [motion]);\n\tconst hueEnabled = hueShift && !isMono;\n\t// Keyed on the colour CONTENTS, not the array identity: `colors` is usually\n\t// an inline literal, so a fresh array arrives on every parent render and an\n\t// identity dependency would rebuild the ~29 gradients each time for nothing.\n\tconst colorsKey = colors?.join(\"|\");\n\tconst backgrounds = useMemo(\n\t\t() => buildLayerBackgrounds({ variant, palette, colors, tone, op: motion.op }),\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t\t[variant, palette, colorsKey, tone, motion.op]\n\t);\n\tconst clampedStrength = Math.min(1, Math.max(0, Number.isFinite(strength) ? strength : 1));\n\tconst running = phase !== \"idle\";\n\n\t// --- reduced motion ----------------------------------------------------------\n\n\t// Declared FIRST: the ref must be set before the fade effect's first run,\n\t// matching the Svelte onMount-before-$effect ordering.\n\tuseEffect(() => {\n\t\tif (typeof window.matchMedia !== \"function\") return;\n\t\tconst mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n\t\treducedMotionRef.current = mq.matches;\n\t\tsetReducedMotion(mq.matches);\n\t\tconst handler = (e: MediaQueryListEvent) => {\n\t\t\treducedMotionRef.current = e.matches;\n\t\t\tsetReducedMotion(e.matches);\n\t\t};\n\t\tmq.addEventListener(\"change\", handler);\n\t\treturn () => mq.removeEventListener(\"change\", handler);\n\t}, []);\n\n\t// --- fade state machine ------------------------------------------------------\n\n\tconst settle = (kind: \"in\" | \"out\") => {\n\t\tif (pendingRef.current !== kind) return;\n\t\tpendingRef.current = null;\n\t\tclearTimeout(fadeTimerRef.current);\n\t\tif (kind === \"out\") {\n\t\t\tsetPhase(\"idle\");\n\t\t\tonfadeoutRef.current?.();\n\t\t} else {\n\t\t\tonfadeinRef.current?.();\n\t\t}\n\t};\n\n\tconst handleTransitionEnd = (e: ReactTransitionEvent<HTMLDivElement>) => {\n\t\tif (e.propertyName !== \"opacity\" || e.target !== e.currentTarget) return;\n\t\tsettle(phaseRef.current === \"fading\" ? \"out\" : \"in\");\n\t};\n\n\tuseEffect(() => {\n\t\tconst on = active;\n\t\tconst host = hostRef.current;\n\t\tclearTimeout(fadeTimerRef.current);\n\t\tif (on) {\n\t\t\t// Already active: no new fade to start, but a fade still pending from an\n\t\t\t// earlier run needs its fallback back (the cleanup above just cleared it).\n\t\t\tif (phaseRef.current !== \"active\") {\n\t\t\t\t// Force the idle style to be computed so the flip to active is a\n\t\t\t\t// transition rather than a first paint.\n\t\t\t\tif (phaseRef.current === \"idle\" && host) void host.offsetWidth;\n\t\t\t\tsetPhase(\"active\");\n\t\t\t\tpendingRef.current = \"in\";\n\t\t\t}\n\t\t} else {\n\t\t\tif (phaseRef.current === \"idle\") {\n\t\t\t\tpendingRef.current = null;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsetPhase(\"fading\");\n\t\t\tpendingRef.current = \"out\";\n\t\t}\n\t\t// Nothing left to settle — the fade already completed.\n\t\tif (pendingRef.current === null) return;\n\t\t// Fallback for the cases where no transitionend arrives: reduced\n\t\t// motion, a display:none ancestor, an offscreen host, jsdom.\n\t\t// Re-armed on EVERY run, so StrictMode's body → cleanup → body double\n\t\t// invoke cannot leave a pending fade without its fallback timer.\n\t\tconst ms = reducedMotionRef.current ? 0 : (on ? FADE_IN_MS : FADE_OUT_MS) + FADE_SLACK_MS;\n\t\tfadeTimerRef.current = setTimeout(() => settle(on ? \"in\" : \"out\"), ms);\n\t\treturn () => clearTimeout(fadeTimerRef.current);\n\t\t// `settle` only touches refs; phase/reducedMotion are read untracked, as in the source.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [active]);\n\n\t// --- shared animation loop ----------------------------------------------------\n\n\tuseEffect(() => {\n\t\tconst host = hostRef.current;\n\t\tconst hue = hueEnabled ? { period: motion.huePeriod } : null;\n\t\tif (!host) return;\n\t\t// Turning hue drift off must drop the rotation the loop last wrote, otherwise\n\t\t// the palette stays frozen at an arbitrary angle.\n\t\tif (!hue) host.style.removeProperty(\"--pb-hue\");\n\t\tif (!running || reducedMotion) return;\n\n\t\tconst handle = registerPulse(host, oscillators, hue);\n\t\tlet io: IntersectionObserver | undefined;\n\t\tif (typeof IntersectionObserver !== \"undefined\") {\n\t\t\tio = new IntersectionObserver(\n\t\t\t\t([entry]) => {\n\t\t\t\t\tif (entry) handle.setPaused(!entry.isIntersecting);\n\t\t\t\t},\n\t\t\t\t{ rootMargin: \"256px\" }\n\t\t\t);\n\t\t\tio.observe(host);\n\t\t}\n\t\treturn () => {\n\t\t\tio?.disconnect();\n\t\t\thandle.unregister();\n\t\t};\n\t}, [running, reducedMotion, oscillators, hueEnabled, motion]);\n\n\t// --- outside variant: scale blobs with the box --------------------------------\n\n\tuseEffect(() => {\n\t\tconst host = hostRef.current;\n\t\tif (!host || variant !== \"outside\" || typeof ResizeObserver === \"undefined\") return;\n\t\tconst clamp = (v: number) => Math.max(0.35, Math.min(4, v));\n\t\tconst apply = () => {\n\t\t\tconst r = host.getBoundingClientRect();\n\t\t\tif (!r.width || !r.height) return;\n\t\t\thost.style.setProperty(\"--pb-sx\", clamp(r.width / 350).toFixed(3));\n\t\t\thost.style.setProperty(\"--pb-sy\", clamp(r.height / 140).toFixed(3));\n\t\t};\n\t\tapply();\n\t\tconst ro = new ResizeObserver(apply);\n\t\tro.observe(host);\n\t\treturn () => {\n\t\t\tro.disconnect();\n\t\t\thost.style.removeProperty(\"--pb-sx\");\n\t\t\thost.style.removeProperty(\"--pb-sy\");\n\t\t};\n\t}, [variant]);\n\n\t// The host's `style` prop carries only the static configuration properties.\n\t// The loop writes the animated `--pb-*` properties imperatively; React only\n\t// removes style keys that were present in a previous `style` object, so those\n\t// writes survive re-renders — never add an animated property here.\n\tconst hostStyle = {\n\t\t...style,\n\t\t\"--pb-strength\": clampedStrength,\n\t\t\"--pb-radius\": `${radius}px`,\n\t\t\"--pb-o-stroke\": preset.stroke * monoFactor,\n\t\t\"--pb-o-glow\": preset.glow * monoFactor,\n\t\t\"--pb-o-bloom\": preset.bloom * monoFactor,\n\t\t\"--pb-brightness\": brightness ?? preset.brightness,\n\t\t\"--pb-saturation\": saturation ?? preset.saturation,\n\t\t\"--pb-glow-blur\": `${preset.glowBlur}px`,\n\t\t\"--pb-bloom-blur\": `${preset.bloomBlur}px`,\n\t} as CSSProperties;\n\n\treturn (\n\t\t<div\n\t\t\tref={setHostRef}\n\t\t\tclassName={cn(\"pulse-beam\", className)}\n\t\t\tstyle={hostStyle}\n\t\t\t{...restProps}\n\t\t\tdata-variant={variant}\n\t\t\tdata-state={phase}\n\t\t>\n\t\t\t{children}\n\t\t\t<div className=\"pulse-beam__layer pulse-beam__glow\" style={{ background: backgrounds.glow }} />\n\t\t\t<div\n\t\t\t\tclassName=\"pulse-beam__layer pulse-beam__stroke\"\n\t\t\t\tstyle={{ background: backgrounds.stroke }}\n\t\t\t\tonTransitionEnd={handleTransitionEnd}\n\t\t\t/>\n\t\t\t<div\n\t\t\t\tclassName=\"pulse-beam__layer pulse-beam__bloom\"\n\t\t\t\tstyle={{ background: backgrounds.bloom }}\n\t\t\t/>\n\t\t</div>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/pulse-beam/PulseBeam.tsx"},{"path":"react/src/components/pulse-beam/index.ts","content":"\"use client\";\n\nexport { PulseBeam } from \"./PulseBeam\";\nexport type {\n\tPulseBeamProps,\n\tPulseBeamPalette,\n\tPulseBeamTone,\n\tPulseBeamVariant,\n} from \"./PulseBeam\";\n","type":"registry:ui","target":"@ui/fancy/components/pulse-beam/index.ts"},{"path":"react/src/components/pulse-beam/pulse-beam-data.ts","content":"\"use client\";\n\n/**\n * PulseBeam — pure data and string builders.\n *\n * Everything here is DOM-free so it can be unit-tested without jsdom and\n * evaluated safely on the server. The component reads these tables to build\n * the three layer backgrounds and the oscillator set that the shared loop\n * (`pulse-beam-loop.ts`) drives through CSS custom properties.\n *\n * Custom property namespace: `--pb-*`. Every `var()` carries a fallback so the\n * static (reduced-motion, or pre-first-frame) render is already correct.\n */\n\nexport type PulseBeamVariant = \"inner\" | \"outside\";\nexport type PulseBeamPalette = \"colorful\" | \"mono\" | \"ocean\" | \"sunset\";\nexport type PulseBeamTone = \"dark\" | \"light\";\n\ntype Quadrant = \"tl\" | \"tr\" | \"bl\" | \"br\";\ntype Region = 1 | 2 | 3;\ntype Rgb = readonly [number, number, number];\n\n/** One animated custom property: a cosine breathing between `a` and `b`. */\nexport interface Oscillator {\n\tprop: string;\n\ta: number;\n\tb: number;\n\t/** seconds */\n\tperiod: number;\n\t/** seconds */\n\tdelay: number;\n\tunit: \"\" | \"px\";\n}\n\n// ---------------------------------------------------------------------------\n// Palettes — nine colour slots. Slot order is the anchor order below.\n// ---------------------------------------------------------------------------\n\nexport const PALETTES: Record<PulseBeamPalette, readonly Rgb[]> = {\n\tcolorful: [\n\t\t[255, 50, 100],\n\t\t[40, 140, 255],\n\t\t[50, 200, 80],\n\t\t[30, 185, 170],\n\t\t[100, 70, 255],\n\t\t[40, 140, 255],\n\t\t[255, 120, 40],\n\t\t[240, 50, 180],\n\t\t[180, 40, 240],\n\t],\n\tmono: [\n\t\t[180, 180, 180],\n\t\t[140, 140, 140],\n\t\t[160, 160, 160],\n\t\t[130, 130, 130],\n\t\t[170, 170, 170],\n\t\t[150, 150, 150],\n\t\t[190, 190, 190],\n\t\t[145, 145, 145],\n\t\t[165, 165, 165],\n\t],\n\tocean: [\n\t\t[100, 80, 220],\n\t\t[60, 120, 255],\n\t\t[80, 100, 200],\n\t\t[50, 140, 220],\n\t\t[120, 80, 255],\n\t\t[70, 130, 255],\n\t\t[140, 100, 240],\n\t\t[90, 110, 230],\n\t\t[130, 70, 255],\n\t],\n\tsunset: [\n\t\t[255, 80, 50],\n\t\t[255, 160, 40],\n\t\t[255, 120, 60],\n\t\t[255, 200, 50],\n\t\t[255, 100, 80],\n\t\t[255, 180, 60],\n\t\t[255, 60, 60],\n\t\t[255, 140, 50],\n\t\t[255, 90, 70],\n\t],\n};\n\n/**\n * Where each slot sits on the box edge, which corner group drives its alpha,\n * and which oscillator region (1–3) drives its shape and drift.\n */\ninterface Slot {\n\tx: string;\n\ty: string;\n\tquad: Quadrant;\n\tregion: Region;\n}\n\nconst SLOTS: readonly Slot[] = [\n\t{ x: \"33%\", y: \"-7.4%\", quad: \"tl\", region: 1 },\n\t{ x: \"12%\", y: \"-5%\", quad: \"tl\", region: 2 },\n\t{ x: \"2.1%\", y: \"68.3%\", quad: \"bl\", region: 3 },\n\t{ x: \"2.1%\", y: \"68.3%\", quad: \"bl\", region: 1 },\n\t{ x: \"74.4%\", y: \"100%\", quad: \"br\", region: 2 },\n\t{ x: \"55%\", y: \"100%\", quad: \"br\", region: 3 },\n\t{ x: \"93.9%\", y: \"0%\", quad: \"tr\", region: 1 },\n\t{ x: \"100%\", y: \"27.1%\", quad: \"tr\", region: 2 },\n\t{ x: \"100%\", y: \"27.1%\", quad: \"tr\", region: 3 },\n];\n\n/** Animated blob: slot index + base ellipse size (px). */\ninterface Blob {\n\tslot: number;\n\tw: number;\n\th: number;\n\t/** Optional anchor override (outside variant hugs the edge more tightly). */\n\tx?: string;\n\ty?: string;\n}\n\n/** Static blob (bloom layer): no oscillators, fixed alpha. */\ntype StaticBlob = Blob;\n\n// Inner variant ---------------------------------------------------------------\n\nconst INNER_STROKE: readonly Blob[] = [\n\t{ slot: 0, w: 70, h: 40 },\n\t{ slot: 1, w: 60, h: 35 },\n\t{ slot: 2, w: 40, h: 70 },\n\t{ slot: 3, w: 20, h: 35 },\n\t{ slot: 4, w: 180, h: 32 },\n\t{ slot: 5, w: 85, h: 26 },\n\t{ slot: 6, w: 74, h: 32 },\n\t{ slot: 7, w: 26, h: 42 },\n\t{ slot: 8, w: 52, h: 48 },\n];\n\nconst INNER_GLOW: readonly Blob[] = [\n\t{ slot: 0, w: 65, h: 35 },\n\t{ slot: 1, w: 55, h: 30 },\n\t{ slot: 2, w: 35, h: 65 },\n\t{ slot: 3, w: 15, h: 30 },\n\t{ slot: 4, w: 173, h: 28 },\n\t{ slot: 5, w: 80, h: 22 },\n\t{ slot: 6, w: 69, h: 28 },\n\t{ slot: 7, w: 22, h: 38 },\n\t{ slot: 8, w: 47, h: 44 },\n];\n\nconst INNER_BLOOM: readonly StaticBlob[] = [\n\t{ slot: 0, w: 84, h: 48 },\n\t{ slot: 1, w: 72, h: 42 },\n\t{ slot: 2, w: 48, h: 84 },\n\t{ slot: 4, w: 216, h: 38 },\n\t{ slot: 5, w: 102, h: 31 },\n\t{ slot: 6, w: 89, h: 38 },\n\t{ slot: 8, w: 62, h: 58 },\n];\n\n// Outside variant -------------------------------------------------------------\n\nconst OUTSIDE_STROKE: readonly Blob[] = [\n\t{ slot: 0, w: 80, h: 19, x: \"27%\", y: \"0%\" },\n\t{ slot: 6, w: 74, h: 11, x: \"73%\", y: \"-1%\" },\n\t{ slot: 7, w: 15, h: 44, x: \"100%\", y: \"33%\" },\n\t{ slot: 8, w: 19, h: 38, x: \"101%\", y: \"72%\" },\n\t{ slot: 4, w: 84, h: 13, x: \"67%\", y: \"100%\" },\n\t{ slot: 1, w: 60, h: 21, x: \"24%\", y: \"101%\" },\n\t{ slot: 2, w: 17, h: 40, x: \"0%\", y: \"60%\" },\n\t{ slot: 3, w: 13, h: 32, x: \"-1%\", y: \"28%\" },\n];\n\nconst OUTSIDE_GLOW: readonly Blob[] = OUTSIDE_STROKE;\n\nconst OUTSIDE_BLOOM: readonly StaticBlob[] = [\n\t{ slot: 0, w: 110, h: 30, x: \"27%\", y: \"3%\" },\n\t{ slot: 6, w: 100, h: 20, x: \"73%\", y: \"1%\" },\n\t{ slot: 7, w: 26, h: 62, x: \"100%\", y: \"33%\" },\n\t{ slot: 8, w: 30, h: 56, x: \"101%\", y: \"72%\" },\n\t{ slot: 4, w: 120, h: 22, x: \"67%\", y: \"99%\" },\n\t{ slot: 1, w: 88, h: 32, x: \"24%\", y: \"99%\" },\n\t{ slot: 2, w: 28, h: 58, x: \"0%\", y: \"60%\" },\n];\n\n// ---------------------------------------------------------------------------\n// Presets\n// ---------------------------------------------------------------------------\n\nexport interface LayerPreset {\n\tstroke: number;\n\tglow: number;\n\tbloom: number;\n\tbrightness: number;\n\tsaturation: number;\n\t/** px — outside variant only (inner glow is masked, not blurred) */\n\tglowBlur: number;\n\t/** px */\n\tbloomBlur: number;\n}\n\nexport const LAYER_PRESETS: Record<PulseBeamVariant, Record<PulseBeamTone, LayerPreset>> = {\n\tinner: {\n\t\tdark: {\n\t\t\tstroke: 1.54,\n\t\t\tglow: 0.44,\n\t\t\tbloom: 0.66,\n\t\t\tbrightness: 0.75,\n\t\t\tsaturation: 1.2,\n\t\t\tglowBlur: 0,\n\t\t\tbloomBlur: 8,\n\t\t},\n\t\tlight: {\n\t\t\tstroke: 0.32,\n\t\t\tglow: 0.4,\n\t\t\tbloom: 0.8,\n\t\t\tbrightness: 1.3,\n\t\t\tsaturation: 0.75,\n\t\t\tglowBlur: 0,\n\t\t\tbloomBlur: 8,\n\t\t},\n\t},\n\toutside: {\n\t\tdark: {\n\t\t\tstroke: 0.94,\n\t\t\tglow: 0.34,\n\t\t\tbloom: 0.3,\n\t\t\tbrightness: 1.9,\n\t\t\tsaturation: 1.2,\n\t\t\tglowBlur: 3,\n\t\t\tbloomBlur: 22.5,\n\t\t},\n\t\tlight: {\n\t\t\tstroke: 1.96,\n\t\t\tglow: 1.04,\n\t\t\tbloom: 0.42,\n\t\t\tbrightness: 1.7,\n\t\t\tsaturation: 0.6,\n\t\t\tglowBlur: 6,\n\t\t\tbloomBlur: 15,\n\t\t},\n\t},\n};\n\nexport interface MotionPreset {\n\t/** shape scale amplitude (±) */\n\tsp: number;\n\t/** drift amplitude in px (±) */\n\tdr: number;\n\t/** corner alpha dip: alphas breathe between 1 - op and 1 */\n\top: number;\n\t/** global height scale amplitude (±) */\n\tgh: number;\n\t/** base period for drift + alpha (s) */\n\tbs: number;\n\t/** base period for shape (s) */\n\tss: number;\n\t/** period for global height (s) */\n\tghs: number;\n\t/** full hue rotation period (s) */\n\thuePeriod: number;\n}\n\nconst MOTION: Record<PulseBeamVariant, Record<PulseBeamTone, MotionPreset>> = {\n\tinner: {\n\t\tdark: { sp: 0.28, dr: 33, op: 0.48, gh: 0.34, bs: 1.9, ss: 2.6, ghs: 2.4, huePeriod: 16 },\n\t\tlight: { sp: 0.28, dr: 40, op: 0.45, gh: 0.22, bs: 2.6, ss: 4.6, ghs: 5.5, huePeriod: 16 },\n\t},\n\toutside: {\n\t\tdark: { sp: 0.28, dr: 14, op: 0.46, gh: 0.16, bs: 2.3, ss: 6.4, ghs: 2.4, huePeriod: 14 },\n\t\tlight: { sp: 0.36, dr: 19, op: 0, gh: 0.58, bs: 3.7, ss: 4.6, ghs: 3.8, huePeriod: 14 },\n\t},\n};\n\n/** Motion preset with `speed` applied (higher = faster breathing; hue period untouched). */\nexport function motionPreset(\n\tvariant: PulseBeamVariant,\n\ttone: PulseBeamTone,\n\tspeed = 1\n): MotionPreset {\n\tconst base = MOTION[variant][tone];\n\tconst s = Number.isFinite(speed) && speed > 0 ? speed : 1;\n\treturn { ...base, bs: base.bs / s, ss: base.ss / s, ghs: base.ghs / s };\n}\n\n// ---------------------------------------------------------------------------\n// Oscillators\n// ---------------------------------------------------------------------------\n\n/** Cosine breathing: `a` at t = delay, `b` half a period later, back to `a`. */\nexport function oscillate(o: Oscillator, t: number): number {\n\tconst phase = (t - o.delay) / o.period;\n\tconst k = (1 - Math.cos(Math.PI * 2 * phase)) / 2;\n\treturn o.a + (o.b - o.a) * k;\n}\n\n/** The 17 animated properties (3 regions × 4, global height, 4 corner alphas). */\nexport function buildOscillators(m: MotionPreset): Oscillator[] {\n\tconst { sp, dr, op, gh, bs, ss, ghs } = m;\n\tconst n = (prop: string, a: number, b: number, period: number, delay = 0): Oscillator => ({\n\t\tprop,\n\t\ta,\n\t\tb,\n\t\tperiod,\n\t\tdelay,\n\t\tunit: \"\",\n\t});\n\tconst px = (prop: string, a: number, b: number, period: number): Oscillator => ({\n\t\tprop,\n\t\ta,\n\t\tb,\n\t\tperiod,\n\t\tdelay: 0,\n\t\tunit: \"px\",\n\t});\n\treturn [\n\t\tn(\"--pb-bw1\", 1 - sp, 1 + sp * 1.1, ss * 0.9),\n\t\tn(\"--pb-bh1\", 1 + sp * 0.9, 1 - sp * 0.85, ss * 1.26),\n\t\tpx(\"--pb-bx1\", -dr, dr * 0.9, bs * 1.6),\n\t\tpx(\"--pb-by1\", dr * 0.55, -dr * 0.7, bs * 1.6),\n\t\tn(\"--pb-bw2\", 1 + sp, 1 - sp * 0.85, ss * 1.1),\n\t\tn(\"--pb-bh2\", 1 - sp * 0.8, 1 + sp * 1.05, ss * 0.81),\n\t\tpx(\"--pb-bx2\", dr * 0.8, -dr * 0.9, bs * 1.88),\n\t\tpx(\"--pb-by2\", -dr, dr * 0.65, bs * 1.88),\n\t\tn(\"--pb-bw3\", 1 - sp * 0.6, 1 + sp * 1.15, ss * 0.98),\n\t\tn(\"--pb-bh3\", 1 + sp * 0.75, 1 - sp, ss * 1.4),\n\t\tpx(\"--pb-bx3\", -dr * 0.6, dr, bs * 1.45),\n\t\tpx(\"--pb-by3\", -dr * 0.85, dr * 0.45, bs * 1.45),\n\t\tn(\"--pb-gh\", 1 - gh, 1 + gh, ghs),\n\t\tn(\"--pb-op-tl\", 1 - op, 1, bs),\n\t\tn(\"--pb-op-tr\", 1 - op, 1, bs * 1.32, bs * 0.28),\n\t\tn(\"--pb-op-bl\", 1 - op, 1, bs * 0.84, bs * 0.55),\n\t\tn(\"--pb-op-br\", 1 - op, 1, bs * 1.58, bs * 0.83),\n\t];\n}\n\n// ---------------------------------------------------------------------------\n// Colour helpers\n// ---------------------------------------------------------------------------\n\nconst HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;\nconst RGB_RE = /^rgba?\\(\\s*(\\d{1,3})\\s*[, ]\\s*(\\d{1,3})\\s*[, ]\\s*(\\d{1,3})/i;\nconst RGB_ALPHA_RE =\n\t/^rgba?\\(\\s*\\d{1,3}\\s*[, ]\\s*\\d{1,3}\\s*[, ]\\s*\\d{1,3}\\s*[,/]\\s*([0-9]*\\.?[0-9]+)(%?)\\s*\\)$/i;\n\n/** Parse `#rgb`, `#rrggbb`, `rgb()` / `rgba()` to a triplet; anything else → null. */\nexport function parseRgb(input: string): Rgb | null {\n\tconst s = input.trim();\n\tconst hex = HEX_RE.exec(s);\n\tif (hex) {\n\t\tconst h = hex[1]!;\n\t\tconst full = h.length === 3 ? h.replace(/./g, (c) => c + c) : h;\n\t\treturn [\n\t\t\tparseInt(full.slice(0, 2), 16),\n\t\t\tparseInt(full.slice(2, 4), 16),\n\t\t\tparseInt(full.slice(4, 6), 16),\n\t\t];\n\t}\n\tconst rgb = RGB_RE.exec(s);\n\tif (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];\n\treturn null;\n}\n\n/** Alpha carried by an `rgba()` string, 0–1; null when the colour has none. */\nfunction parseRgbAlpha(input: string): number | null {\n\tconst m = RGB_ALPHA_RE.exec(input.trim());\n\tif (!m) return null;\n\tconst n = Number(m[1]);\n\tif (!Number.isFinite(n)) return null;\n\treturn Math.min(1, Math.max(0, m[2] ? n / 100 : n));\n}\n\n/** Colour with a `var()`-driven alpha. Falls back to `color-mix()` for opaque tokens. */\nfunction withAlpha(color: Rgb | string, alpha: string): string {\n\tif (typeof color !== \"string\") return `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${alpha})`;\n\tconst rgb = parseRgb(color);\n\tif (rgb) {\n\t\t// An `rgba()` override keeps its own alpha: multiply it into the animated one\n\t\t// instead of dropping it, so a translucent colour stays translucent.\n\t\tconst own = parseRgbAlpha(color);\n\t\tconst a = own === null || own === 1 ? alpha : `calc(${alpha} * ${own})`;\n\t\treturn `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${a})`;\n\t}\n\treturn `color-mix(in srgb, ${color} calc(${alpha} * 100%), transparent)`;\n}\n\nfunction slotColors(palette: PulseBeamPalette, colors?: string[]): readonly (Rgb | string)[] {\n\tconst base = PALETTES[palette];\n\tif (!colors || colors.length === 0) return base;\n\treturn base.map((c, i) => {\n\t\tconst override = colors[i % colors.length];\n\t\treturn override && override.trim() ? override : c;\n\t});\n}\n\n// ---------------------------------------------------------------------------\n// Layer backgrounds\n// ---------------------------------------------------------------------------\n\nexport interface BackgroundOptions {\n\tvariant: PulseBeamVariant;\n\tpalette: PulseBeamPalette;\n\tcolors?: string[];\n\ttone: PulseBeamTone;\n\t/** `op` from the motion preset — sets the bloom's static alpha */\n\top: number;\n}\n\nexport interface LayerBackgrounds {\n\tstroke: string;\n\tglow: string;\n\tbloom: string;\n}\n\nfunction animatedBlob(b: Blob, color: Rgb | string, scaled: boolean): string {\n\tconst s = SLOTS[b.slot]!;\n\tconst r = s.region;\n\tconst sx = scaled ? \" * var(--pb-sx, 1)\" : \"\";\n\tconst sy = scaled ? \" * var(--pb-sy, 1)\" : \"\";\n\tconst w = `calc(${b.w}px * var(--pb-bw${r}, 1)${sx})`;\n\tconst h = `calc(${b.h}px * var(--pb-bh${r}, 1) * var(--pb-gh, 1)${sy})`;\n\tconst x = `calc(${b.x ?? s.x} + var(--pb-bx${r}, 0px))`;\n\tconst y = `calc(${b.y ?? s.y} + var(--pb-by${r}, 0px))`;\n\treturn `radial-gradient(ellipse ${w} ${h} at ${x} ${y}, ${withAlpha(color, `var(--pb-op-${s.quad}, 1)`)}, transparent)`;\n}\n\nfunction staticBlob(b: StaticBlob, color: Rgb | string, alpha: number, scaled: boolean): string {\n\tconst s = SLOTS[b.slot]!;\n\tconst w = scaled ? `calc(${b.w}px * var(--pb-sx, 1))` : `${b.w}px`;\n\tconst h = scaled ? `calc(${b.h}px * var(--pb-sy, 1))` : `${b.h}px`;\n\tconst a = +alpha.toFixed(3);\n\treturn `radial-gradient(ellipse ${w} ${h} at ${b.x ?? s.x} ${b.y ?? s.y}, ${withAlpha(color, String(a))}, transparent)`;\n}\n\nfunction cornerDots(tone: PulseBeamTone): string[] {\n\tconst rgb = tone === \"dark\" ? \"255, 255, 255\" : \"0, 0, 0\";\n\tconst a = tone === \"dark\" ? 0.18 : 0.08;\n\tconst corners: [string, string, Quadrant][] = [\n\t\t[\"0%\", \"0%\", \"tl\"],\n\t\t[\"100%\", \"0%\", \"tr\"],\n\t\t[\"0%\", \"100%\", \"bl\"],\n\t\t[\"100%\", \"100%\", \"br\"],\n\t];\n\treturn corners.map(\n\t\t([x, y, q]) =>\n\t\t\t`radial-gradient(ellipse 60px 60px at ${x} ${y}, rgba(${rgb}, calc(${a} * var(--pb-op-${q}, 1))), transparent 70%)`\n\t);\n}\n\n/** Build the three layer `background` values for the given configuration. */\nexport function buildLayerBackgrounds(o: BackgroundOptions): LayerBackgrounds {\n\tconst colors = slotColors(o.palette, o.colors);\n\tconst outside = o.variant === \"outside\";\n\tconst strokeBlobs = outside ? OUTSIDE_STROKE : INNER_STROKE;\n\tconst glowBlobs = outside ? OUTSIDE_GLOW : INNER_GLOW;\n\tconst bloomBlobs = outside ? OUTSIDE_BLOOM : INNER_BLOOM;\n\tconst bloomAlpha = 1 - o.op * 0.5;\n\n\tconst stroke = strokeBlobs.map((b) => animatedBlob(b, colors[b.slot]!, outside));\n\tconst glow = glowBlobs.map((b) => animatedBlob(b, colors[b.slot]!, outside));\n\tif (!outside) glow.push(...cornerDots(o.tone));\n\tconst bloom = bloomBlobs.map((b) => staticBlob(b, colors[b.slot]!, bloomAlpha, outside));\n\n\treturn { stroke: stroke.join(\", \"), glow: glow.join(\", \"), bloom: bloom.join(\", \") };\n}\n","type":"registry:ui","target":"@ui/fancy/components/pulse-beam/pulse-beam-data.ts"},{"path":"react/src/components/pulse-beam/pulse-beam-loop.ts","content":"\"use client\";\n\n/**\n * PulseBeam — one shared requestAnimationFrame loop for every mounted instance.\n *\n * Each instance registers its host element plus its oscillator set; the loop\n * writes the animated custom properties straight onto `el.style` (no Svelte\n * reactivity in the hot path). One loop for N instances keeps them in phase and\n * keeps the frame budget to a single callback.\n *\n * Throttled to ~30 fps: the blobs are soft and slow, 60 fps is invisible here\n * and doubles the style-recalc cost. Paused entries are skipped; when every\n * entry is paused the loop stops rescheduling and restarts on the next\n * `setPaused(false)`.\n *\n * Nothing here touches `window` at module evaluation time — safe under SSR.\n */\nimport { oscillate, type Oscillator } from \"./pulse-beam-data\";\n\ninterface Entry {\n\tel: HTMLElement;\n\toscillators: Oscillator[];\n\thue: { period: number } | null;\n\tpaused: boolean;\n}\n\nexport interface PulseHandle {\n\tsetPaused(paused: boolean): void;\n\tunregister(): void;\n}\n\nconst MIN_DT = 1000 / 30 - 2;\n\nconst entries = new Set<Entry>();\nlet rafId: number | null = null;\nlet last = -Infinity;\n\nfunction write(e: Entry, t: number) {\n\tconst s = e.el.style;\n\tfor (const o of e.oscillators) {\n\t\tconst v = oscillate(o, t);\n\t\ts.setProperty(o.prop, o.unit === \"px\" ? `${v.toFixed(2)}px` : v.toFixed(4));\n\t}\n\tif (e.hue) {\n\t\tconst deg = ((t / e.hue.period) % 1) * 360;\n\t\ts.setProperty(\"--pb-hue\", `${deg.toFixed(2)}deg`);\n\t}\n}\n\nfunction tick(now: number) {\n\tlet live = 0;\n\tfor (const e of entries) if (!e.paused) live++;\n\tif (live === 0) {\n\t\trafId = null;\n\t\treturn;\n\t}\n\trafId = requestAnimationFrame(tick);\n\tif (now - last < MIN_DT) return;\n\tlast = now;\n\tconst t = now / 1000;\n\tfor (const e of entries) if (!e.paused) write(e, t);\n}\n\nfunction ensureRunning() {\n\tif (rafId !== null) return;\n\tif (typeof requestAnimationFrame !== \"function\") return;\n\trafId = requestAnimationFrame(tick);\n}\n\nfunction stop() {\n\tif (rafId !== null && typeof cancelAnimationFrame === \"function\") cancelAnimationFrame(rafId);\n\trafId = null;\n\tlast = -Infinity;\n}\n\n/** Register a host element; returns a handle to pause or remove it. */\nexport function registerPulse(\n\tel: HTMLElement,\n\toscillators: Oscillator[],\n\thue: { period: number } | null\n): PulseHandle {\n\tconst entry: Entry = { el, oscillators, hue, paused: false };\n\tentries.add(entry);\n\tensureRunning();\n\treturn {\n\t\tsetPaused(paused) {\n\t\t\tentry.paused = paused;\n\t\t\tif (!paused) ensureRunning();\n\t\t},\n\t\tunregister() {\n\t\t\tentries.delete(entry);\n\t\t\tif (entries.size === 0) stop();\n\t\t},\n\t};\n}\n\n/** Test hook: number of registered instances. */\nexport function pulseEntryCount(): number {\n\treturn entries.size;\n}\n","type":"registry:ui","target":"@ui/fancy/components/pulse-beam/pulse-beam-loop.ts"},{"path":"react/src/components/pulse-beam/pulse-beam.css","content":".pulse-beam {\n\tposition: relative;\n\tisolation: isolate;\n\tborder-radius: var(--pb-radius);\n\toverflow: hidden;\n}\n\n.pulse-beam[data-variant=\"outside\"] {\n\toverflow: visible;\n}\n\n.pulse-beam .pulse-beam__layer {\n\tposition: absolute;\n\tinset: 0;\n\tborder-radius: var(--pb-radius);\n\tpointer-events: none;\n\ttransition: opacity 600ms ease;\n}\n\n/* Filter runs before the mask: the bloom blurs its gradients, then the 1px\n   ring is cut from the blurred image — a softened ring, not a halo. */\n.pulse-beam .pulse-beam__stroke,\n.pulse-beam .pulse-beam__bloom {\n\tpadding: 1px;\n\tclip-path: inset(0 round var(--pb-radius));\n\t-webkit-mask:\n\t\tlinear-gradient(#fff 0 0) content-box,\n\t\tlinear-gradient(#fff 0 0);\n\t-webkit-mask-composite: xor;\n\tmask:\n\t\tlinear-gradient(#fff 0 0) content-box,\n\t\tlinear-gradient(#fff 0 0);\n\tmask-composite: exclude;\n}\n\n.pulse-beam .pulse-beam__stroke {\n\tz-index: 2;\n\topacity: calc(var(--pb-o-stroke) * var(--pb-strength));\n\tfilter: hue-rotate(var(--pb-hue, 0deg)) brightness(var(--pb-brightness))\n\t\tsaturate(var(--pb-saturation));\n}\n\n.pulse-beam .pulse-beam__glow {\n\tz-index: 1;\n\tclip-path: inset(0 round var(--pb-radius));\n\t-webkit-mask-image:\n\t\tlinear-gradient(#fff, transparent 28px, transparent calc(100% - 28px), #fff),\n\t\tlinear-gradient(to right, #fff, transparent 28px, transparent calc(100% - 28px), #fff);\n\t-webkit-mask-composite: source-over;\n\tmask-image:\n\t\tlinear-gradient(#fff, transparent 28px, transparent calc(100% - 28px), #fff),\n\t\tlinear-gradient(to right, #fff, transparent 28px, transparent calc(100% - 28px), #fff);\n\tmask-composite: add;\n\topacity: calc(var(--pb-o-glow) * var(--pb-strength));\n\tfilter: hue-rotate(var(--pb-hue, 0deg)) brightness(var(--pb-brightness))\n\t\tsaturate(var(--pb-saturation));\n}\n\n.pulse-beam .pulse-beam__bloom {\n\tz-index: 3;\n\topacity: calc(var(--pb-o-bloom) * var(--pb-strength));\n\tfilter: blur(var(--pb-bloom-blur)) hue-rotate(var(--pb-hue, 0deg))\n\t\tbrightness(var(--pb-brightness)) saturate(var(--pb-saturation));\n}\n\n/* Outside: the stroke stays a ring; glow and bloom bleed past the box and\n   sit behind the (opaque) children inside the isolated stacking context. */\n.pulse-beam[data-variant=\"outside\"] .pulse-beam__glow {\n\tinset: -10px;\n\tz-index: -1;\n\tborder-radius: calc(var(--pb-radius) + 10px);\n\tclip-path: none;\n\t-webkit-mask: none;\n\tmask: none;\n\ttransform: scale(0.95, 0.9);\n\tfilter: blur(var(--pb-glow-blur)) hue-rotate(var(--pb-hue, 0deg))\n\t\tbrightness(var(--pb-brightness)) saturate(var(--pb-saturation));\n}\n\n.pulse-beam[data-variant=\"outside\"] .pulse-beam__bloom {\n\tinset: -30px;\n\tz-index: -1;\n\tborder-radius: calc(var(--pb-radius) + 30px);\n\tpadding: 0;\n\tclip-path: none;\n\t-webkit-mask: none;\n\tmask: none;\n\ttransform: scale(0.95, 0.9);\n}\n\n/* Fade states */\n.pulse-beam[data-state=\"idle\"] .pulse-beam__layer {\n\topacity: 0;\n\tvisibility: hidden;\n\ttransition: none;\n}\n\n.pulse-beam[data-state=\"fading\"] .pulse-beam__layer {\n\topacity: 0;\n\ttransition-duration: 500ms;\n}\n\n.pulse-beam[data-state=\"active\"] .pulse-beam__layer,\n.pulse-beam[data-state=\"fading\"] .pulse-beam__layer {\n\twill-change: opacity, filter;\n}\n\n@media (prefers-reduced-motion: reduce) {\n\t.pulse-beam .pulse-beam__layer {\n\t\ttransition: none;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/pulse-beam/pulse-beam.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/pulse-beam?framework=react"}}