UNPKG

@medalsocial/meda

Version:

Shared Meda UI shell and runtime package.

122 lines (121 loc) 5.94 kB
'use client'; import { jsx as _jsx } from "react/jsx-runtime"; // SPDX-License-Identifier: Apache-2.0 import { Canvas } from '@react-three/fiber'; import * as React from 'react'; import { cn } from '../lib/utils.js'; import { Scene } from './voice-orb-scene.js'; // Note: CSS for `.meda-voice-orb` is shipped via @medalsocial/meda/styles.css // (re-exported from globals.css). We avoid a JS-side CSS import here so // Meda's tsc-only build pipeline doesn't need CSS bundling. // --------------------------------------------------------------------------- // Theming helpers // --------------------------------------------------------------------------- // Fallback colors used only if CSS custom properties fail to resolve. // Both values are from the canonical brand ramp (.lib.pen): // brand-400 (#9A6AC2) and brand-500 (#7E3FAC). // Runtime path reads --primary and --accent first. const FALLBACK_COLOR = '#9A6AC2'; // brand-400 function hslToHex(h, s, l) { const sl = s / 100; const ll = l / 100; const a = sl * Math.min(ll, 1 - ll); const f = (n) => { const k = (n + h / 30) % 12; const color = ll - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); return Math.round(255 * color) .toString(16) .padStart(2, '0'); }; return `#${f(0)}${f(8)}${f(4)}`; } /** * Convert any CSS color token to a hex string the Three.js shader can consume. * * Handles: * - hex: #RGB / #RRGGBB / #RRGGBBAA * - Tailwind/shadcn space-separated HSL: "271 36% 60%" * - hsl(), rgb(), oklch(), color(), var() — resolved via browser CSSOM */ export function parseColor(value, fallback = FALLBACK_COLOR) { const v = value.trim(); if (!v) return fallback; // Already a hex literal — pass through directly. if (/^#[0-9a-fA-F]{3,8}$/.test(v)) return v; // Tailwind/shadcn convention: "H S% L%" (no leading "hsl(") const hslMatch = v.match(/^(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/); if (hslMatch) { return hslToHex(Number(hslMatch[1]), Number(hslMatch[2]), Number(hslMatch[3])); } // Functional notation — hsl(), rgb(), oklch(), color(), var(), etc. // Let the browser resolve it via a throw-away element. if (typeof document !== 'undefined' && (v.includes('(') || v.startsWith('var'))) { const probe = document.createElement('div'); probe.style.display = 'none'; probe.style.color = v; document.body.appendChild(probe); const computed = getComputedStyle(probe).color; document.body.removeChild(probe); const m = computed.match(/rgb(?:a)?\((\d+)[,\s]+(\d+)[,\s]+(\d+)/); if (m) { const r = Number(m[1]); const g = Number(m[2]); const b = Number(m[3]); return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`; } } return fallback; } function readMedaColors(el) { const style = getComputedStyle(el); const primary = style.getPropertyValue('--primary').trim(); const accent = style.getPropertyValue('--accent').trim(); const colorA = parseColor(primary); const colorB = accent ? parseColor(accent) : colorA; return [colorA, colorB]; } const DEFAULT_COLORS = ['#9A6AC2', '#7E3FAC']; // brand-400 → brand-500 // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export const VoiceOrb = React.forwardRef(function VoiceOrb({ pressed, level = 0, outputLevel = 0, phase = 'idle', size = 144, disabled, label = 'Hold to talk', colors: colorsProp, variant = 'aurora', className, style, ...rest }, ref) { const buttonRef = React.useRef(null); // Merge forwarded ref React.useImperativeHandle(ref, () => buttonRef.current); // Resolved theme colors (from CSSOM unless overridden by prop) const [resolvedColors, setResolvedColors] = React.useState(colorsProp ?? DEFAULT_COLORS); // prefers-reduced-motion const [reducedMotion, setReducedMotion] = React.useState(false); React.useEffect(() => { const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); setReducedMotion(mq.matches); const handler = (e) => setReducedMotion(e.matches); mq.addEventListener('change', handler); return () => mq.removeEventListener('change', handler); }, []); // Theme color resolution React.useEffect(() => { if (colorsProp) { setResolvedColors(colorsProp); return; } const update = () => { setResolvedColors(readMedaColors(document.documentElement)); }; update(); const observer = new MutationObserver(update); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'], }); return () => observer.disconnect(); }, [colorsProp]); const btnStyle = { width: size, height: size, ...style, }; return (_jsx("button", { ref: buttonRef, type: "button", "aria-pressed": pressed, "aria-label": label, disabled: disabled, "data-phase": phase, "data-pressed": pressed, className: cn('meda-voice-orb', 'relative inline-flex items-center justify-center rounded-full', 'select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 'transition-transform duration-200 ease-out', disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer', pressed ? 'scale-[0.97]' : 'scale-100', className), style: btnStyle, ...rest, children: _jsx(Canvas, { gl: { alpha: true, antialias: true, premultipliedAlpha: true }, style: { pointerEvents: 'none' }, children: _jsx(Scene, { level: level, outputLevel: outputLevel, phase: phase, colors: resolvedColors, reducedMotion: reducedMotion, pressed: pressed, variant: variant }) }) })); });