+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Only the globe takes the pointer; around it, events reach what is behind. */}
+
+ {ripples.map((ripple) => (
+
+ ))}
+
+ );
+};
diff --git a/src/plugins/AIComponents/VoiceVisualization/VoiceVisualizationView.tsx b/src/plugins/AIComponents/VoiceVisualization/VoiceVisualizationView.tsx
new file mode 100644
index 000000000..986851639
--- /dev/null
+++ b/src/plugins/AIComponents/VoiceVisualization/VoiceVisualizationView.tsx
@@ -0,0 +1,265 @@
+import { type PointerEvent, useEffect, useRef } from 'react';
+import clsx from 'clsx';
+
+import { useReducedMotionPreference } from '../../../components/MessageList/hooks/useReducedMotionPreference';
+import { useStableCallback } from '../../../utils/useStableCallback';
+import { type VoiceDriverInput, VoiceVisualizationDriver } from './driver';
+import { audioLevelInput, expressivityInput } from './math';
+import { toLocalPoint } from './hitArea';
+import { FALLBACK_COLOR, readVoiceTheme, toHsvPalette } from './palette';
+import type { VoiceRenderer } from './renderers/types';
+import type { Hsv, VoiceVisualizationProps } from './types';
+import { useVoiceStateLabel } from './useVoiceStateLabel';
+
+/** How many frames pass between two reads of the theme, about once a second at 60 Hz. */
+const THEME_FRAMES = 60;
+
+type ViewProps = VoiceVisualizationProps & {
+ /** Whether a point, centred and normalized to the shorter side with y up, is on the shape. */
+ contains: (x: number, y: number) => boolean;
+ /** Makes the renderer for a freshly mounted canvas, or null when it cannot draw there. */
+ createRenderer: (canvas: HTMLCanvasElement) => VoiceRenderer | null;
+ /** Remounts the canvas and remakes the renderer when it changes. */
+ rendererKey: string;
+ /**
+ * The most device pixels per CSS pixel the canvas gets: a blurred shape looks the same at 1,
+ * at a quarter of the pixels of a 2x display, while a thin line needs the screen's own.
+ */
+ maxPixelRatio: number;
+};
+
+/**
+ * What VoiceWave and VoiceGlow share: the motion advanced once a frame, the canvas kept at the
+ * element's size, the theme's colors, reduced motion, the pointer and the accessible name.
+ *
+ * The loop runs only while the element is on screen, reads `audioLevel` once a frame and
+ * redraws without re-rendering React.
+ */
+export const VoiceVisualizationView = ({
+ audioLevel,
+ className,
+ colors,
+ contains,
+ createRenderer,
+ expressivity,
+ maxPixelRatio,
+ onPointerLeave,
+ onPointerMove,
+ onPointerUp,
+ rendererKey,
+ state,
+ ...restProps
+}: ViewProps) => {
+ const label = useVoiceStateLabel(state);
+ const reduceMotion = useReducedMotionPreference();
+ const rootRef = useRef