@medalsocial/meda 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +45 -227
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/shell/index.d.ts +15 -15
  6. package/dist/shell/index.js +15 -15
  7. package/dist/shell/public.d.ts +2 -4
  8. package/dist/shell/public.js +1 -3
  9. package/dist/shell/shell-app-rail.d.ts +1 -1
  10. package/dist/shell/shell-header.js +1 -1
  11. package/dist/shell/shell-layout-utils.d.ts +1 -1
  12. package/dist/shell/shell-module-nav.d.ts +1 -1
  13. package/dist/shell/shell-panel-rail.d.ts +1 -1
  14. package/dist/shell/shell-route-utils.d.ts +1 -1
  15. package/dist/shell/shell-scrollable-content.d.ts +1 -1
  16. package/dist/shell/shell-state.js +4 -1
  17. package/dist/shell/shell-tab-bar.d.ts +1 -1
  18. package/dist/shell/shell-tab-bar.js +1 -1
  19. package/dist/shell/utils.d.ts +1 -1
  20. package/dist/shell/workbench-layout.d.ts +1 -1
  21. package/dist/styles.css +22 -0
  22. package/dist/voice/index.d.ts +9 -0
  23. package/dist/voice/index.js +4 -0
  24. package/dist/voice/public.d.ts +1 -0
  25. package/dist/voice/public.js +1 -0
  26. package/dist/voice/types.d.ts +8 -0
  27. package/dist/voice/types.js +1 -0
  28. package/dist/voice/use-mic-capture.d.ts +14 -0
  29. package/dist/voice/use-mic-capture.js +185 -0
  30. package/dist/voice/voice-level.d.ts +8 -0
  31. package/dist/voice/voice-level.js +29 -0
  32. package/dist/voice/voice-orb-scene.d.ts +12 -0
  33. package/dist/voice/voice-orb-scene.js +121 -0
  34. package/dist/voice/voice-orb-shader.d.ts +2 -0
  35. package/dist/voice/voice-orb-shader.js +255 -0
  36. package/dist/voice/voice-orb-volume.d.ts +12 -0
  37. package/dist/voice/voice-orb-volume.js +21 -0
  38. package/dist/voice/voice-orb.d.ts +45 -0
  39. package/dist/voice/voice-orb.js +123 -0
  40. package/dist/voice/voice-status-pill.d.ts +7 -0
  41. package/dist/voice/voice-status-pill.js +26 -0
  42. package/package.json +53 -12
@@ -0,0 +1,255 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Portions adapted from ElevenLabs UI's Orb component (MIT):
3
+ // https://github.com/elevenlabs/ui/blob/main/apps/www/registry/elevenlabs-ui/ui/orb.tsx
4
+ // Copyright (c) ElevenLabs Inc.
5
+ // Adaptations: Meda token-driven theming, 5-phase state model (idle/listening/thinking/
6
+ // speaking/error), outputLevel uniform, procedural simplex noise (replaces texture sampling).
7
+ // Simplex noise implementation based on Stefan Gustavson's public-domain GLSL noise.
8
+ export const VERTEX_SHADER = /* glsl */ `
9
+ uniform float uTime;
10
+ varying vec2 vUv;
11
+
12
+ void main() {
13
+ vUv = uv;
14
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
15
+ }
16
+ `;
17
+ // Stefan Gustavson's simplex noise — public domain
18
+ // https://github.com/stegu/webgl-noise (public domain)
19
+ const SIMPLEX_NOISE_GLSL = /* glsl */ `
20
+ vec3 mod289_3(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
21
+ vec4 mod289_4(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
22
+ vec4 permute(vec4 x) { return mod289_4(((x * 34.0) + 10.0) * x); }
23
+ vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
24
+
25
+ float snoise(vec3 v) {
26
+ const vec2 C = vec2(1.0/6.0, 1.0/3.0);
27
+ const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
28
+
29
+ vec3 i = floor(v + dot(v, C.yyy));
30
+ vec3 x0 = v - i + dot(i, C.xxx);
31
+
32
+ vec3 g = step(x0.yzx, x0.xyz);
33
+ vec3 l = 1.0 - g;
34
+ vec3 i1 = min(g.xyz, l.zxy);
35
+ vec3 i2 = max(g.xyz, l.zxy);
36
+
37
+ vec3 x1 = x0 - i1 + C.xxx;
38
+ vec3 x2 = x0 - i2 + C.yyy;
39
+ vec3 x3 = x0 - D.yyy;
40
+
41
+ i = mod289_3(i);
42
+ vec4 p = permute(permute(permute(
43
+ i.z + vec4(0.0, i1.z, i2.z, 1.0))
44
+ + i.y + vec4(0.0, i1.y, i2.y, 1.0))
45
+ + i.x + vec4(0.0, i1.x, i2.x, 1.0));
46
+
47
+ float n_ = 0.142857142857;
48
+ vec3 ns = n_ * D.wyz - D.xzx;
49
+
50
+ vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
51
+
52
+ vec4 x_ = floor(j * ns.z);
53
+ vec4 y_ = floor(j - 7.0 * x_);
54
+
55
+ vec4 x = x_ * ns.x + ns.yyyy;
56
+ vec4 y = y_ * ns.x + ns.yyyy;
57
+ vec4 h = 1.0 - abs(x) - abs(y);
58
+
59
+ vec4 b0 = vec4(x.xy, y.xy);
60
+ vec4 b1 = vec4(x.zw, y.zw);
61
+
62
+ vec4 s0 = floor(b0) * 2.0 + 1.0;
63
+ vec4 s1 = floor(b1) * 2.0 + 1.0;
64
+ vec4 sh = -step(h, vec4(0.0));
65
+
66
+ vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
67
+ vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
68
+
69
+ vec3 p0 = vec3(a0.xy, h.x);
70
+ vec3 p1 = vec3(a0.zw, h.y);
71
+ vec3 p2 = vec3(a1.xy, h.z);
72
+ vec3 p3 = vec3(a1.zw, h.w);
73
+
74
+ vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
75
+ p0 *= norm.x;
76
+ p1 *= norm.y;
77
+ p2 *= norm.z;
78
+ p3 *= norm.w;
79
+
80
+ vec4 m = max(0.5 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
81
+ m = m * m;
82
+ return 105.0 * dot(m * m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
83
+ }
84
+
85
+ // Fractional Brownian Motion: stacked octaves of simplex noise
86
+ float fbm(vec3 p) {
87
+ float value = 0.0;
88
+ float amplitude = 0.5;
89
+ float frequency = 1.0;
90
+ for (int i = 0; i < 4; i++) {
91
+ value += amplitude * snoise(p * frequency);
92
+ amplitude *= 0.5;
93
+ frequency *= 2.0;
94
+ }
95
+ return value;
96
+ }
97
+ `;
98
+ export const FRAGMENT_SHADER = /* glsl */ `
99
+ ${SIMPLEX_NOISE_GLSL}
100
+
101
+ uniform float uTime;
102
+ uniform float uAnimation;
103
+ uniform float uInputVolume;
104
+ uniform float uOutputVolume;
105
+ uniform vec3 uColorA;
106
+ uniform vec3 uColorB;
107
+ uniform float uPhaseT;
108
+ uniform float uErrorFlash;
109
+ uniform float uPressed;
110
+ uniform float uOpacity;
111
+ uniform float uVariant; // 0 = metal, 1 = aurora
112
+
113
+ const vec3 ERROR_COLOR = vec3(0.85, 0.18, 0.18);
114
+ const float PI = 3.14159265358979323846;
115
+
116
+ varying vec2 vUv;
117
+
118
+ // =============================================================================
119
+ // METAL look — smooth chrome-like sphere with strong specular + depth shading.
120
+ // Apple Siri / Vision Pro adjacent.
121
+ // =============================================================================
122
+ vec4 metalLook(vec2 uv, float r, vec3 cA, vec3 cB, float drive) {
123
+ // Two octaves of noise for surface variation
124
+ float n = fbm(vec3(uv * 1.4, uAnimation * 0.18)) * 0.5 + 0.5;
125
+ float n2 = fbm(vec3(uv * 2.6 + 4.7, uAnimation * 0.10)) * 0.5 + 0.5;
126
+
127
+ // Vertical light bias — top brighter than bottom (3D illusion)
128
+ float topLight = smoothstep(-1.0, 1.0, uv.y * 0.6 + 0.4);
129
+ float mixT = clamp(n * 0.55 + topLight * 0.45 + n2 * 0.08, 0.0, 1.0);
130
+
131
+ // Light cream color for the highlight band — gives the chrome look.
132
+ // Brightens cA toward white for the upper portion of the sphere.
133
+ vec3 highlightTone = mix(cA, vec3(0.95, 0.92, 0.98), 0.55);
134
+ vec3 deepTone = mix(cB, vec3(0.04, 0.02, 0.10), 0.55);
135
+
136
+ // 4-stop ramp: cream highlight → cA → cB → deep core
137
+ vec3 base;
138
+ if (mixT > 0.66) {
139
+ base = mix(cA, highlightTone, (mixT - 0.66) / 0.34);
140
+ } else if (mixT > 0.33) {
141
+ base = mix(cB, cA, (mixT - 0.33) / 0.33);
142
+ } else {
143
+ base = mix(deepTone, cB, mixT / 0.33);
144
+ }
145
+
146
+ // Specular: tight bright spot top-left
147
+ vec2 specCenter = vec2(-0.35, 0.45);
148
+ float spec = smoothstep(0.45, 0.0, length(uv - specCenter));
149
+ base += vec3(1.0) * spec * 0.32;
150
+
151
+ // Bottom shadow for depth
152
+ float shadow = smoothstep(0.3, 1.0, -uv.y) * 0.35;
153
+ base = mix(base, base * 0.55, shadow);
154
+
155
+ // Inner glow on audio
156
+ float glow = (1.0 - r) * (1.0 - r);
157
+ base += cA * drive * glow * 0.5;
158
+
159
+ return vec4(base, 1.0);
160
+ }
161
+
162
+ // =============================================================================
163
+ // AURORA look — soft layered ribbons drifting across a translucent disc.
164
+ // Northern-lights aesthetic. Calm, atmospheric.
165
+ // =============================================================================
166
+ vec4 auroraLook(vec2 uv, float r, vec3 cA, vec3 cB, float drive) {
167
+ float t = uAnimation * 0.25;
168
+
169
+ // Disc base — deep, translucent. cB darkened.
170
+ vec3 baseColor = mix(cB * 0.25, cB * 0.55, smoothstep(1.0, 0.0, r));
171
+
172
+ // Three drifting ribbons at different y-offsets, angles, and speeds.
173
+ // Each ribbon is a horizontally-stretched soft band whose vertical position
174
+ // oscillates with time + noise.
175
+
176
+ // Ribbon 1 — upper, primary color
177
+ float y1 = uv.y - (sin(t * 0.7 + uv.x * 1.5) * 0.18) - 0.15;
178
+ float band1 = exp(-pow(y1 / 0.18, 2.0));
179
+ float n1 = fbm(vec3(uv.x * 2.0 + t, uv.y * 0.8, t * 0.4)) * 0.5 + 0.5;
180
+ vec3 ribbon1 = cA * band1 * n1 * 1.4;
181
+
182
+ // Ribbon 2 — middle, accent color (cooler — shift cA toward cyan)
183
+ vec3 cool = mix(cA, vec3(0.36, 0.83, 1.0), 0.45);
184
+ float y2 = uv.y - (sin(t * 0.55 + uv.x * 2.2 + 1.7) * 0.22) + 0.05;
185
+ float band2 = exp(-pow(y2 / 0.14, 2.0));
186
+ float n2 = fbm(vec3(uv.x * 2.6 + t * 0.6 + 3.1, uv.y * 0.6, t * 0.3)) * 0.5 + 0.5;
187
+ vec3 ribbon2 = cool * band2 * n2 * 1.0;
188
+
189
+ // Ribbon 3 — lower, warm accent (shift cA toward pink)
190
+ vec3 warm = mix(cA, vec3(1.0, 0.49, 0.83), 0.55);
191
+ float y3 = uv.y - (sin(t * 0.4 + uv.x * 1.8 + 4.2) * 0.18) + 0.30;
192
+ float band3 = exp(-pow(y3 / 0.16, 2.0));
193
+ float n3 = fbm(vec3(uv.x * 2.2 + t * 0.45 + 7.9, uv.y * 0.7, t * 0.35)) * 0.5 + 0.5;
194
+ vec3 ribbon3 = warm * band3 * n3 * 0.85;
195
+
196
+ // Stack ribbons additively over the disc
197
+ vec3 base = baseColor + ribbon1 + ribbon2 + ribbon3;
198
+
199
+ // Audio drive intensifies all ribbons + brightens center
200
+ base += (ribbon1 + ribbon2 + ribbon3) * drive * 0.6;
201
+ float glow = (1.0 - r) * (1.0 - r);
202
+ base += cA * drive * glow * 0.35;
203
+
204
+ // Subtle inner rim accent for legibility against dark backgrounds
205
+ float rim = smoothstep(0.82, 0.98, r);
206
+ base += cA * rim * 0.22;
207
+
208
+ return vec4(base, 1.0);
209
+ }
210
+
211
+ void main() {
212
+ vec2 uv = vUv * 2.0 - 1.0;
213
+ float r = length(uv);
214
+
215
+ // Discard fragments outside the unit circle so the canvas stays clean.
216
+ if (r > 1.0) {
217
+ gl_FragColor = vec4(0.0);
218
+ return;
219
+ }
220
+
221
+ // ---- Phase masks (0..1 for each phase) ---------------------------------
222
+ // uPhaseT smoothly interpolates between numeric phases:
223
+ // 0=idle, 1=listening, 2=thinking, 3=speaking, 4=error.
224
+ float isListening = clamp(1.0 - abs(uPhaseT - 1.0), 0.0, 1.0);
225
+ float isThinking = clamp(1.0 - abs(uPhaseT - 2.0), 0.0, 1.0);
226
+ float isSpeaking = clamp(1.0 - abs(uPhaseT - 3.0), 0.0, 1.0);
227
+
228
+ float inputDrive = uInputVolume * isListening;
229
+ float outputDrive = uOutputVolume * isSpeaking;
230
+ float thinkPulse = isThinking * (0.5 + 0.5 * sin(uTime * 2.0));
231
+ float drive = max(inputDrive, max(outputDrive, thinkPulse * 0.4));
232
+
233
+ // ---- Resolve effective colors with error tint --------------------------
234
+ vec3 cA = mix(uColorA, ERROR_COLOR, uErrorFlash);
235
+ vec3 cB = mix(uColorB, ERROR_COLOR * 0.55, uErrorFlash * 0.7);
236
+
237
+ // ---- Pick variant ------------------------------------------------------
238
+ vec4 result;
239
+ if (uVariant > 0.5) {
240
+ result = auroraLook(uv, r, cA, cB, drive);
241
+ } else {
242
+ result = metalLook(uv, r, cA, cB, drive);
243
+ }
244
+
245
+ vec3 base = result.rgb;
246
+
247
+ // ---- Press: slight core brighten ---------------------------------------
248
+ base = mix(base, base * 1.18, uPressed * (1.0 - smoothstep(0.0, 0.55, r)));
249
+
250
+ // ---- Edge falloff: smooth alpha at the disc boundary -------------------
251
+ float alpha = smoothstep(1.0, 0.92, r);
252
+
253
+ gl_FragColor = vec4(base, alpha * uOpacity);
254
+ }
255
+ `;
@@ -0,0 +1,12 @@
1
+ export interface VolumeSmoother {
2
+ /** Call each frame with the raw 0..1 signal. */
3
+ update(raw: number): void;
4
+ /** Current smoothed value 0..1. */
5
+ readonly value: number;
6
+ }
7
+ /**
8
+ * Creates a volume smoother with asymmetric attack/decay envelopes.
9
+ * @param attackAlpha - Per-frame alpha toward higher values (default 0.85 ≈ 50ms at 60Hz)
10
+ * @param decayAlpha - Per-frame alpha toward lower values (default 0.30 ≈ 300ms at 60Hz)
11
+ */
12
+ export declare function createVolumeSmoother(attackAlpha?: number, decayAlpha?: number): VolumeSmoother;
@@ -0,0 +1,21 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Volume signal smoother: fast attack (~50ms), slow decay (~300ms).
3
+ // Attack/decay constants are frame-rate independent when called at ~60Hz.
4
+ /**
5
+ * Creates a volume smoother with asymmetric attack/decay envelopes.
6
+ * @param attackAlpha - Per-frame alpha toward higher values (default 0.85 ≈ 50ms at 60Hz)
7
+ * @param decayAlpha - Per-frame alpha toward lower values (default 0.30 ≈ 300ms at 60Hz)
8
+ */
9
+ export function createVolumeSmoother(attackAlpha = 0.85, decayAlpha = 0.3) {
10
+ let _value = 0;
11
+ return {
12
+ update(raw) {
13
+ const clamped = Math.min(1, Math.max(0, raw));
14
+ const alpha = clamped > _value ? attackAlpha : decayAlpha;
15
+ _value = _value + (clamped - _value) * alpha;
16
+ },
17
+ get value() {
18
+ return _value;
19
+ },
20
+ };
21
+ }
@@ -0,0 +1,45 @@
1
+ import * as React from 'react';
2
+ import type { TurnPhase } from './types.js';
3
+ import { type VoiceOrbVariant } from './voice-orb-scene.js';
4
+ export type { VoiceOrbVariant } from './voice-orb-scene.js';
5
+ /**
6
+ * Convert any CSS color token to a hex string the Three.js shader can consume.
7
+ *
8
+ * Handles:
9
+ * - hex: #RGB / #RRGGBB / #RRGGBBAA
10
+ * - Tailwind/shadcn space-separated HSL: "271 36% 60%"
11
+ * - hsl(), rgb(), oklch(), color(), var() — resolved via browser CSSOM
12
+ */
13
+ export declare function parseColor(value: string, fallback?: string): string;
14
+ export interface VoiceOrbProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
15
+ /** Held / not held. Drives the squish + halo intensity. */
16
+ pressed: boolean;
17
+ /** 0..1 mic input level. Drives shader input-volume uniform. */
18
+ level?: number;
19
+ /** 0..1 TTS playback level. Drives shader output-volume uniform. */
20
+ outputLevel?: number;
21
+ /** Visual phase. Drives state-specific shader behavior. */
22
+ phase?: TurnPhase;
23
+ /** Diameter in px. Defaults to 144. */
24
+ size?: number;
25
+ disabled?: boolean;
26
+ /** A11y label. Defaults to "Hold to talk". */
27
+ label?: string;
28
+ /**
29
+ * Override the gradient pair [colorA, colorB]. If omitted, reads --primary
30
+ * and --accent from CSS at mount and on theme change.
31
+ */
32
+ colors?: [string, string];
33
+ /**
34
+ * Visual variant.
35
+ *
36
+ * - `'aurora'` (default): soft layered ribbons of color drifting across a
37
+ * translucent disc. Northern-lights aesthetic. Calm, atmospheric, reads
38
+ * like motion even at idle. Default for Medal apps.
39
+ * - `'metal'`: smooth chrome-like sphere with strong specular + depth
40
+ * shading. Apple Siri / Vision Pro adjacent. Use for object-like,
41
+ * premium-feeling presence.
42
+ */
43
+ variant?: VoiceOrbVariant;
44
+ }
45
+ export declare const VoiceOrb: React.ForwardRefExoticComponent<VoiceOrbProps & React.RefAttributes<HTMLButtonElement>>;
@@ -0,0 +1,123 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { Canvas } from '@react-three/fiber';
4
+ import * as React from 'react';
5
+ import { Scene } from './voice-orb-scene.js';
6
+ // Note: CSS for `.meda-voice-orb` is shipped via @medalsocial/meda/styles.css
7
+ // (re-exported from globals.css). We avoid a JS-side CSS import here so
8
+ // Meda's tsc-only build pipeline doesn't need CSS bundling.
9
+ // ---------------------------------------------------------------------------
10
+ // Theming helpers
11
+ // ---------------------------------------------------------------------------
12
+ const FALLBACK_COLOR = '#9A6AC2'; // Pilot purple
13
+ function hslToHex(h, s, l) {
14
+ const sl = s / 100;
15
+ const ll = l / 100;
16
+ const a = sl * Math.min(ll, 1 - ll);
17
+ const f = (n) => {
18
+ const k = (n + h / 30) % 12;
19
+ const color = ll - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
20
+ return Math.round(255 * color)
21
+ .toString(16)
22
+ .padStart(2, '0');
23
+ };
24
+ return `#${f(0)}${f(8)}${f(4)}`;
25
+ }
26
+ /**
27
+ * Convert any CSS color token to a hex string the Three.js shader can consume.
28
+ *
29
+ * Handles:
30
+ * - hex: #RGB / #RRGGBB / #RRGGBBAA
31
+ * - Tailwind/shadcn space-separated HSL: "271 36% 60%"
32
+ * - hsl(), rgb(), oklch(), color(), var() — resolved via browser CSSOM
33
+ */
34
+ export function parseColor(value, fallback = FALLBACK_COLOR) {
35
+ const v = value.trim();
36
+ if (!v)
37
+ return fallback;
38
+ // Already a hex literal — pass through directly.
39
+ if (/^#[0-9a-fA-F]{3,8}$/.test(v))
40
+ return v;
41
+ // Tailwind/shadcn convention: "H S% L%" (no leading "hsl(")
42
+ const hslMatch = v.match(/^(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/);
43
+ if (hslMatch) {
44
+ return hslToHex(Number(hslMatch[1]), Number(hslMatch[2]), Number(hslMatch[3]));
45
+ }
46
+ // Functional notation — hsl(), rgb(), oklch(), color(), var(), etc.
47
+ // Let the browser resolve it via a throw-away element.
48
+ if (typeof document !== 'undefined' && (v.includes('(') || v.startsWith('var'))) {
49
+ const probe = document.createElement('div');
50
+ probe.style.display = 'none';
51
+ probe.style.color = v;
52
+ document.body.appendChild(probe);
53
+ const computed = getComputedStyle(probe).color;
54
+ document.body.removeChild(probe);
55
+ const m = computed.match(/rgb(?:a)?\((\d+)[,\s]+(\d+)[,\s]+(\d+)/);
56
+ if (m) {
57
+ const r = Number(m[1]);
58
+ const g = Number(m[2]);
59
+ const b = Number(m[3]);
60
+ return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`;
61
+ }
62
+ }
63
+ return fallback;
64
+ }
65
+ function readMedaColors(el) {
66
+ const style = getComputedStyle(el);
67
+ const primary = style.getPropertyValue('--primary').trim();
68
+ const accent = style.getPropertyValue('--accent').trim();
69
+ const colorA = parseColor(primary);
70
+ const colorB = accent ? parseColor(accent) : colorA;
71
+ return [colorA, colorB];
72
+ }
73
+ const DEFAULT_COLORS = ['#9A6AC2', '#7B4FAB'];
74
+ // ---------------------------------------------------------------------------
75
+ // Component
76
+ // ---------------------------------------------------------------------------
77
+ 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) {
78
+ const buttonRef = React.useRef(null);
79
+ // Merge forwarded ref
80
+ React.useImperativeHandle(ref, () => buttonRef.current);
81
+ // Resolved theme colors (from CSSOM unless overridden by prop)
82
+ const [resolvedColors, setResolvedColors] = React.useState(colorsProp ?? DEFAULT_COLORS);
83
+ // prefers-reduced-motion
84
+ const [reducedMotion, setReducedMotion] = React.useState(false);
85
+ React.useEffect(() => {
86
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
87
+ setReducedMotion(mq.matches);
88
+ const handler = (e) => setReducedMotion(e.matches);
89
+ mq.addEventListener('change', handler);
90
+ return () => mq.removeEventListener('change', handler);
91
+ }, []);
92
+ // Theme color resolution
93
+ React.useEffect(() => {
94
+ if (colorsProp) {
95
+ setResolvedColors(colorsProp);
96
+ return;
97
+ }
98
+ const update = () => {
99
+ setResolvedColors(readMedaColors(document.documentElement));
100
+ };
101
+ update();
102
+ const observer = new MutationObserver(update);
103
+ observer.observe(document.documentElement, {
104
+ attributes: true,
105
+ attributeFilter: ['class', 'data-theme'],
106
+ });
107
+ return () => observer.disconnect();
108
+ }, [colorsProp]);
109
+ const btnStyle = {
110
+ width: size,
111
+ height: size,
112
+ ...style,
113
+ };
114
+ return (_jsx("button", { ref: buttonRef, type: "button", "aria-pressed": pressed, "aria-label": label, disabled: disabled, "data-phase": phase, "data-pressed": pressed, className: [
115
+ 'meda-voice-orb',
116
+ 'relative inline-flex items-center justify-center rounded-full',
117
+ 'select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
118
+ 'transition-transform duration-200 ease-out',
119
+ disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
120
+ pressed ? 'scale-[0.97]' : 'scale-100',
121
+ className ?? '',
122
+ ].join(' '), 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 }) }) }));
123
+ });
@@ -0,0 +1,7 @@
1
+ import type { TurnPhase } from './types.js';
2
+ export interface VoiceStatusPillProps {
3
+ phase: TurnPhase;
4
+ thinkingForMs?: number;
5
+ className?: string;
6
+ }
7
+ export declare function VoiceStatusPill({ phase, thinkingForMs, className }: VoiceStatusPillProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ const PHASE_LABEL = {
3
+ idle: 'Idle',
4
+ listening: 'Listening',
5
+ thinking: 'Thinking',
6
+ speaking: 'Speaking',
7
+ error: 'Error',
8
+ };
9
+ export function VoiceStatusPill({ phase, thinkingForMs, className }) {
10
+ const tone = phase === 'listening'
11
+ ? 'bg-primary text-primary-foreground'
12
+ : phase === 'speaking'
13
+ ? 'bg-emerald-500 text-white'
14
+ : phase === 'error'
15
+ ? 'bg-destructive text-destructive-foreground'
16
+ : 'bg-muted text-muted-foreground';
17
+ const seconds = thinkingForMs ? (thinkingForMs / 1000).toFixed(1) : null;
18
+ return (_jsxs("span", { role: "status", "data-phase": phase, className: [
19
+ 'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium',
20
+ tone,
21
+ className ?? '',
22
+ ].join(' '), children: [_jsx("span", { className: [
23
+ 'size-1.5 rounded-full bg-current',
24
+ phase === 'thinking' ? 'animate-pulse' : '',
25
+ ].join(' ') }), PHASE_LABEL[phase], phase === 'thinking' && seconds && _jsxs("span", { className: "opacity-70", children: ["\u00B7 ", seconds, "s"] })] }));
26
+ }
package/package.json CHANGED
@@ -1,12 +1,19 @@
1
1
  {
2
2
  "name": "@medalsocial/meda",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Shared Meda UI shell and runtime package.",
5
5
  "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Medal-Social/meda.git"
9
+ },
10
+ "homepage": "https://github.com/Medal-Social/meda#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/Medal-Social/meda/issues"
13
+ },
6
14
  "type": "module",
7
15
  "main": "./dist/index.js",
8
16
  "types": "./dist/index.d.ts",
9
- "packageManager": "pnpm@10.28.0",
10
17
  "files": [
11
18
  "README.md",
12
19
  "components.json",
@@ -21,21 +28,25 @@
21
28
  "types": "./dist/shell/index.d.ts",
22
29
  "default": "./dist/shell/index.js"
23
30
  },
31
+ "./voice": {
32
+ "types": "./dist/voice/index.d.ts",
33
+ "default": "./dist/voice/index.js"
34
+ },
24
35
  "./styles.css": "./dist/styles.css"
25
36
  },
26
- "scripts": {
27
- "build": "pnpm exec tsc -p tsconfig.build.json && node ./scripts/build.mjs",
28
- "prepack": "pnpm build",
29
- "test": "vitest run --environment jsdom",
30
- "test:run": "vitest run --environment jsdom",
31
- "typecheck": "tsc --noEmit",
32
- "version": "changeset version",
33
- "release": "pnpm build && pnpm publish --access public --no-git-checks"
37
+ "publishConfig": {
38
+ "access": "public"
34
39
  },
35
40
  "peerDependencies": {
41
+ "@react-three/fiber": "^9.0.0",
36
42
  "react": ">=19",
37
43
  "react-dom": ">=19"
38
44
  },
45
+ "peerDependenciesMeta": {
46
+ "@react-three/fiber": {
47
+ "optional": true
48
+ }
49
+ },
39
50
  "dependencies": {
40
51
  "@base-ui/react": "^1.4.0",
41
52
  "@fontsource-variable/geist": "^5.2.8",
@@ -46,17 +57,47 @@
46
57
  "lucide-react": "^1.8.0",
47
58
  "react-resizable-panels": "^4.10.0",
48
59
  "tailwind-merge": "^3.5.0",
60
+ "three": "^0.170.0",
49
61
  "vaul": "^1.1.2"
50
62
  },
51
63
  "devDependencies": {
64
+ "@biomejs/biome": "^2.4.12",
52
65
  "@changesets/changelog-github": "^0.5.1",
53
66
  "@changesets/cli": "^2.29.0",
67
+ "@react-three/drei": "^10.7.7",
68
+ "@react-three/fiber": "^9.6.0",
69
+ "@tailwindcss/vite": "^4.2.4",
70
+ "@types/three": "^0.184.0",
54
71
  "@testing-library/jest-dom": "^6.9.1",
55
72
  "@testing-library/react": "^16.3.2",
56
73
  "@types/react": "^19.2.14",
57
74
  "@types/react-dom": "^19.2.3",
75
+ "@vitejs/plugin-react": "^6.0.1",
76
+ "husky": "^9.1.7",
58
77
  "jsdom": "^29.0.2",
78
+ "react": "^19.2.4",
79
+ "react-dom": "^19.2.4",
80
+ "tailwindcss": "^4.2.4",
59
81
  "typescript": "~6.0.2",
60
- "vitest": "^4.1.4"
82
+ "vite": "^8.0.4",
83
+ "vitest": "^4.1.4",
84
+ "wrangler": "^4.0.0"
85
+ },
86
+ "scripts": {
87
+ "build": "pnpm exec tsc -p tsconfig.build.json && node ./scripts/build.mjs",
88
+ "lint": "biome check .",
89
+ "lint:fix": "biome check --write .",
90
+ "format": "biome format --write .",
91
+ "demo:dev": "vite --config vite.config.demo.ts",
92
+ "demo:build": "vite build --config vite.config.demo.ts",
93
+ "worker:build": "pnpm demo:build && node ./scripts/copy-registry-to-dist.mjs",
94
+ "worker:dev": "pnpm worker:build && wrangler dev",
95
+ "worker:deploy": "pnpm worker:build && wrangler deploy",
96
+ "test": "vitest run --environment jsdom",
97
+ "test:run": "vitest run --environment jsdom",
98
+ "typecheck": "tsc --noEmit",
99
+ "version": "changeset version",
100
+ "release": "pnpm build && changeset publish",
101
+ "registry:validate": "node ./registry/scripts/validate-registry.mjs"
61
102
  }
62
- }
103
+ }