diff --git a/design-system/apps/design-lab/src/components/VoiceCallPreview.css b/design-system/apps/design-lab/src/components/VoiceCallPreview.css
new file mode 100644
index 0000000000..ec65abf534
--- /dev/null
+++ b/design-system/apps/design-lab/src/components/VoiceCallPreview.css
@@ -0,0 +1,16 @@
+.lab-voice-call-preview {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ inline-size: min(480px, 100%);
+ block-size: 680px;
+}
+
+.lab-voice-call-preview--compact { block-size: 440px; }
+
+.lab-voice-particle-preview {
+ inline-size: min(300px, 100%);
+ block-size: 300px;
+ border-radius: var(--openbitfun-radius-xl);
+ background: color-mix(in srgb, var(--openbitfun-color-content-on-light) 80%, transparent);
+}
diff --git a/design-system/apps/design-lab/src/components/VoiceCallPreview.tsx b/design-system/apps/design-lab/src/components/VoiceCallPreview.tsx
new file mode 100644
index 0000000000..9d42f002e2
--- /dev/null
+++ b/design-system/apps/design-lab/src/components/VoiceCallPreview.tsx
@@ -0,0 +1,30 @@
+import { useState } from "react";
+import { Button, VoiceCallPanel, VoiceParticleLogo, type VoiceCallPhase } from "@openbitfun/ui";
+import { useI18n } from "../i18n";
+import "./VoiceCallPreview.css";
+
+/** Both catalog and workbench consume the published components, without simulated audio. */
+export function VoiceCallPreview({ state = "live", compact = false }: { state?: string; compact?: boolean }) {
+ const { t } = useI18n();
+ const [muted, setMuted] = useState(state === "muted");
+ const [closed, setClosed] = useState(false);
+ const [settingsRequested, setSettingsRequested] = useState(false);
+ const phase: VoiceCallPhase = state === "connecting" || state === "ending" || state === "error" ? state : "live";
+ return
+ {closed ? : setClosed(true)} onClose={() => setClosed(true)} onEnd={() => setClosed(true)}
+ onToggleMute={() => setMuted(value => !value)} onOpenSettings={() => setSettingsRequested(value => !value)}
+ />}
+
;
+}
+
+export function VoiceParticlePreview({ active = true }: { active?: boolean }) {
+ return
;
+}
diff --git a/design-system/apps/design-lab/src/i18n/componentMetadata.ts b/design-system/apps/design-lab/src/i18n/componentMetadata.ts
index 7e771f2c1b..d1f512b67c 100644
--- a/design-system/apps/design-lab/src/i18n/componentMetadata.ts
+++ b/design-system/apps/design-lab/src/i18n/componentMetadata.ts
@@ -41,6 +41,8 @@ const descriptionKeys: Readonly> = {
Input: "component.Input.description",
KeyHint: "component.KeyHint.description",
LauncherButton: "component.LauncherButton.description",
+ VoiceCallPanel: "component.VoiceCallPanel.description",
+ VoiceParticleLogo: "component.VoiceParticleLogo.description",
LoadingState: "component.LoadingState.description",
Menu: "component.Menu.description",
MobileActionSheet: "component.MobileActionSheet.description",
diff --git a/design-system/apps/design-lab/src/i18n/messages.ts b/design-system/apps/design-lab/src/i18n/messages.ts
index fa48d5a4b9..2c75bd2b07 100644
--- a/design-system/apps/design-lab/src/i18n/messages.ts
+++ b/design-system/apps/design-lab/src/i18n/messages.ts
@@ -1,6 +1,21 @@
import type { DesignLabLocale } from "./core.mjs";
export const enUSMessages = {
+ "component.VoiceCallPanel.description": "A controlled live-call surface with a particle logo, transcripts and call controls.",
+ "component.VoiceParticleLogo.description": "A reusable particle logo that reacts separately to microphone and assistant playback audio.",
+ "voice.preview.title": "Live Call",
+ "voice.preview.back": "Back to chat",
+ "voice.preview.mute": "Mute microphone",
+ "voice.preview.unmute": "Unmute microphone",
+ "voice.preview.settings": "Voice settings",
+ "voice.preview.end": "End call",
+ "voice.preview.reopen": "Open call preview",
+ "voice.preview.user": "Hello, I want to make a portfolio website",
+ "voice.preview.assistant": "Hello! I’d be happy to help you create a portfolio website.\nA strong portfolio usually includes:",
+ "voice.preview.settingsRequested": "Settings action received by the preview host.",
+ "voice.preview.connecting": "Connecting…",
+ "voice.preview.ending": "Ending call…",
+ "voice.preview.error": "Connection interrupted",
"app.brandSubtitle": "Independent Design Lab",
"app.closeNavigation": "Close navigation",
"app.openNavigation": "Open navigation",
@@ -747,6 +762,21 @@ export type MessageKey = keyof typeof enUSMessages;
export type MessageCatalog = Readonly>;
export const zhCNMessages = {
+ "component.VoiceCallPanel.description": "受控通话面板,包含粒子标志、转写文本和通话控件。",
+ "component.VoiceParticleLogo.description": "可复用的粒子标志,分别响应麦克风与 AI 播放音频。",
+ "voice.preview.title": "实时通话",
+ "voice.preview.back": "返回聊天",
+ "voice.preview.mute": "麦克风静音",
+ "voice.preview.unmute": "取消麦克风静音",
+ "voice.preview.settings": "语音设置",
+ "voice.preview.end": "挂断通话",
+ "voice.preview.reopen": "打开通话预览",
+ "voice.preview.user": "你好,我想制作一个作品集网站",
+ "voice.preview.assistant": "你好!很乐意帮你创建作品集网站。\n一个出色的作品集通常包括:",
+ "voice.preview.settingsRequested": "预览宿主已收到设置操作。",
+ "voice.preview.connecting": "正在连接…",
+ "voice.preview.ending": "正在结束通话…",
+ "voice.preview.error": "连接已中断",
"app.brandSubtitle": "独立 Design Lab",
"app.closeNavigation": "关闭导航",
"app.openNavigation": "打开导航",
@@ -1491,6 +1521,21 @@ export const zhCNMessages = {
export const zhTWMessages = {
...zhCNMessages,
+ "component.VoiceCallPanel.description": "受控通話面板,包含粒子標誌、轉寫文字與通話控制項。",
+ "component.VoiceParticleLogo.description": "可重用的粒子標誌,分別回應麥克風與 AI 播放音訊。",
+ "voice.preview.title": "即時通話",
+ "voice.preview.back": "返回聊天",
+ "voice.preview.mute": "麥克風靜音",
+ "voice.preview.unmute": "取消麥克風靜音",
+ "voice.preview.settings": "語音設定",
+ "voice.preview.end": "掛斷通話",
+ "voice.preview.reopen": "開啟通話預覽",
+ "voice.preview.user": "你好,我想製作一個作品集網站",
+ "voice.preview.assistant": "你好!很樂意幫你建立作品集網站。\n一個出色的作品集通常包括:",
+ "voice.preview.settingsRequested": "預覽宿主已收到設定操作。",
+ "voice.preview.connecting": "正在連線…",
+ "voice.preview.ending": "正在結束通話…",
+ "voice.preview.error": "連線已中斷",
"patterns.workspace.parent": "父資料夾",
"patterns.workspace.name": "工作區名稱",
"patterns.workspace.fullPath": "完整路徑",
diff --git a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx
index a8a6dd1247..ec7e4839ef 100644
--- a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx
+++ b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx
@@ -1,4 +1,5 @@
import { Fragment, useMemo, useState } from "react";
+import { VoiceCallPreview, VoiceParticlePreview } from "../components/VoiceCallPreview";
import { Clipboard, List } from "lucide-react";
import {
ActionCard,
@@ -497,6 +498,12 @@ export function ComponentDetailPage({
if (component.name === "LauncherButton") {
return 'import { Icon, LauncherButton } from "@openbitfun/ui";\n\n}>\n Hello\n';
}
+ if (component.name === "VoiceParticleLogo") {
+ return 'import { VoiceParticleLogo } from "@openbitfun/ui";\n\n// Return live FFT byte bins from fftSize=256 analysers.\n ({\n user: microphoneFrequencyData,\n assistant: playbackFrequencyData,\n assistantSpeaking: isAudioPlaying,\n})} />';
+ }
+ if (component.name === "VoiceCallPanel") {
+ return 'import { VoiceCallPanel } from "@openbitfun/ui";\n\n';
+ }
if (component.name === "ActionItem") {
const metadataProp = actionItemShowMetadata ? `\n metadata="12"` : "";
return `import { Icon, ActionItem, KeyHint } from "@openbitfun/ui";\n\n, label: "${t("components.preview.add")}" },\n { id: "more", icon: , label: "${t("components.preview.more")}" },\n ]}\n leading={}${metadataProp}\n shortcut={K}\n>\n ${t("components.preview.assistant")}\n`;
@@ -892,6 +899,8 @@ export function ComponentDetailPage({
previewVariant = variant,
applyInspectorControls = false,
) {
+ if (component.name === "VoiceCallPanel") return ;
+ if (component.name === "VoiceParticleLogo") return ;
if (isFlowChatComponent) {
return (
;
+ if (component.name === "VoiceParticleLogo") return ;
if (component.name === "Combobox") return ;
const flowChatPreview = getFlowChatPreviewDefinition(component.name);
diff --git a/design-system/packages/design-tokens/README.md b/design-system/packages/design-tokens/README.md
index d2d06bf37f..12b61448fb 100644
--- a/design-system/packages/design-tokens/README.md
+++ b/design-system/packages/design-tokens/README.md
@@ -33,10 +33,12 @@ tokens. The core interface roles are:
| --- | --- | --- |
| Page title | `type.heading.page` | Control, 24px, 700, 1.2, normal |
| Compact page title | `type.heading.compactPage` | Control, 20px, 600, 1.2, normal |
+| Panel title | `type.heading.panel` | Control, 18px, 600, 1.2, normal |
| Navigation title | `type.heading.navigation` | Control, 17px, 600, 1.2, normal |
| Section title | `type.heading.section` | Control, 15px, 600, 1.2, normal |
| Card title | `type.heading.card` | Control, 13px, 600, 1.2, normal |
| Body copy | `type.body.sm` | Sans, 13px, 400, 1.5, normal |
+| Large body copy | `type.body.lg` | Sans, 15px, 400, 1.6, normal |
| Supporting text | `type.support` | Control, 11px, 400, 1.55, normal |
| Control label | `type.label.md` | Control, 13px, 400, 1.2, normal |
| Selected control label | `type.label.selected` | Control, 13px, 600, 1.2, normal |
@@ -64,6 +66,8 @@ its established role. `type.overline.*` owns extra-small uppercase annotations;
these modifiers keep product styles semantic without changing their resolved
metrics during migration. `type.modifier.leading.support` provides the compact
1.45 supporting-text rhythm used when an 11px role must align to a 16px line.
+`type.modifier.leading.tight` provides 1.2 leading for compact message bubbles:
+18px at the default 15px body size, scaling with user typography preferences.
`layout.searchDialog` owns the shared Lab/product search composition: 800 × 460
when space permits, 20px inset and query-to-scope gap, and a 30px query row.
diff --git a/design-system/packages/design-tokens/src/system.tokens.json b/design-system/packages/design-tokens/src/system.tokens.json
index 32fb07dbbd..f0fbaf6d3b 100644
--- a/design-system/packages/design-tokens/src/system.tokens.json
+++ b/design-system/packages/design-tokens/src/system.tokens.json
@@ -258,6 +258,14 @@
"lineHeight": { "$value": "{lineHeight.tight}" },
"letterSpacing": { "$value": "{letterSpacing.normal}" }
},
+ "panel": {
+ "$description": "Compact panel titles, including VoiceCallPanel, use 18px between the 17px navigation and 20px compact-page roles.",
+ "fontFamily": { "$value": "{font.family.control}" },
+ "fontSize": { "$value": "{font.size.2xl}" },
+ "fontWeight": { "$value": "{font.weight.semibold}" },
+ "lineHeight": { "$value": "{lineHeight.tight}" },
+ "letterSpacing": { "$value": "{letterSpacing.normal}" }
+ },
"section": {
"fontFamily": { "$value": "{font.family.control}" },
"fontSize": { "$value": "{font.size.lg}" },
@@ -321,6 +329,10 @@
"$description": "Single-property semantic modifiers for intentional deviations from a complete typography role.",
"leading": {
"none": { "lineHeight": { "$value": "{lineHeight.none}" } },
+ "tight": {
+ "$description": "Compact text leading for message bubbles: 18px at the default 15px body size, scaling with user typography.",
+ "lineHeight": { "$value": "{lineHeight.tight}" }
+ },
"dense": { "lineHeight": { "$value": "{lineHeight.dense}" } },
"snug": { "lineHeight": { "$value": "{lineHeight.snug}" } },
"balanced": { "lineHeight": { "$value": "{lineHeight.balanced}" } },
diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md
index 9242c36222..97872aa8bc 100644
--- a/design-system/packages/ui/README.md
+++ b/design-system/packages/ui/README.md
@@ -18,6 +18,43 @@ export function Example() {
The package owns component anatomy, behavior, accessibility, and stable variants. It does not own theme selection persistence, product state, routes, locale resources, or platform APIs.
+## Voice calls
+
+`VoiceCallPanel` owns the complete compact call surface: navigation, particle
+logo, scrolling transcripts and mute/settings/end controls. Pass localized
+`title`, `labels`, transcript strings, optional `status`, and callbacks from
+the host. Returning to chat, closing the window and ending a call are separate
+callbacks so the package never decides session or window lifetime.
+
+Typography follows the shared `type.heading.panel` role for the title (18px,
+semibold) and `type.body.lg` for transcripts and status text (15px, regular,
+1.6 line height). Both use the canonical interface font stacks and follow user
+font-size preferences. Compact-height layouts keep the same typography roles.
+User bubbles apply `type.modifier.leading.tight` for 18px leading, with 12px
+padding on all sides and a 12px corner radius. They fit their content and wrap
+within the conversation width; a single line is 42px high at the default size.
+
+`VoiceParticleLogo` is also exported independently. Its `readAudio` callback
+reads `{ user, assistant, assistantSpeaking }` once per animation frame. The
+two spectra are FFT byte bins from analysers configured with `fftSize = 256`;
+pass `null` for muted or unavailable audio. `assistantSpeaking` describes the
+audible playback clock, including queued audio that outlives a provider's
+completion event. Omit the callback for the calm resting motion.
+
+The supplied Voice-Particles-Demo contour, sampling, force coefficients,
+envelopes, density and brightness profiles are preserved in a fixed simulation
+space; resizing changes only its projection. The host supplies real capture and
+playback, and keeps permission, network, transcription and audio cleanup outside
+the component. The canvas pauses offscreen, when inactive or hidden, and renders
+a still logo for reduced motion. Both components are registered in Design Lab;
+its presentation specimens do not capture or simulate speech.
+
+The panel uses the semantic on-light/on-dark endpoints: 80% dark background,
+30% light user bubble, full light text and 20% light circular controls. Render
+it on a transparent host shell to avoid applying the background opacity twice.
+The host allocates its dimensions; the product uses the existing 480 × 680
+compact-window contract. Build and source tests do not prove visual fidelity.
+
## Buttons
Choose variants by action role: use `primary` for the main save, submit, create,
diff --git a/design-system/packages/ui/src/components/VoiceCallPanel/VoiceCallPanel.meta.ts b/design-system/packages/ui/src/components/VoiceCallPanel/VoiceCallPanel.meta.ts
new file mode 100644
index 0000000000..8dd9f922b8
--- /dev/null
+++ b/design-system/packages/ui/src/components/VoiceCallPanel/VoiceCallPanel.meta.ts
@@ -0,0 +1,25 @@
+import type { ComponentMeta } from "../../registry.types";
+
+export const voiceCallPanelMeta = {
+ category: "feedback",
+ name: "VoiceCallPanel",
+ description: "A controlled live-call surface with a reactive particle logo, transcripts, and accessible call controls.",
+ maturity: "stable",
+ props: [
+ { name: "title", type: "string" },
+ { name: "labels", type: "VoiceCallLabels" },
+ { name: "phase", type: "connecting | live | ending | error", defaultValue: "live" },
+ { name: "muted", type: "boolean", defaultValue: "false" },
+ { name: "userTranscript", type: "string" },
+ { name: "assistantTranscript", type: "string" },
+ { name: "status", type: "ReactNode" },
+ { name: "readAudio", type: "VoiceParticleAudioReader" },
+ { name: "onBack / onClose / onToggleMute / onOpenSettings / onEnd", type: "() => void" },
+ ],
+ states: ["connecting", "live", "muted", "ending", "error"],
+ tokens: [
+ "color.content.onDark", "color.content.onLight",
+ "type.heading.panel", "type.body.lg", "type.modifier.leading.tight",
+ "space.3", "space.6", "space.8", "radius.lg",
+ ],
+} as const satisfies ComponentMeta;
diff --git a/design-system/packages/ui/src/components/VoiceCallPanel/VoiceCallPanel.module.css b/design-system/packages/ui/src/components/VoiceCallPanel/VoiceCallPanel.module.css
new file mode 100644
index 0000000000..4fa97ebfd4
--- /dev/null
+++ b/design-system/packages/ui/src/components/VoiceCallPanel/VoiceCallPanel.module.css
@@ -0,0 +1,153 @@
+@layer openbitfun.components {
+ .root {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ box-sizing: border-box;
+ container-type: size;
+ inline-size: 100%;
+ block-size: 100%;
+ min-inline-size: 0;
+ min-block-size: 0;
+ overflow: hidden;
+ border-radius: calc(var(--openbitfun-space-8) + var(--openbitfun-space-1));
+ background: color-mix(in srgb, var(--openbitfun-color-content-on-light) 80%, transparent);
+ color: var(--openbitfun-color-content-on-dark);
+ font-family: var(--openbitfun-type-body-lg-font-family);
+ font-size: var(--openbitfun-type-body-lg-font-size);
+ font-weight: var(--openbitfun-type-body-lg-font-weight);
+ line-height: var(--openbitfun-type-body-lg-line-height);
+ letter-spacing: var(--openbitfun-type-body-lg-letter-spacing);
+ }
+
+ .header {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: var(--openbitfun-space-3);
+ padding: var(--openbitfun-space-5) var(--openbitfun-space-6) 0;
+ }
+
+ .title {
+ flex: 1 1 auto;
+ min-inline-size: 0;
+ margin: 0;
+ color: inherit;
+ font-family: var(--openbitfun-type-heading-panel-font-family);
+ font-size: var(--openbitfun-type-heading-panel-font-size);
+ font-weight: var(--openbitfun-type-heading-panel-font-weight);
+ line-height: var(--openbitfun-type-heading-panel-line-height);
+ letter-spacing: var(--openbitfun-type-heading-panel-letter-spacing);
+ }
+
+ .root .back,
+ .root .close {
+ inline-size: 40px;
+ block-size: 40px;
+ flex: 0 0 40px;
+ border: 0;
+ color: inherit;
+ }
+
+ .root .back {
+ background: color-mix(in srgb, currentColor 10%, transparent);
+ }
+
+ .root .close {
+ background: transparent;
+ opacity: 0.5;
+ }
+
+ .visualizer {
+ flex: 0 1 44cqh;
+ min-block-size: 120px;
+ display: flex;
+ justify-content: center;
+ pointer-events: none;
+ }
+
+ .conversation {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-block-size: 0;
+ gap: calc(var(--openbitfun-space-8) + var(--openbitfun-space-1));
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: var(--openbitfun-space-2) var(--openbitfun-space-6) var(--openbitfun-space-2) var(--openbitfun-space-10);
+ color: inherit;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ }
+
+ .user {
+ align-self: flex-end;
+ inline-size: fit-content;
+ max-inline-size: 100%;
+ box-sizing: border-box;
+ padding: var(--openbitfun-space-3);
+ border-radius: var(--openbitfun-radius-lg);
+ background: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 30%, transparent);
+ line-height: var(--openbitfun-type-modifier-leading-tight-line-height);
+ }
+
+ .assistant { align-self: stretch; }
+
+ .status {
+ color: inherit;
+ }
+
+ .controls {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ gap: var(--openbitfun-space-3);
+ padding: var(--openbitfun-space-6) var(--openbitfun-space-6) clamp(var(--openbitfun-space-5), 7.4cqh, 54px);
+ }
+
+ .root .control {
+ inline-size: 74px;
+ block-size: 74px;
+ flex: 0 0 74px;
+ border: 0;
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 20%, transparent);
+ color: inherit;
+ }
+
+ .root .control [data-openbitfun-part="icon"] { inline-size: 28px; block-size: 28px; }
+ .root :is(.back, .close) [data-openbitfun-part="icon"] { inline-size: 20px; block-size: 20px; }
+ .root :is(.control, .back, .close)::before { content: none; }
+
+ .root :is(.control, .back, .close):hover:not(:disabled) {
+ background: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 30%, transparent);
+ color: inherit;
+ opacity: 1;
+ }
+
+ .root :is(.control, .back, .close):active:not(:disabled) {
+ background: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 40%, transparent);
+ }
+
+ .root :is(.control, .back, .close):focus-visible {
+ outline: var(--openbitfun-focus-width) solid currentColor;
+ outline-offset: var(--openbitfun-focus-offset);
+ }
+
+ @container (height < 520px) {
+ .controls { padding-block: var(--openbitfun-space-3); }
+ .root .control { inline-size: 56px; block-size: 56px; flex-basis: 56px; }
+ .conversation { gap: var(--openbitfun-space-5); }
+ }
+
+ @media (prefers-reduced-transparency: reduce) {
+ .root { background: var(--openbitfun-color-content-on-light); }
+ }
+
+ @media (forced-colors: active) {
+ .root { background: Canvas; color: CanvasText; }
+ .user { border: 1px solid CanvasText; }
+ .root :is(.control, .back, .close) { border: 1px solid ButtonText; color: ButtonText; opacity: 1; }
+ }
+}
diff --git a/design-system/packages/ui/src/components/VoiceCallPanel/VoiceCallPanel.tsx b/design-system/packages/ui/src/components/VoiceCallPanel/VoiceCallPanel.tsx
new file mode 100644
index 0000000000..05380c8f48
--- /dev/null
+++ b/design-system/packages/ui/src/components/VoiceCallPanel/VoiceCallPanel.tsx
@@ -0,0 +1,95 @@
+import { useEffect, useRef, type HTMLAttributes, type ReactNode } from "react";
+import { ArrowLeft, Mic, MicOff, Phone, SlidersHorizontal, X } from "lucide-react";
+import { classNames } from "../../internal/classNames";
+import { OverflowText } from "../../primitives/OverflowText";
+import { IconButton } from "../IconButton";
+import { Tooltip } from "../Tooltip";
+import { VoiceParticleLogo, type VoiceParticleAudioReader } from "../VoiceParticleLogo";
+import styles from "./VoiceCallPanel.module.css";
+
+export type VoiceCallPhase = "connecting" | "live" | "ending" | "error";
+export interface VoiceCallLabels {
+ back: string;
+ close: string;
+ mute: string;
+ unmute: string;
+ settings: string;
+ end: string;
+}
+export interface VoiceCallPanelProps extends Omit, "children"> {
+ title: string;
+ labels: VoiceCallLabels;
+ phase?: VoiceCallPhase;
+ muted?: boolean;
+ userTranscript?: string;
+ assistantTranscript?: string;
+ /** Localized connection, error or task status supplied by the application. */
+ status?: ReactNode;
+ readAudio?: VoiceParticleAudioReader;
+ onBack: () => void;
+ onClose: () => void;
+ onToggleMute: () => void;
+ onOpenSettings: () => void;
+ onEnd: () => void;
+}
+
+/** Controlled call presentation. Contains no routes, stores, locale catalogs or media APIs. */
+export function VoiceCallPanel({
+ title, labels, phase = "live", muted = false, userTranscript, assistantTranscript,
+ status, readAudio, onBack, onClose, onToggleMute, onOpenSettings, onEnd, className, ...props
+}: VoiceCallPanelProps) {
+ const conversationRef = useRef(null);
+ const followsLatest = useRef(true);
+ const connecting = phase === "connecting" || phase === "ending";
+ useEffect(() => {
+ const conversation = conversationRef.current;
+ if (conversation && followsLatest.current) conversation.scrollTop = conversation.scrollHeight;
+ }, [userTranscript, assistantTranscript, status]);
+
+ return (
+
+
+
+ } />
+
+ {title}
+
+ } />
+
+
+
+
+
+
+
+ {
+ const element = conversationRef.current;
+ if (element) followsLatest.current = element.scrollHeight - element.clientHeight - element.scrollTop < 24;
+ }}>
+ {userTranscript &&
{userTranscript}
}
+ {assistantTranscript &&
{assistantTranscript}
}
+ {status &&
{status}
}
+
+
+
+
+ );
+}
diff --git a/design-system/packages/ui/src/components/VoiceCallPanel/index.ts b/design-system/packages/ui/src/components/VoiceCallPanel/index.ts
new file mode 100644
index 0000000000..7125c311fb
--- /dev/null
+++ b/design-system/packages/ui/src/components/VoiceCallPanel/index.ts
@@ -0,0 +1 @@
+export { VoiceCallPanel, type VoiceCallPanelProps, type VoiceCallLabels, type VoiceCallPhase } from "./VoiceCallPanel";
diff --git a/design-system/packages/ui/src/components/VoiceParticleLogo/VoiceParticleLogo.meta.ts b/design-system/packages/ui/src/components/VoiceParticleLogo/VoiceParticleLogo.meta.ts
new file mode 100644
index 0000000000..07c46cf75b
--- /dev/null
+++ b/design-system/packages/ui/src/components/VoiceParticleLogo/VoiceParticleLogo.meta.ts
@@ -0,0 +1,14 @@
+import type { ComponentMeta } from "../../registry.types";
+
+export const voiceParticleLogoMeta = {
+ category: "feedback",
+ name: "VoiceParticleLogo",
+ description: "A particle logo with separate microphone and audible assistant speech responses, preserving the supplied motion model.",
+ maturity: "stable",
+ props: [
+ { name: "readAudio", type: "VoiceParticleAudioReader" },
+ { name: "active", type: "boolean", defaultValue: "true" },
+ ],
+ states: ["idle", "paused", "reduced-motion"],
+ tokens: ["color.content.onDark", "color.content.onLight"],
+} as const satisfies ComponentMeta;
diff --git a/design-system/packages/ui/src/components/VoiceParticleLogo/VoiceParticleLogo.module.css b/design-system/packages/ui/src/components/VoiceParticleLogo/VoiceParticleLogo.module.css
new file mode 100644
index 0000000000..832ec0ed7a
--- /dev/null
+++ b/design-system/packages/ui/src/components/VoiceParticleLogo/VoiceParticleLogo.module.css
@@ -0,0 +1,11 @@
+@layer openbitfun.components {
+ .root {
+ display: block;
+ inline-size: 100%;
+ block-size: 100%;
+ min-inline-size: 0;
+ min-block-size: 0;
+ color: var(--openbitfun-color-content-on-dark);
+ pointer-events: none;
+ }
+}
diff --git a/design-system/packages/ui/src/components/VoiceParticleLogo/VoiceParticleLogo.tsx b/design-system/packages/ui/src/components/VoiceParticleLogo/VoiceParticleLogo.tsx
new file mode 100644
index 0000000000..1871748a61
--- /dev/null
+++ b/design-system/packages/ui/src/components/VoiceParticleLogo/VoiceParticleLogo.tsx
@@ -0,0 +1,105 @@
+import { useEffect, useRef, type CanvasHTMLAttributes } from "react";
+import { classNames } from "../../internal/classNames";
+import { createVoiceParticleRenderer } from "./voiceParticleRenderer";
+import { SILENT_VOICE_AUDIO, type VoiceParticleAudioReader } from "./voiceParticleDynamics";
+import styles from "./VoiceParticleLogo.module.css";
+
+export interface VoiceParticleLogoProps extends CanvasHTMLAttributes {
+ /** Read the current microphone/output FFT without publishing audio frames through React. */
+ readAudio?: VoiceParticleAudioReader;
+ active?: boolean;
+}
+
+/** Decorative, audio-reactive logo. The host alone owns media and permission lifetimes. */
+export function VoiceParticleLogo({ readAudio, active = true, className, ...props }: VoiceParticleLogoProps) {
+ const canvasRef = useRef(null);
+ const readerRef = useRef(readAudio);
+ useEffect(() => { readerRef.current = readAudio; }, [readAudio]);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
+ const colors = getComputedStyle(canvas);
+ let foreground = colors.color;
+ let background = colors.getPropertyValue("--openbitfun-color-content-on-light").trim();
+ const renderer = createVoiceParticleRenderer(
+ canvas,
+ foreground,
+ background,
+ );
+ if (!renderer) return;
+
+ let frame = 0;
+ let lastFrame = 0;
+ let visible = true;
+ let width = 0;
+ let height = 0;
+ const frameDuration = 1000 / 60;
+ const canAnimate = () => active && visible && !document.hidden && !motion.matches && width > 0 && height > 0;
+ const animate = (time: number) => {
+ frame = 0;
+ if (!canAnimate()) return;
+ const elapsed = time - lastFrame;
+ if (elapsed >= frameDuration - 0.5) {
+ lastFrame = time - (elapsed % frameDuration);
+ renderer.draw(time, readerRef.current?.() ?? SILENT_VOICE_AUDIO);
+ }
+ frame = requestAnimationFrame(animate);
+ };
+ const sync = () => {
+ cancelAnimationFrame(frame);
+ frame = 0;
+ lastFrame = 0;
+ if (!width || !height) return;
+ if (canAnimate()) frame = requestAnimationFrame(animate);
+ else renderer.draw(0, SILENT_VOICE_AUDIO, false);
+ };
+ const resize = new ResizeObserver(([entry]) => {
+ if (!entry) return;
+ width = entry.contentRect.width;
+ height = entry.contentRect.height;
+ renderer.resize(width, height, window.devicePixelRatio || 1);
+ sync();
+ });
+ const intersection = new IntersectionObserver(([entry]) => {
+ if (!entry) return;
+ visible = entry.isIntersecting;
+ sync();
+ });
+ const theme = new MutationObserver(() => {
+ const current = getComputedStyle(canvas);
+ const nextForeground = current.color;
+ const nextBackground = current.getPropertyValue("--openbitfun-color-content-on-light").trim();
+ if (foreground === nextForeground && background === nextBackground) return;
+ foreground = nextForeground;
+ background = nextBackground;
+ renderer.setColors(foreground, background);
+ sync();
+ });
+ for (let ancestor: HTMLElement | null = canvas; ancestor; ancestor = ancestor.parentElement) {
+ theme.observe(ancestor, { attributes: true, attributeFilter: ["style", "class", "data-color-scheme", "data-contrast"] });
+ }
+ const updateProjection = () => {
+ renderer.resize(width, height, window.devicePixelRatio || 1);
+ sync();
+ };
+ resize.observe(canvas);
+ intersection.observe(canvas);
+ motion.addEventListener("change", sync);
+ document.addEventListener("visibilitychange", sync);
+ window.addEventListener("resize", updateProjection);
+ return () => {
+ cancelAnimationFrame(frame);
+ resize.disconnect();
+ intersection.disconnect();
+ theme.disconnect();
+ motion.removeEventListener("change", sync);
+ document.removeEventListener("visibilitychange", sync);
+ window.removeEventListener("resize", updateProjection);
+ };
+ }, [active]);
+
+ return ;
+}
diff --git a/design-system/packages/ui/src/components/VoiceParticleLogo/index.ts b/design-system/packages/ui/src/components/VoiceParticleLogo/index.ts
new file mode 100644
index 0000000000..c3ef396c40
--- /dev/null
+++ b/design-system/packages/ui/src/components/VoiceParticleLogo/index.ts
@@ -0,0 +1,2 @@
+export { VoiceParticleLogo, type VoiceParticleLogoProps } from "./VoiceParticleLogo";
+export type { VoiceParticleAudio, VoiceParticleAudioReader } from "./voiceParticleDynamics";
diff --git a/design-system/packages/ui/src/components/VoiceParticleLogo/voiceParticleDynamics.ts b/design-system/packages/ui/src/components/VoiceParticleLogo/voiceParticleDynamics.ts
new file mode 100644
index 0000000000..a022ba0a7c
--- /dev/null
+++ b/design-system/packages/ui/src/components/VoiceParticleLogo/voiceParticleDynamics.ts
@@ -0,0 +1,124 @@
+/** Motion coefficients and FFT envelopes ported from the supplied Voice-Particles-Demo. */
+export interface Point { x: number; y: number }
+export interface ParticleMotion extends Point { motionSpeed: number; motionPhase: number; seed: number }
+export interface VoiceParticleAudio {
+ /** FFT byte bins (fftSize 256), sampled from capture before muting or from audible playback. */
+ user: Uint8Array | null;
+ assistant: Uint8Array | null;
+ assistantSpeaking: boolean;
+}
+export type VoiceParticleAudioReader = () => VoiceParticleAudio;
+export const SILENT_VOICE_AUDIO: VoiceParticleAudio = { user: null, assistant: null, assistantSpeaking: false };
+export const effectProfiles = {
+ human: { strength: 1.4, motion: 1.2, density: 1.4, brightness: 1 },
+ ai: { strength: 2.1, motion: 1.6, density: 1.5, brightness: 1.2 },
+} as const;
+export const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
+export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
+export interface VoiceEnergy { base: number; ai: number; aiBreath: number; demo: number }
+
+function spectrum(data: Uint8Array) {
+ let sum = 0, peak = 0;
+ for (const byte of data) {
+ const value = byte / 255;
+ sum += value;
+ if (value > peak) peak = value;
+ }
+ return { average: data.length ? sum / data.length : 0, peak };
+}
+
+/** Renderer-local envelopes avoid React updates at audio/frame frequency. No simulated speech. */
+export class VoiceParticleAudioState {
+ mode: 'idle' | 'speech' | 'ai' = 'idle';
+ private micLevel = 0;
+ private aiActivity = 0;
+ private aiAudioLevel = 0;
+ private previousAiAudioLevel = 0;
+ private aiBreathPulse = 0;
+ private aiPulse = 0;
+
+ update(audio: VoiceParticleAudio, time: number): VoiceEnergy {
+ this.mode = audio.assistantSpeaking ? 'ai' : audio.user ? 'speech' : 'idle';
+ if (audio.user) {
+ const { average, peak } = spectrum(audio.user);
+ this.micLevel = lerp(this.micLevel, Math.max(average * 1.8, peak * 0.85), 0.18);
+ this.aiActivity = Math.max(this.aiActivity, peak * 0.45);
+ } else {
+ this.micLevel *= 0.92;
+ }
+ if (audio.assistant) {
+ const { average, peak } = spectrum(audio.assistant);
+ this.aiAudioLevel = lerp(this.aiAudioLevel, Math.max(average * 1.7, peak * 1.15), 0.24);
+ this.aiActivity = Math.max(this.aiActivity, average * 1.7, peak * 1.15);
+ } else {
+ this.aiAudioLevel *= 0.9;
+ }
+ if (this.mode === 'ai') {
+ const breathGap = this.previousAiAudioLevel > 0.12 && this.aiAudioLevel < 0.07 ? 0.76 : 0;
+ this.aiBreathPulse = Math.max(this.aiBreathPulse * 0.955, breathGap);
+ this.previousAiAudioLevel = this.aiAudioLevel;
+ this.aiActivity *= 0.965;
+ const breathing = 0.18 + Math.sin(time * 0.010) * 0.07;
+ this.aiPulse = lerp(this.aiPulse, Math.max(0.18, this.aiActivity + breathing), 0.16);
+ } else {
+ this.aiActivity *= 0.9;
+ this.aiPulse *= 0.88;
+ this.aiAudioLevel *= 0.88;
+ this.aiBreathPulse *= 0.88;
+ this.previousAiAudioLevel = this.aiAudioLevel;
+ }
+ return {
+ base: clamp(this.micLevel * effectProfiles.human.strength, 0, 1.4),
+ ai: clamp(this.aiPulse * effectProfiles.ai.strength, 0, 2.2),
+ aiBreath: clamp(this.aiBreathPulse * effectProfiles.ai.strength, 0, 1.5),
+ demo: 0,
+ };
+ }
+}
+
+export function particleForce(p: ParticleMotion, anchor: Point, time: number, energy: VoiceEnergy) {
+ const dx = anchor.x - p.x;
+ const dy = anchor.y - p.y;
+ const dist = Math.max(1, Math.hypot(dx, dy));
+ const nx = dx / dist;
+ const ny = dy / dist;
+ const tx = -ny;
+ const ty = nx;
+ const individualPhase = time * p.motionSpeed + p.motionPhase;
+ const orbit = Math.sin(individualPhase) * 0.7 + Math.cos(individualPhase * 0.63 + 1.4) * 0.35;
+ const localWave = Math.sin(individualPhase * 0.8 + dist * 0.08) * 0.18;
+ let ax = dx * 0.0008 + tx * (0.045 + orbit * 0.03) + nx * localWave * 0.025;
+ let ay = dy * 0.0008 + ty * (0.045 + orbit * 0.03) + ny * localWave * 0.025;
+
+ // A shared travelling field gives the cloud a direction without synchronising particles.
+ const wavePhase = time * (0.007 + energy.base * 0.001) + anchor.y * 0.014;
+ const humanMotion = energy.base * effectProfiles.human.motion;
+ const aiMotion = energy.ai * effectProfiles.ai.motion;
+ const breathFlow = energy.aiBreath * effectProfiles.ai.motion;
+ const directionWave = Math.sin(wavePhase) * (0.08 + humanMotion * 0.16 + aiMotion * 0.24 + energy.demo * 0.28);
+ const crossWave = Math.cos(wavePhase * 0.73 + anchor.x * 0.004) * (0.025 + humanMotion * 0.06 + aiMotion * 0.1);
+ ax += directionWave * 0.32;
+ ay += crossWave;
+
+ // AI breath is a short, local airflow disturbance instead of a global size pulse.
+ const airflowPhase = time * 0.015 + p.seed * 0.11 + anchor.x * 0.012;
+ const airflowCurl = Math.sin(airflowPhase) * breathFlow;
+ const airflowDrift = Math.cos(airflowPhase * 0.71 + anchor.y * 0.008) * breathFlow;
+ ax += tx * airflowCurl * 0.14 + nx * airflowDrift * 0.035;
+ ay += ty * airflowCurl * 0.14 + ny * airflowDrift * 0.035;
+
+ // Moving pressure lanes compress a small portion of particles into turbulent beams.
+ const beamPhase = anchor.x * 0.024 - anchor.y * 0.017 - time * 0.006;
+ const beamCompression = Math.cos(beamPhase)
+ * (energy.aiBreath * 0.85 + energy.ai * 0.18)
+ * effectProfiles.ai.motion;
+ ax += 0.82 * beamCompression * 0.12;
+ ay -= 0.57 * beamCompression * 0.12;
+
+ // Speech/AI amplify each particle's own response rather than moving the whole shape as one body.
+ const responsePhase = individualPhase * 1.7 + anchor.x * 0.011;
+ const response = Math.sin(responsePhase) * (humanMotion * 0.08 + aiMotion * 0.18 + energy.demo * 0.2);
+ ax += tx * response;
+ ay += ty * response;
+ return { ax, ay };
+}
diff --git a/design-system/packages/ui/src/components/VoiceParticleLogo/voiceParticleRenderer.ts b/design-system/packages/ui/src/components/VoiceParticleLogo/voiceParticleRenderer.ts
new file mode 100644
index 0000000000..2633f69d7b
--- /dev/null
+++ b/design-system/packages/ui/src/components/VoiceParticleLogo/voiceParticleRenderer.ts
@@ -0,0 +1,327 @@
+import { clamp, effectProfiles, lerp, particleForce, VoiceParticleAudioState, type ParticleMotion, type VoiceEnergy, type VoiceParticleAudio } from './voiceParticleDynamics';
+
+// The provided logo contour is kept verbatim, including its asymmetric inner cutout.
+const LOGO_PATH = "M309.959 16.0769C347.087 -5.35899 392.831 -5.35896 429.959 16.0769L679.919 160.39C717.047 181.826 739.919 221.442 739.919 264.314V552.942C739.919 595.814 717.047 635.429 679.919 656.865L429.959 801.179C392.831 822.615 347.087 822.615 309.959 801.179L60 656.865C22.8719 635.429 8.5819e-05 595.814 0 552.942V264.313C0.000173083 221.442 22.872 181.826 60 160.39L309.959 16.0769ZM249.035 119.18C220.454 119.18 194.044 134.428 179.753 159.18L58.8281 368.628C44.5375 393.38 44.5375 423.876 58.8281 448.628L179.753 658.075C194.043 682.827 220.453 698.075 249.034 698.075H490.884C519.465 698.075 545.875 682.827 560.166 658.075L681.091 448.628C695.381 423.876 695.381 393.38 681.091 368.628L560.166 159.18C545.875 134.428 519.465 119.18 490.884 119.18H249.035Z";
+const BASE_SVG_VIEWBOX = { width: 740, height: 818 };
+const MAX_SHAPE_WIDTH = 560;
+const MAX_SHAPE_HEIGHT = 620;
+const PARTICLE_POOL_DENSITY = 1.7;
+// Fixed simulation coordinates preserve the Demo's particle size, count and forces
+// when embedded in a compact panel. Only the final canvas projection is resized.
+const width = 720;
+const height = 800;
+interface Anchor { x: number; y: number; a: number; baseR: number; bias: number; centerDist: number }
+interface Particle extends ParticleMotion { vx: number; vy: number; anchorIndex: number; size: number; sizePhase: number; sizeSpeed: number; centerDist: number; bias: number }
+interface Bounds { minX: number; minY: number; maxX: number; maxY: number; cx: number; cy: number }
+
+/** Canvas-only renderer. It never captures audio, owns a call, or starts a timer. */
+export function createVoiceParticleRenderer(canvas: HTMLCanvasElement, foreground: string, background: string) {
+ const context = canvas.getContext('2d');
+ if (!context) return null;
+ const ctx = context;
+ let particles: Particle[] = [];
+ let anchors: Anchor[] = [];
+ let sourceBounds: Bounds | null = null;
+ let outerBoundary: Path2D | null = null;
+ let boundaryRows: Float64Array | null = null;
+ let boundaryScale = 1, boundaryX = 0, boundaryY = 0;
+ const audioState = new VoiceParticleAudioState();
+ const particleLayout = () => ({ x: 0, width, cx: width * 0.5 });
+ // Resolve the monochrome ramp from the host's semantic colors once. At the
+ // default endpoints this is exactly the Demo's 0..255 grayscale ramp.
+ const swatch = document.createElement('canvas');
+ swatch.width = 256;
+ swatch.height = 1;
+ const swatchContext = swatch.getContext('2d', { willReadFrequently: true });
+ if (!swatchContext) return null;
+ const palette: string[] = [];
+ function setColors(nextForeground: string, nextBackground: string) {
+ foreground = nextForeground;
+ background = nextBackground;
+ const paint = swatchContext!;
+ paint.globalAlpha = 1;
+ paint.fillStyle = background;
+ paint.fillRect(0, 0, 256, 1);
+ paint.fillStyle = foreground;
+ for (let shade = 0; shade < 256; shade++) {
+ paint.globalAlpha = shade / 255;
+ paint.fillRect(shade, 0, 1, 1);
+ }
+ const pixels = paint.getImageData(0, 0, 256, 1).data;
+ for (let shade = 0; shade < 256; shade++) {
+ const offset = shade * 4;
+ palette[shade] = `rgb(${pixels[offset]},${pixels[offset + 1]},${pixels[offset + 2]})`;
+ }
+ }
+ setColors(foreground, background);
+
+ function insideBoundary(x: number, y: number) {
+ const row = Math.floor((y - boundaryY) / boundaryScale);
+ if (!boundaryRows || row < 0 || row >= boundaryRows.length / 2) return false;
+ const localX = (x - boundaryX) / boundaryScale;
+ return localX >= boundaryRows[row * 2]! && localX < boundaryRows[row * 2 + 1]!;
+ }
+
+ function captureOuterBoundary(img: ImageData, offsetX = 0, offsetY = 0) {
+ const path = new Path2D();
+ boundaryRows = new Float64Array(img.height * 2);
+ boundaryRows.fill(Infinity);
+ boundaryScale = 1;
+ boundaryX = offsetX;
+ boundaryY = offsetY;
+ // Fill between the outermost occupied pixels, retaining the logo's inner flow area.
+ for (let y = 0; y < img.height; y++) {
+ let left = -1, right = -1;
+ for (let x = 0; x < img.width; x++) {
+ if (img.data[(y * img.width + x) * 4 + 3]! > 18) {
+ if (left < 0) left = x;
+ right = x;
+ }
+ }
+ if (left >= 0) {
+ path.rect(left + offsetX, y + offsetY, right - left + 1, 1);
+ boundaryRows[y * 2] = left;
+ boundaryRows[y * 2 + 1] = right + 1;
+ }
+ }
+ outerBoundary = path;
+ }
+
+ function buildFallbackShape() {
+ const path = new Path2D(LOGO_PATH);
+ const pts: Anchor[] = [];
+ const layout = particleLayout();
+ const cx = layout.cx;
+ const cy = height * 0.5;
+ const availableWidth = Math.min(layout.width * 0.88, MAX_SHAPE_WIDTH);
+ const availableHeight = Math.min(height * 0.78, MAX_SHAPE_HEIGHT);
+ const scale = Math.min(
+ availableWidth / BASE_SVG_VIEWBOX.width,
+ availableHeight / BASE_SVG_VIEWBOX.height,
+ );
+ const drawW = BASE_SVG_VIEWBOX.width * scale;
+ const drawH = BASE_SVG_VIEWBOX.height * scale;
+ const offX = cx - drawW * 0.5;
+ const offY = cy - drawH * 0.5;
+ const off = document.createElement('canvas');
+ off.width = Math.max(1, Math.floor(drawW));
+ off.height = Math.max(1, Math.floor(drawH));
+ const octx = off.getContext('2d', { willReadFrequently: true });
+ if (!octx) return pts;
+ octx.clearRect(0, 0, off.width, off.height);
+ octx.save();
+ octx.setTransform(scale, 0, 0, scale, -BASE_SVG_VIEWBOX.width * 0.5 * scale + off.width * 0.5, -BASE_SVG_VIEWBOX.height * 0.5 * scale + off.height * 0.5);
+ octx.fillStyle = foreground;
+ octx.fill(path);
+ octx.restore();
+ const img = octx.getImageData(0, 0, off.width, off.height);
+ captureOuterBoundary(img, offX, offY);
+ const target = Math.floor(2200 * PARTICLE_POOL_DENSITY);
+ const step = Math.max(1, Math.floor(Math.sqrt((off.width * off.height) / Math.max(target * 2, 1))));
+ const centerX = off.width * 0.5;
+ const centerY = off.height * 0.5;
+ const maxDist = Math.hypot(centerX, centerY) || 1;
+ for (let y = 0; y < off.height; y += step) {
+ for (let x = 0; x < off.width; x += step) {
+ const idx = (y * off.width + x) * 4;
+ if (img.data[idx + 3]! < 8) continue;
+ const dx = x - centerX;
+ const dy = y - centerY;
+ const centerDist = Math.hypot(dx, dy) / maxDist;
+ const shell = clamp(centerDist, 0, 1);
+ const keep = 0.06 + 0.94 * Math.pow(shell, 1.8);
+ if (Math.random() > keep) continue;
+ pts.push({
+ x: offX + x,
+ y: offY + y,
+ a: Math.atan2(dy, dx),
+ baseR: 1,
+ bias: keep,
+ centerDist: shell,
+ });
+ }
+ }
+ return pts;
+ }
+
+ function normalizePoints(points: Anchor[]) {
+ if (!points.length) return points;
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (const p of points) {
+ if (p.x < minX) minX = p.x;
+ if (p.y < minY) minY = p.y;
+ if (p.x > maxX) maxX = p.x;
+ if (p.y > maxY) maxY = p.y;
+ }
+ const bw = maxX - minX;
+ const bh = maxY - minY;
+ const layout = particleLayout();
+ const availableWidth = Math.min(layout.width * 0.88, MAX_SHAPE_WIDTH);
+ const availableHeight = Math.min(height * 0.78, MAX_SHAPE_HEIGHT);
+ const scale = Math.min(
+ availableWidth / Math.max(1, bw),
+ availableHeight / Math.max(1, bh),
+ );
+ const ox = layout.x + (layout.width - bw * scale) * 0.5;
+ const oy = (height - bh * scale) * 0.5;
+ if (outerBoundary) {
+ const transformed = new Path2D();
+ transformed.addPath(outerBoundary, new DOMMatrix([scale, 0, 0, scale, ox - minX * scale, oy - minY * scale]));
+ outerBoundary = transformed;
+ boundaryX = boundaryX * scale + ox - minX * scale;
+ boundaryY = boundaryY * scale + oy - minY * scale;
+ boundaryScale *= scale;
+ }
+ return points.map(p => ({
+ x: (p.x - minX) * scale + ox,
+ y: (p.y - minY) * scale + oy,
+ a: p.a,
+ baseR: p.baseR || 1,
+ bias: p.bias,
+ centerDist: p.centerDist,
+ }));
+ }
+
+ function buildSourceBounds() {
+ if (!anchors.length) {
+ sourceBounds = null;
+ return;
+ }
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (const p of anchors) {
+ if (p.x < minX) minX = p.x;
+ if (p.y < minY) minY = p.y;
+ if (p.x > maxX) maxX = p.x;
+ if (p.y > maxY) maxY = p.y;
+ }
+ sourceBounds = { minX, minY, maxX, maxY, cx: (minX + maxX) * 0.5, cy: (minY + maxY) * 0.5 };
+ }
+
+ function rebuildParticles(points: Anchor[]) {
+ anchors = normalizePoints(points);
+ if (!anchors.length) return;
+ const targetCount = Math.max(800, Math.floor(anchors.length * PARTICLE_POOL_DENSITY));
+ const next: Particle[] = [];
+ for (let i = 0; i < targetCount; i++) {
+ const anchorIndex = Math.floor(Math.random() * anchors.length);
+ const anchor = anchors[anchorIndex]!;
+ next.push({
+ x: anchor.x,
+ y: anchor.y,
+ vx: 0,
+ vy: 0,
+ anchorIndex,
+ seed: Math.random() * 1000,
+ motionPhase: Math.random() * Math.PI * 2,
+ motionSpeed: 0.0011 + Math.random() * 0.0018,
+ size: 0.45 + Math.pow(Math.random(), 2) * 2.35,
+ sizePhase: Math.random() * Math.PI * 2,
+ sizeSpeed: 0.0005 + Math.random() * 0.001,
+ centerDist: anchor.centerDist ?? 0.5,
+ bias: anchor.bias ?? 0.5,
+ });
+ }
+ particles = next;
+ buildSourceBounds();
+ }
+
+ function drawParticles(time: number, energy: VoiceEnergy, animate: boolean) {
+ if (!particles.length || !anchors.length) return;
+ const scope = Math.min(particleLayout().width, height, MAX_SHAPE_HEIGHT);
+ const visibleProfile = audioState.mode === 'ai' ? effectProfiles.ai : effectProfiles.human;
+ const centerPulse = Math.sin(time * 0.0011) * 0.6 + 0.9;
+ ctx.save();
+ ctx.globalCompositeOperation = 'lighter';
+
+ for (const p of particles) {
+ const anchor = anchors[p.anchorIndex % anchors.length]!;
+ if (animate) {
+ const f = particleForce(p, anchor, time, energy);
+ p.vx += f.ax;
+ p.vy += f.ay;
+ const spring = audioState.mode === 'ai' ? 0.034 : 0.05;
+ p.vx += (anchor.x - p.x) * spring * 0.0012;
+ p.vy += (anchor.y - p.y) * spring * 0.0012;
+ p.vx *= 0.92;
+ p.vy *= 0.92;
+ p.x += p.vx;
+ p.y += p.vy;
+ if (outerBoundary) {
+ if (!insideBoundary(p.x, p.y)) {
+ let inside = 0, outside = 1;
+ for (let step = 0; step < 12; step++) {
+ const t = (inside + outside) * 0.5;
+ if (insideBoundary(lerp(anchor.x, p.x, t), lerp(anchor.y, p.y, t))) inside = t;
+ else outside = t;
+ }
+ p.x = lerp(anchor.x, p.x, inside * 0.98);
+ p.y = lerp(anchor.y, p.y, inside * 0.98);
+ p.vx *= 0.35;
+ p.vy *= 0.35;
+ }
+ }
+
+ }
+ const dx = p.x - anchor.x;
+ const dy = p.y - anchor.y;
+ const dist = Math.hypot(dx, dy);
+ const distWeight = clamp(1 - dist / (scope * 0.22), 0, 1);
+ const breath = 0.55 + 0.45 * Math.sin(time * 0.0014 + p.seed * 0.7);
+ const aiTint = clamp(energy.ai * 0.6 + energy.base * 0.2, 0, 1);
+ const centerRange = 0.42;
+ const centerCx = sourceBounds ? sourceBounds.cx : width * 0.5;
+ const centerCy = sourceBounds ? sourceBounds.cy : height * 0.5;
+ const shapeRadius = sourceBounds
+ ? Math.min(sourceBounds.maxX - sourceBounds.minX, sourceBounds.maxY - sourceBounds.minY) * 0.5
+ : scope * 0.42;
+ const centerRadius = Math.max(1, shapeRadius * centerRange);
+ const centerDist = Math.hypot(p.x - centerCx, p.y - centerCy);
+ const radialPosition = clamp(centerDist / centerRadius, 0, 1);
+ // Concentrate the transition near the circle's edge; leave the exterior unchanged.
+ const transition = clamp((radialPosition - 0.35) / 0.65, 0, 1);
+ const keepProbability = 0.04 + 0.96 * transition * transition * (3 - 2 * transition);
+ const beamPhase = anchor.x * 0.024 - anchor.y * 0.017 - time * 0.006;
+ const beamFocus = Math.pow((Math.sin(beamPhase) + 1) * 0.5, 6);
+ const beamIntensity = audioState.mode === 'ai'
+ ? clamp(energy.aiBreath * 0.9 + energy.ai * 0.22, 0, 1)
+ : 0;
+ const beamVisibility = 1 - beamIntensity * 0.38 + beamFocus * beamIntensity * 0.92;
+ // A stable per-particle threshold changes density without dimming particles.
+ const densityRank = (p.seed * 0.61803398875) % 1;
+ if (densityRank > clamp(keepProbability * visibleProfile.density * beamVisibility, 0, 1)) continue;
+ const sizeVariation = 1 + 0.22 * Math.sin(time * p.sizeSpeed + p.sizePhase)
+ + 0.1 * Math.sin(time * p.sizeSpeed * 1.73 + p.sizePhase * 2);
+ const radius = p.size * sizeVariation * (0.92 + aiTint * 0.18 + distWeight * 0.22 + centerPulse * 0.08);
+ const beamGlow = beamFocus * beamIntensity;
+ const gray = Math.round(clamp(190 - dist * 0.08 + aiTint * 35 + breath * 16 + beamGlow * 28 + (visibleProfile.brightness - 1) * 35, 120, 255));
+ const alpha = clamp((0.18 + (1 - dist / (scope * 0.18)) * 0.45 + aiTint * 0.22 + beamGlow * 0.16) * visibleProfile.brightness, 0.08, 0.9);
+
+ ctx.fillStyle = palette[gray]!;
+ ctx.globalAlpha = alpha;
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ ctx.restore();
+ }
+
+ rebuildParticles(buildFallbackShape());
+ return {
+ setColors,
+ resize(cssWidth: number, cssHeight: number, pixelRatio: number) {
+ const dpr = clamp(pixelRatio, 1, 2);
+ canvas.width = Math.max(1, Math.floor(cssWidth * dpr));
+ canvas.height = Math.max(1, Math.floor(cssHeight * dpr));
+ const scale = Math.min(cssWidth / width, cssHeight / height) * dpr;
+ ctx.setTransform(scale, 0, 0, scale, (canvas.width - width * scale) * 0.5, (canvas.height - height * scale) * 0.5);
+ },
+ draw(time: number, audio: VoiceParticleAudio, animate = true) {
+ ctx.save();
+ ctx.resetTransform();
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.restore();
+ drawParticles(time, audioState.update(audio, time), animate);
+ },
+ };
+}
diff --git a/design-system/packages/ui/src/index.ts b/design-system/packages/ui/src/index.ts
index ddc46263bb..87565a63f1 100644
--- a/design-system/packages/ui/src/index.ts
+++ b/design-system/packages/ui/src/index.ts
@@ -1,6 +1,9 @@
import "./styles/layers.css";
import "./styles/scrollbars.css";
+export { VoiceCallPanel, type VoiceCallPanelProps, type VoiceCallLabels, type VoiceCallPhase } from "./components/VoiceCallPanel";
+export { VoiceParticleLogo, type VoiceParticleLogoProps, type VoiceParticleAudio, type VoiceParticleAudioReader } from "./components/VoiceParticleLogo";
+
export {
DesignSystemProvider,
type DesignSystemMessages,
diff --git a/design-system/packages/ui/src/registry.ts b/design-system/packages/ui/src/registry.ts
index fd569eb42f..771a956bec 100644
--- a/design-system/packages/ui/src/registry.ts
+++ b/design-system/packages/ui/src/registry.ts
@@ -39,6 +39,8 @@ import { textareaMeta } from "./components/Textarea/Textarea.meta";
import { tabGroupMeta } from "./components/TabGroup/TabGroup.meta";
import { toolbarMeta } from "./components/Toolbar/Toolbar.meta";
import { tooltipMeta } from "./components/Tooltip/Tooltip.meta";
+import { voiceCallPanelMeta } from "./components/VoiceCallPanel/VoiceCallPanel.meta";
+import { voiceParticleLogoMeta } from "./components/VoiceParticleLogo/VoiceParticleLogo.meta";
import { mobileIconButtonMeta } from "./mobile/MobileIconButton/MobileIconButton.meta";
import { mobileActionSheetMeta } from "./mobile/MobileActionSheet/MobileActionSheet.meta";
import { mobileBadgeMeta } from "./mobile/MobileBadge/MobileBadge.meta";
@@ -196,6 +198,8 @@ export const componentRegistry = [
todoToolCardMeta,
toolbarMeta,
tooltipMeta,
+ voiceCallPanelMeta,
+ voiceParticleLogoMeta,
viewImageToolCardMeta,
webFetchToolCardMeta,
webSearchToolCardMeta,
diff --git a/design-system/packages/ui/tests/registry.test.mjs b/design-system/packages/ui/tests/registry.test.mjs
index f789373f64..7030ad9403 100644
--- a/design-system/packages/ui/tests/registry.test.mjs
+++ b/design-system/packages/ui/tests/registry.test.mjs
@@ -103,6 +103,8 @@ test("registry exposes only the formal stable components", () => {
"TodoToolCard",
"Toolbar",
"Tooltip",
+ "VoiceCallPanel",
+ "VoiceParticleLogo",
"ViewImageToolCard",
"WebFetchToolCard",
"WebSearchToolCard",
diff --git a/design-system/packages/ui/tests/voice-call.test.mjs b/design-system/packages/ui/tests/voice-call.test.mjs
new file mode 100644
index 0000000000..e0b9327e92
--- /dev/null
+++ b/design-system/packages/ui/tests/voice-call.test.mjs
@@ -0,0 +1,77 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import ts from "typescript";
+import { VoiceCallPanel, VoiceParticleLogo } from "../dist/index.js";
+
+const motionSource = await readFile(new URL("../src/components/VoiceParticleLogo/voiceParticleDynamics.ts", import.meta.url), "utf8");
+const motionModule = ts.transpileModule(motionSource, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText;
+const { VoiceParticleAudioState, particleForce, SILENT_VOICE_AUDIO } = await import(`data:text/javascript;base64,${Buffer.from(motionModule).toString("base64")}`);
+
+const labels = { back: "Back to chat", close: "Close window", mute: "Mute", unmute: "Unmute", settings: "Settings", end: "End call" };
+const props = { title: "Live Call", labels, onBack() {}, onClose() {}, onToggleMute() {}, onOpenSettings() {}, onEnd() {} };
+
+test("call presentation exposes one title, full transcripts and five labeled controls without accessing media", () => {
+ const markup = renderToStaticMarkup(createElement(VoiceCallPanel, { ...props, muted: true, userTranscript: "A ", assistantTranscript: "Let's build it." }));
+ assert.equal((markup.match(/