Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions design-system/apps/design-lab/src/components/VoiceCallPreview.css
Original file line number Diff line number Diff line change
@@ -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);
}
30 changes: 30 additions & 0 deletions design-system/apps/design-lab/src/components/VoiceCallPreview.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className={compact ? "lab-voice-call-preview lab-voice-call-preview--compact" : "lab-voice-call-preview"}>
{closed ? <Button onClick={() => setClosed(false)}>{t("voice.preview.reopen")}</Button> : <VoiceCallPanel
title={t("voice.preview.title")}
labels={{
back: t("voice.preview.back"), close: t("components.preview.close"), mute: t("voice.preview.mute"),
unmute: t("voice.preview.unmute"), settings: t("voice.preview.settings"), end: t("voice.preview.end")
}}
phase={phase} muted={muted} userTranscript={t("voice.preview.user")} assistantTranscript={t("voice.preview.assistant")}
status={settingsRequested ? t("voice.preview.settingsRequested") : phase === "live" ? undefined : t(`voice.preview.${phase}`)}
onBack={() => setClosed(true)} onClose={() => setClosed(true)} onEnd={() => setClosed(true)}
onToggleMute={() => setMuted(value => !value)} onOpenSettings={() => setSettingsRequested(value => !value)}
/>}
</div>;
}

export function VoiceParticlePreview({ active = true }: { active?: boolean }) {
return <div className="lab-voice-particle-preview"><VoiceParticleLogo active={active} /></div>;
}
2 changes: 2 additions & 0 deletions design-system/apps/design-lab/src/i18n/componentMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const descriptionKeys: Readonly<Record<string, MessageKey>> = {
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",
Expand Down
45 changes: 45 additions & 0 deletions design-system/apps/design-lab/src/i18n/messages.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -747,6 +762,21 @@ export type MessageKey = keyof typeof enUSMessages;
export type MessageCatalog = Readonly<Record<MessageKey, string>>;

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": "打开导航",
Expand Down Expand Up @@ -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": "完整路徑",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Fragment, useMemo, useState } from "react";
import { VoiceCallPreview, VoiceParticlePreview } from "../components/VoiceCallPreview";
import { Clipboard, List } from "lucide-react";
import {
ActionCard,
Expand Down Expand Up @@ -497,6 +498,12 @@ export function ComponentDetailPage({
if (component.name === "LauncherButton") {
return 'import { Icon, LauncherButton } from "@openbitfun/ui";\n\n<LauncherButton leadingIcon={<Icon name="mic" />}>\n Hello\n</LauncherButton>';
}
if (component.name === "VoiceParticleLogo") {
return 'import { VoiceParticleLogo } from "@openbitfun/ui";\n\n// Return live FFT byte bins from fftSize=256 analysers.\n<VoiceParticleLogo readAudio={() => ({\n user: microphoneFrequencyData,\n assistant: playbackFrequencyData,\n assistantSpeaking: isAudioPlaying,\n})} />';
}
if (component.name === "VoiceCallPanel") {
return 'import { VoiceCallPanel } from "@openbitfun/ui";\n\n<VoiceCallPanel\n title="Live Call"\n labels={{ back: "Back to chat", close: "Close", mute: "Mute",\n unmute: "Unmute", settings: "Settings", end: "End call" }}\n phase="live"\n muted={muted}\n userTranscript={userTranscript}\n assistantTranscript={assistantTranscript}\n readAudio={readAudio}\n onBack={returnToChat}\n onClose={closeWindow}\n onToggleMute={toggleMute}\n onOpenSettings={openVoiceSettings}\n onEnd={endCall}\n/>';
}
if (component.name === "ActionItem") {
const metadataProp = actionItemShowMetadata ? `\n metadata="12"` : "";
return `import { Icon, ActionItem, KeyHint } from "@openbitfun/ui";\n\n<ActionItem\n actions={[\n { id: "add", icon: <Icon name="plus" />, label: "${t("components.preview.add")}" },\n { id: "more", icon: <Icon name="more" />, label: "${t("components.preview.more")}" },\n ]}\n leading={<Icon name="session" />}${metadataProp}\n shortcut={<KeyHint>K</KeyHint>}\n>\n ${t("components.preview.assistant")}\n</ActionItem>`;
Expand Down Expand Up @@ -892,6 +899,8 @@ export function ComponentDetailPage({
previewVariant = variant,
applyInspectorControls = false,
) {
if (component.name === "VoiceCallPanel") return <VoiceCallPreview key={state} state={state} />;
if (component.name === "VoiceParticleLogo") return <VoiceParticlePreview active={state !== "paused" && state !== "reduced-motion"} />;
if (isFlowChatComponent) {
return (
<FlowChatComponentPreview
Expand Down
3 changes: 3 additions & 0 deletions design-system/apps/design-lab/src/pages/ComponentsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AppWindow, Heading, Keyboard, List, Rows3, MousePointerClick, PanelTop, ToggleLeft } from "lucide-react";
import { VoiceCallPreview, VoiceParticlePreview } from "../components/VoiceCallPreview";
import {
ActionCard,
ActionItem,
Expand Down Expand Up @@ -168,6 +169,8 @@ const componentIcons = {

function ComponentCardPreview({ component }: { component: ComponentMeta }) {
const { t } = useI18n();
if (component.name === "VoiceCallPanel") return <VoiceCallPreview compact />;
if (component.name === "VoiceParticleLogo") return <VoiceParticlePreview />;
if (component.name === "Combobox") return <Combobox label={t("components.preview.modalProviderName")} defaultValue="openbitfun" options={[{ value: "openbitfun", label: "OpenBitFun" }, { value: "custom", label: t("components.preview.add") }]} />;
const flowChatPreview = getFlowChatPreviewDefinition(component.name);

Expand Down
4 changes: 4 additions & 0 deletions design-system/packages/design-tokens/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions design-system/packages/design-tokens/src/system.tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}" },
Expand Down Expand Up @@ -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}" } },
Expand Down
37 changes: 37 additions & 0 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading