From 8d52449b5aa95997f2b2985413b6fca1a8423b82 Mon Sep 17 00:00:00 2001 From: GCWing Date: Thu, 10 Sep 2026 10:23:45 +0800 Subject: [PATCH 1/2] feat(voice): unify realtime call presentation and audio-reactive logo --- .../src/components/VoiceCallPreview.css | 16 + .../src/components/VoiceCallPreview.tsx | 30 ++ .../design-lab/src/i18n/componentMetadata.ts | 2 + .../apps/design-lab/src/i18n/messages.ts | 45 +++ .../src/pages/ComponentDetailPage.tsx | 9 + .../design-lab/src/pages/ComponentsPage.tsx | 3 + .../packages/design-tokens/README.md | 4 + .../design-tokens/src/system.tokens.json | 12 + design-system/packages/ui/README.md | 37 ++ .../VoiceCallPanel/VoiceCallPanel.meta.ts | 25 ++ .../VoiceCallPanel/VoiceCallPanel.module.css | 153 ++++++++ .../VoiceCallPanel/VoiceCallPanel.tsx | 95 +++++ .../ui/src/components/VoiceCallPanel/index.ts | 1 + .../VoiceParticleLogo.meta.ts | 14 + .../VoiceParticleLogo.module.css | 11 + .../VoiceParticleLogo/VoiceParticleLogo.tsx | 105 ++++++ .../src/components/VoiceParticleLogo/index.ts | 2 + .../voiceParticleDynamics.ts | 124 +++++++ .../voiceParticleRenderer.ts | 327 ++++++++++++++++++ design-system/packages/ui/src/index.ts | 3 + design-system/packages/ui/src/registry.ts | 4 + .../packages/ui/tests/registry.test.mjs | 2 + .../packages/ui/tests/voice-call.test.mjs | 77 +++++ .../ui/generated/design-system.css | 6 + .../src/app/layout/FloatingMiniChat.scss | 12 + .../src/app/layout/FloatingMiniChat.tsx | 10 +- .../components/toolbar-mode/ToolbarMode.scss | 9 + .../components/toolbar-mode/ToolbarMode.tsx | 20 +- .../toolbarModeSessionSurface.test.ts | 9 +- .../ConversationModeSurface.appearance.ts | 1 - .../voice/ConversationModeSurface.scss | 31 -- .../voice/ConversationModeSurface.test.tsx | 33 +- .../voice/ConversationModeSurface.tsx | 57 +-- .../voice/RealtimeVoiceCall.appearance.ts | 12 +- .../components/voice/RealtimeVoiceCall.scss | 233 ------------- .../voice/RealtimeVoiceCallPanel.tsx | 205 +++-------- .../voice/realtimeVoiceAudio.test.ts | 53 ++- .../components/voice/realtimeVoiceAudio.ts | 30 +- .../components/voice/useRealtimeVoiceCall.ts | 36 +- .../infrastructure/speech/voiceInputAudio.ts | 22 +- .../locales/en-US/settings/voice-input.json | 3 +- .../locales/zh-CN/settings/voice-input.json | 3 +- .../locales/zh-TW/settings/voice-input.json | 3 +- 43 files changed, 1360 insertions(+), 529 deletions(-) create mode 100644 design-system/apps/design-lab/src/components/VoiceCallPreview.css create mode 100644 design-system/apps/design-lab/src/components/VoiceCallPreview.tsx create mode 100644 design-system/packages/ui/src/components/VoiceCallPanel/VoiceCallPanel.meta.ts create mode 100644 design-system/packages/ui/src/components/VoiceCallPanel/VoiceCallPanel.module.css create mode 100644 design-system/packages/ui/src/components/VoiceCallPanel/VoiceCallPanel.tsx create mode 100644 design-system/packages/ui/src/components/VoiceCallPanel/index.ts create mode 100644 design-system/packages/ui/src/components/VoiceParticleLogo/VoiceParticleLogo.meta.ts create mode 100644 design-system/packages/ui/src/components/VoiceParticleLogo/VoiceParticleLogo.module.css create mode 100644 design-system/packages/ui/src/components/VoiceParticleLogo/VoiceParticleLogo.tsx create mode 100644 design-system/packages/ui/src/components/VoiceParticleLogo/index.ts create mode 100644 design-system/packages/ui/src/components/VoiceParticleLogo/voiceParticleDynamics.ts create mode 100644 design-system/packages/ui/src/components/VoiceParticleLogo/voiceParticleRenderer.ts create mode 100644 design-system/packages/ui/tests/voice-call.test.mjs delete mode 100644 src/web-ui/src/flow_chat/components/voice/RealtimeVoiceCall.scss 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