26.1 ChannelSandbox
A multi-channel preview shell for the widget. One tab strip across the top — Web, SMS, Voice — with the active channel rendered live below. Designers use it on the Chat Appearance editor to test how a single WidgetTheme reads across every channel before publishing. Operators use it on the public site so visitors can pick the channel they prefer.
Web channel active
.widget-channel-sandbox[data-channel="web"]Default zero-prop usage. Web tab selected; preview pane shows the <WidgetShell> chrome from chapter 17 — header row, two message rows (one inbound, one outbound), and a composer placeholder. The tab strip sits flush against the top edge so the boundary reads as a single composed object, not two stacked elements.
<div class="widget-channel-sandbox" data-channel="web">
<div class="widget-channel-sandbox-tabs" role="tablist">
<button type="button" role="tab" aria-selected="true"
class="widget-channel-sandbox-tab">Web</button>
<button type="button" role="tab" aria-selected="false"
class="widget-channel-sandbox-tab">SMS</button>
<button type="button" role="tab" aria-selected="false"
class="widget-channel-sandbox-tab">Voice</button>
</div>
<div class="widget-channel-sandbox-preview" role="tabpanel">
<!-- Web preview — <WidgetShell> chrome from chapter 17. -->
<div class="widget-shell">…</div>
</div>
</div>
.widget-channel-sandbox {
border: 1px solid var(--hair);
border-radius: var(--r-md);
overflow: hidden;
background: var(--bg-paper);
display: flex; flex-direction: column;
}
.widget-channel-sandbox-tabs {
display: flex; align-items: stretch;
border-bottom: 1px solid var(--hair);
background: var(--bg-sunk);
}
.widget-channel-sandbox-tab {
flex: 1; min-width: 0;
background: transparent; border: 0;
padding: var(--s-3) var(--s-4);
font: 600 13px/1.2 var(--f-body); color: var(--fg-soft);
cursor: pointer; position: relative;
min-height: 44px;
}
.widget-channel-sandbox-tab[aria-selected="true"] { color: var(--accent-text); background: var(--bg-paper); }
.widget-channel-sandbox-tab[aria-selected="true"]::after {
content: "";
position: absolute; left: 0; right: 0; bottom: -1px;
height: 2px; background: var(--accent);
}
.widget-channel-sandbox-tab:focus-visible { outline: 0; box-shadow: var(--sh-focus); }
.widget-channel-sandbox-preview { padding: var(--s-4); min-height: 240px; }
.widget-channel-sandbox-sms { display: flex; flex-direction: column; }
.widget-channel-sandbox-sms-bubble {
display: block; max-width: 75%; padding: 8px 12px;
border-radius: 14px; font: 400 13px/1.4 var(--f-body);
margin-bottom: 8px;
}
.widget-channel-sandbox-sms-bubble.is-inbound { background: var(--bg-sunk); color: var(--fg); }
.widget-channel-sandbox-sms-bubble.is-outbound { background: var(--accent); color: var(--on-accent); margin-left: auto; }
@media (max-width: 480px) {
.widget-channel-sandbox-tabs { flex-direction: column; }
.widget-channel-sandbox-tab { border-bottom: 1px solid var(--hair); }
.widget-channel-sandbox-tab[aria-selected="true"]::after {
left: 0; right: auto; top: 0; bottom: 0;
width: 2px; height: auto;
}
.widget-channel-sandbox-preview { padding: var(--s-3); min-height: 200px; }
}
import { ChannelSandbox } from "@magicblocksai/ui";
// Zero-prop default — Web channel selected, default previews wired up.
// The preview pane renders the same <WidgetShell> chrome chapter 17 uses.
<ChannelSandbox />
SMS channel active
.widget-channel-sandbox[data-channel="sms"]SMS tab selected; preview pane shows three SMS-style row bubbles. Inbound bubbles sit on the left with the sunk surface tone; outbound bubbles sit on the right with the accent fill and on-accent text colour. Bubble widths cap at 75% of the preview pane so long messages wrap naturally.
<div class="widget-channel-sandbox" data-channel="sms">
<div class="widget-channel-sandbox-tabs" role="tablist">
<!-- three tab buttons — SMS aria-selected="true" -->
</div>
<div class="widget-channel-sandbox-preview" role="tabpanel">
<div class="widget-channel-sandbox-sms">
<span class="widget-channel-sandbox-sms-bubble is-inbound">…</span>
<span class="widget-channel-sandbox-sms-bubble is-outbound">…</span>
<span class="widget-channel-sandbox-sms-bubble is-inbound">…</span>
</div>
</div>
</div>
.widget-channel-sandbox {
border: 1px solid var(--hair);
border-radius: var(--r-md);
overflow: hidden;
background: var(--bg-paper);
display: flex; flex-direction: column;
}
.widget-channel-sandbox-tabs {
display: flex; align-items: stretch;
border-bottom: 1px solid var(--hair);
background: var(--bg-sunk);
}
.widget-channel-sandbox-tab {
flex: 1; min-width: 0;
background: transparent; border: 0;
padding: var(--s-3) var(--s-4);
font: 600 13px/1.2 var(--f-body); color: var(--fg-soft);
cursor: pointer; position: relative;
min-height: 44px;
}
.widget-channel-sandbox-tab[aria-selected="true"] { color: var(--accent-text); background: var(--bg-paper); }
.widget-channel-sandbox-tab[aria-selected="true"]::after {
content: "";
position: absolute; left: 0; right: 0; bottom: -1px;
height: 2px; background: var(--accent);
}
.widget-channel-sandbox-tab:focus-visible { outline: 0; box-shadow: var(--sh-focus); }
.widget-channel-sandbox-preview { padding: var(--s-4); min-height: 240px; }
.widget-channel-sandbox-sms { display: flex; flex-direction: column; }
.widget-channel-sandbox-sms-bubble {
display: block; max-width: 75%; padding: 8px 12px;
border-radius: 14px; font: 400 13px/1.4 var(--f-body);
margin-bottom: 8px;
}
.widget-channel-sandbox-sms-bubble.is-inbound { background: var(--bg-sunk); color: var(--fg); }
.widget-channel-sandbox-sms-bubble.is-outbound { background: var(--accent); color: var(--on-accent); margin-left: auto; }
@media (max-width: 480px) {
.widget-channel-sandbox-tabs { flex-direction: column; }
.widget-channel-sandbox-tab { border-bottom: 1px solid var(--hair); }
.widget-channel-sandbox-tab[aria-selected="true"]::after {
left: 0; right: auto; top: 0; bottom: 0;
width: 2px; height: auto;
}
.widget-channel-sandbox-preview { padding: var(--s-3); min-height: 200px; }
}
import { ChannelSandbox } from "@magicblocksai/ui";
// Pre-select the SMS tab. Pair with `onChannelChange` to drive the active
// tab from outside (e.g. the Chat Appearance editor toolbar).
<ChannelSandbox channel="sms" />
Voice channel active
.widget-channel-sandbox[data-channel="voice"]Voice tab selected; preview pane composes a <VoicePlayer> in the expanded variant — scrubbable axis row above the play / scrubber / waveform / time / transcript row. The same player used inside <WidgetMessage> when an agent responds with synthesised speech.
<div class="widget-channel-sandbox" data-channel="voice">
<div class="widget-channel-sandbox-tabs" role="tablist">
<!-- three tab buttons — Voice aria-selected="true" -->
</div>
<div class="widget-channel-sandbox-preview" role="tabpanel">
<!-- Voice preview — <VoicePlayer variant="expanded"> from §23.2. -->
<div class="voice-player" data-variant="expanded">…</div>
</div>
</div>
.widget-channel-sandbox {
border: 1px solid var(--hair);
border-radius: var(--r-md);
overflow: hidden;
background: var(--bg-paper);
display: flex; flex-direction: column;
}
.widget-channel-sandbox-tabs {
display: flex; align-items: stretch;
border-bottom: 1px solid var(--hair);
background: var(--bg-sunk);
}
.widget-channel-sandbox-tab {
flex: 1; min-width: 0;
background: transparent; border: 0;
padding: var(--s-3) var(--s-4);
font: 600 13px/1.2 var(--f-body); color: var(--fg-soft);
cursor: pointer; position: relative;
min-height: 44px;
}
.widget-channel-sandbox-tab[aria-selected="true"] { color: var(--accent-text); background: var(--bg-paper); }
.widget-channel-sandbox-tab[aria-selected="true"]::after {
content: "";
position: absolute; left: 0; right: 0; bottom: -1px;
height: 2px; background: var(--accent);
}
.widget-channel-sandbox-tab:focus-visible { outline: 0; box-shadow: var(--sh-focus); }
.widget-channel-sandbox-preview { padding: var(--s-4); min-height: 240px; }
.widget-channel-sandbox-sms { display: flex; flex-direction: column; }
.widget-channel-sandbox-sms-bubble {
display: block; max-width: 75%; padding: 8px 12px;
border-radius: 14px; font: 400 13px/1.4 var(--f-body);
margin-bottom: 8px;
}
.widget-channel-sandbox-sms-bubble.is-inbound { background: var(--bg-sunk); color: var(--fg); }
.widget-channel-sandbox-sms-bubble.is-outbound { background: var(--accent); color: var(--on-accent); margin-left: auto; }
@media (max-width: 480px) {
.widget-channel-sandbox-tabs { flex-direction: column; }
.widget-channel-sandbox-tab { border-bottom: 1px solid var(--hair); }
.widget-channel-sandbox-tab[aria-selected="true"]::after {
left: 0; right: auto; top: 0; bottom: 0;
width: 2px; height: auto;
}
.widget-channel-sandbox-preview { padding: var(--s-3); min-height: 200px; }
}
import { ChannelSandbox } from "@magicblocksai/ui";
// Pre-select the Voice tab. The preview pane renders <VoicePlayer> in
// the expanded variant — the same player chapter 23.2 documents.
<ChannelSandbox channel="voice" />
| Prop | Type | Purpose |
|---|---|---|
| channel | "web" | "sms" | "voice" | Controlled active channel. When provided, the parent owns selection. Pair with onChannelChange. |
| defaultChannel | "web" | "sms" | "voice" | Uncontrolled initial channel. Defaults to the first entry in channels (i.e. "web" for the zero-prop default set). |
| onChannelChange | (channel) => void | Fires whenever the active channel changes. Always emits, whether controlled or not. |
| channels | Array<"web" | "sms" | "voice"> | Restrict the tab set. Order drives tab order. Defaults to all three (["web", "sms", "voice"]). |
| theme | WidgetTheme | A WidgetTheme partial. Reserved for downstream theme composition (consumed by <WidgetThemeProvider>, chapter 17.1). Falls through to the wrapping theme provider when omitted. |
| preview | ReactNode | { web, sms, voice } | Override the preview pane per channel. Single ReactNode renders for every channel; the object form keys by activeChannel. |
| className | string | Class merged via the kit’s cn() helper. Caller wins over defaults. |
26.2 VoicePlayer
Audio playback chrome for voice agents. A play / pause toggle, scrubber + waveform, time readout, and an optional transcript toggle. Used inside <WidgetMessage> when the agent responds with synthesised speech, and inside trace timelines when an operator wants to scrub through what was actually said. Two variants — compact sized for in-bubble use, expanded for trace-timeline pages where a separate scrubbable axis row sits above the waveform.
Compact
.voice-player[data-variant="compact"]In-bubble shape. Play / pause on the left, scrubber + waveform + time readout in the middle, transcript toggle on the right. The whole row collapses gracefully at narrow viewports — the play toggle and transcript button hit 44px touch-target floors at 480px+ widths.
Yes, I can help with that. Let me pull up your account.
<div class="voice-player" data-variant="compact">
<audio preload="metadata"></audio>
<div class="voice-player-row">
<button type="button" class="voice-player-toggle" aria-label="Play" aria-pressed="false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M8 5v14l11-7z"></path>
</svg>
</button>
<div class="voice-player-scrubber" style="--vp:0" aria-hidden="true">
<div class="voice-player-scrubber-fill" style="width:0%"></div>
</div>
<svg class="voice-player-wave" viewBox="0 0 240 32" preserveAspectRatio="none" aria-hidden="true">
<!-- 40 <rect> bars — alternating amplitudes suggesting speech -->
</svg>
<span class="voice-player-time">0:00 / 1:04</span>
<button type="button" class="voice-player-transcript-toggle" aria-pressed="false">Transcript</button>
</div>
<p class="voice-player-transcript" hidden>
Yes, I can help with that. Let me pull up your account.
</p>
</div>
.voice-player {
display: flex;
flex-direction: column;
gap: var(--s-2);
padding: var(--s-3);
border: 1px solid var(--hair);
border-radius: var(--r-md);
background: var(--bg-paper);
font: 400 13px/1.4 var(--f-body);
}
.voice-player[data-variant="expanded"] { padding: var(--s-4); gap: var(--s-3); }
.voice-player-row { display: flex; align-items: center; gap: var(--s-3); }
.voice-player-toggle {
width: 36px; height: 36px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 50%;
background: var(--accent);
color: var(--on-accent);
border: 0;
cursor: pointer;
flex-shrink: 0;
}
.voice-player-toggle:focus-visible { outline: 0; box-shadow: var(--sh-focus); }
.voice-player-time { font: 500 12px/1 var(--f-mono); color: var(--fg-dim); min-width: 56px; }
.voice-player-scrubber { flex: 1; min-width: 0; height: 4px; background: var(--bg-sunk); border-radius: 999px; position: relative; }
.voice-player-scrubber-fill { height: 100%; background: var(--accent); border-radius: 999px; }
.voice-player-wave { height: 32px; width: 100%; color: var(--accent); opacity: 0.6; }
.voice-player[data-variant="compact"] .voice-player-wave { height: 20px; }
.voice-player-axis { width: 100%; }
.voice-player-axis-input {
flex: 1;
width: 100%;
margin: 0;
height: 24px;
accent-color: var(--accent);
cursor: pointer;
}
.voice-player-axis-input:focus-visible { outline: 0; box-shadow: var(--sh-focus); }
.voice-player-transcript-toggle {
display: inline-flex; align-items: center; gap: 4px;
font: 500 12px/1 var(--f-body); color: var(--fg-soft);
background: transparent; border: 0; cursor: pointer;
padding: 8px; min-height: 32px;
}
.voice-player-transcript-toggle[aria-pressed="true"] { color: var(--accent-text); }
.voice-player-transcript {
margin: 0; padding: var(--s-2) var(--s-3);
background: var(--bg-sunk); border-radius: var(--r-sm);
font: 400 13px/1.5 var(--f-body); color: var(--fg);
}
@media (max-width: 480px) {
.voice-player-toggle { width: 44px; height: 44px; }
.voice-player-transcript-toggle { min-height: 44px; min-width: max-content; }
.voice-player-time { min-width: 48px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.voice-player-scrubber-fill { transition: none; }
}
import { VoicePlayer } from "@magicblocksai/ui";
// The demo above — compact variant, a 64-second clip with a transcript
// fallback. The component owns its own play / pause state.
<VoicePlayer
durationMs={64_000}
transcript="Yes, I can help with that. Let me pull up your account."
/>
// Pointed at a real recording — pass `src` so playback works. Pair with
// `playing` + `onPlayingChange` to drive it from outside.
<VoicePlayer
src="/audio/agent-response-0042.mp3"
durationMs={64_000}
transcript="Yes, I can help with that. Let me pull up your account."
/>
// Zero-prop — renders the chrome with no source, for design surfaces
// where the audio buffer wires in later.
<VoicePlayer />
// Inside a <WidgetMessage> bubble (chapter 17 wires this up automatically
// when the message's contentType is "audio").
<WidgetMessage from="ai">
<VoicePlayer src="/audio/agent-response-0042.mp3" />
</WidgetMessage>
Expanded
.voice-player[data-variant="expanded"]Trace-timeline shape. Adds a scrubbable axis row (native <input type="range">) above the play / scrubber / waveform row, so an operator can drop the playhead anywhere along the duration with a single click. The transcript toggle behaves identically to the compact variant.
Yes, I can help with that. Let me pull up your account.
<div class="voice-player" data-variant="expanded">
<audio preload="metadata"></audio>
<div class="voice-player-row voice-player-axis">
<input type="range" class="voice-player-axis-input"
min="0" max="64" step="0.1" value="0" aria-label="Seek">
</div>
<div class="voice-player-row">
<!-- toggle + scrubber + waveform + time + transcript toggle — same as compact -->
</div>
<p class="voice-player-transcript" hidden>
Yes, I can help with that. Let me pull up your account.
</p>
</div>
.voice-player {
display: flex;
flex-direction: column;
gap: var(--s-2);
padding: var(--s-3);
border: 1px solid var(--hair);
border-radius: var(--r-md);
background: var(--bg-paper);
font: 400 13px/1.4 var(--f-body);
}
.voice-player[data-variant="expanded"] { padding: var(--s-4); gap: var(--s-3); }
.voice-player-row { display: flex; align-items: center; gap: var(--s-3); }
.voice-player-toggle {
width: 36px; height: 36px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 50%;
background: var(--accent);
color: var(--on-accent);
border: 0;
cursor: pointer;
flex-shrink: 0;
}
.voice-player-toggle:focus-visible { outline: 0; box-shadow: var(--sh-focus); }
.voice-player-time { font: 500 12px/1 var(--f-mono); color: var(--fg-dim); min-width: 56px; }
.voice-player-scrubber { flex: 1; min-width: 0; height: 4px; background: var(--bg-sunk); border-radius: 999px; position: relative; }
.voice-player-scrubber-fill { height: 100%; background: var(--accent); border-radius: 999px; }
.voice-player-wave { height: 32px; width: 100%; color: var(--accent); opacity: 0.6; }
.voice-player[data-variant="compact"] .voice-player-wave { height: 20px; }
.voice-player-axis { width: 100%; }
.voice-player-axis-input {
flex: 1;
width: 100%;
margin: 0;
height: 24px;
accent-color: var(--accent);
cursor: pointer;
}
.voice-player-axis-input:focus-visible { outline: 0; box-shadow: var(--sh-focus); }
.voice-player-transcript-toggle {
display: inline-flex; align-items: center; gap: 4px;
font: 500 12px/1 var(--f-body); color: var(--fg-soft);
background: transparent; border: 0; cursor: pointer;
padding: 8px; min-height: 32px;
}
.voice-player-transcript-toggle[aria-pressed="true"] { color: var(--accent-text); }
.voice-player-transcript {
margin: 0; padding: var(--s-2) var(--s-3);
background: var(--bg-sunk); border-radius: var(--r-sm);
font: 400 13px/1.5 var(--f-body); color: var(--fg);
}
@media (max-width: 480px) {
.voice-player-toggle { width: 44px; height: 44px; }
.voice-player-transcript-toggle { min-height: 44px; min-width: max-content; }
.voice-player-time { min-width: 48px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.voice-player-scrubber-fill { transition: none; }
}
import { VoicePlayer } from "@magicblocksai/ui";
// The demo above — expanded variant adds the scrubbable axis above the
// waveform. A 64-second clip with a transcript fallback.
<VoicePlayer
variant="expanded"
durationMs={64_000}
transcript="Yes, I can help with that. Let me pull up your account."
/>
// Pointed at a real recording — pass `src` so playback works.
<VoicePlayer
variant="expanded"
src="/audio/agent-response-0042.mp3"
durationMs={64_000}
transcript="Yes, I can help with that. Let me pull up your account."
/>
// Controlled playback — drive play / pause from outside (e.g. a parent
// trace timeline that pauses every player when the user scrubs the
// timeline overview).
<VoicePlayer
variant="expanded"
src="/audio/agent-response-0042.mp3"
playing={isPlaying}
onPlayingChange={setIsPlaying}
onTimeUpdate={(ms) => setCursorMs(ms)}
/>
| Prop | Type | Purpose |
|---|---|---|
| src | string | Audio source URL (mp3 / wav / ogg). Optional — the player renders chrome without a source so design surfaces can wire the buffer in later. |
| durationMs | number | Total duration in milliseconds, used for the time readout before metadata loads. Falls back to HTMLMediaElement.duration once decoded. |
| autoplay | boolean | Begin playback on mount. Defaults to false; honours prefers-reduced-motion — if the user prefers reduced motion the flag is ignored. |
| playing | boolean | Controlled playback state. Pair with onPlayingChange to drive the player from outside. |
| onPlayingChange | (playing) => void | Fires whenever play / pause toggles, controlled or not. |
| onTimeUpdate | (ms) => void | Fires roughly every animation frame while playing. Throttled internally via requestAnimationFrame; safe to call setState in the handler. |
| transcript | string | Plain-text transcript of the audio. Surfaced to screen readers; users can toggle visibility via the transcript button. |
| variant | "compact" | "expanded" | Compact for in-bubble use; expanded for trace timelines (adds a scrubbable axis above the waveform). Defaults to "compact". |
| className | string | Class merged via the kit’s cn() helper. Caller wins over defaults. |
26.3 WidgetPersonaSwitcher
Lets visitors choose between named personas mid-conversation — for example “Speak with Sales”, “Speak with Support”, or “Speak with Billing”. Renders as a row of selectable persona cards; clicking one swaps the widget’s active persona, updates the header avatar + name, and routes subsequent messages to the corresponding agent profile. Operators configure the persona set in the Chat Appearance editor.
Cards
.widget-persona-switcher[data-variant="cards"]In-conversation shape. Three cards laid out horizontally; each carries a circular avatar (image or two-letter monogram), the persona name, and a one-line role label. The selected card carries an accent ring + check badge. Cards collapse to a vertical stack at narrow widths so the widget stays usable inside the launcher.
<div class="widget-persona-switcher" data-variant="cards">
<div class="widget-persona-switcher-row" role="group">
<button type="button" class="widget-persona-card" aria-pressed="true">
<span class="widget-persona-card-avatar" aria-hidden="true">Sa</span>
<p class="widget-persona-card-name">Sasha</p>
<p class="widget-persona-card-role">Sales</p>
<span class="widget-persona-card-check" aria-hidden="true">
<svg viewBox="0 0 12 12" width="10" height="10" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2.5 6.5 L5 9 L9.5 3.5"></path>
</svg>
</span>
</button>
<!-- two more <button class="widget-persona-card"> entries — Jamie / Robin -->
</div>
<p class="widget-persona-switcher-helper">Switch any time from the widget header</p>
</div>
.widget-persona-switcher { display: flex; flex-direction: column; gap: var(--s-3); }
.widget-persona-switcher-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--s-3); }
.widget-persona-card {
display: flex; flex-direction: column; align-items: center; gap: var(--s-2);
padding: var(--s-4) var(--s-3);
background: var(--bg-paper);
border: 1px solid var(--hair); border-radius: var(--r-md);
cursor: pointer; text-align: center;
font: 400 13px/1.4 var(--f-body); color: var(--fg);
position: relative;
}
.widget-persona-card:hover { border-color: var(--fg-dim); }
.widget-persona-card[aria-pressed="true"] {
border-color: var(--accent);
background: var(--accent-soft);
box-shadow: 0 0 0 1px var(--accent);
}
.widget-persona-card:focus-visible { outline: 0; box-shadow: var(--sh-focus); }
.widget-persona-card-avatar { width: 44px; height: 44px; border-radius: 50%; background: var(--accent-soft); color: var(--accent-text); display: inline-flex; align-items: center; justify-content: center; font: 600 13px/1 var(--f-body); }
.widget-persona-card-avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
.widget-persona-card-name { font: 600 13px/1.3 var(--f-body); color: var(--fg); margin: 0; }
.widget-persona-card-role { font: 500 11px/1 var(--f-mono); text-transform: uppercase; letter-spacing: 0.04em; color: var(--fg-dim); margin: 0; }
.widget-persona-card-check {
position: absolute; top: 8px; right: 8px;
width: 18px; height: 18px; border-radius: 50%;
background: var(--accent); color: var(--on-accent);
display: none; align-items: center; justify-content: center;
}
.widget-persona-card[aria-pressed="true"] .widget-persona-card-check { display: inline-flex; }
.widget-persona-switcher-helper {
font: 400 12px/1.4 var(--f-body); color: var(--fg-faint);
text-align: center; margin: 0;
}
.widget-persona-switcher[data-variant="header"] .widget-persona-pill {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 10px; min-height: 32px;
background: var(--bg-sunk); border: 1px solid var(--hair); border-radius: 999px;
font: 500 13px/1 var(--f-body); color: var(--fg);
cursor: pointer;
}
.widget-persona-switcher[data-variant="header"] .widget-persona-pill[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.7;
}
/* …additional rules trimmed for brevity — see _shared.css */
import { WidgetPersonaSwitcher } from "@magicblocksai/ui";
// Zero-prop — renders the three demo personas (Sasha / Jamie / Robin),
// Sasha selected. Helper text "Switch any time from the widget header"
// appears below the row.
<WidgetPersonaSwitcher />
// Real persona set — each entry carries an id, display name, role label,
// and avatar source. `onPersonaChange` fires every time selection moves.
<WidgetPersonaSwitcher
personas={[
{ id: "sales", name: "Sasha", role: "Sales", avatarSrc: "/team/sasha.png" },
{ id: "support", name: "Jamie", role: "Support", avatarSrc: "/team/jamie.png" },
{ id: "billing", name: "Robin", role: "Billing", avatarSrc: "/team/robin.png" },
]}
defaultPersona="sales"
onPersonaChange={(persona) => routeMessagesTo(persona.id)}
/>
Header pill
.widget-persona-switcher[data-variant="header"]Widget-header shape. A single pill labelled with the active persona’s name and role plus a downward caret — for swap-anytime placement inside the widget header. Helper text is suppressed in this variant; the pill is the affordance.
<div class="widget-persona-switcher" data-variant="header">
<button type="button" class="widget-persona-pill"
aria-haspopup="listbox" aria-expanded="false"
aria-disabled="true">
<span>Sasha · Sales</span>
<span aria-hidden="true">▾</span>
</button>
</div>
.widget-persona-switcher { display: flex; flex-direction: column; gap: var(--s-3); }
.widget-persona-switcher-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--s-3); }
.widget-persona-card {
display: flex; flex-direction: column; align-items: center; gap: var(--s-2);
padding: var(--s-4) var(--s-3);
background: var(--bg-paper);
border: 1px solid var(--hair); border-radius: var(--r-md);
cursor: pointer; text-align: center;
font: 400 13px/1.4 var(--f-body); color: var(--fg);
position: relative;
}
.widget-persona-card:hover { border-color: var(--fg-dim); }
.widget-persona-card[aria-pressed="true"] {
border-color: var(--accent);
background: var(--accent-soft);
box-shadow: 0 0 0 1px var(--accent);
}
.widget-persona-card:focus-visible { outline: 0; box-shadow: var(--sh-focus); }
.widget-persona-card-avatar { width: 44px; height: 44px; border-radius: 50%; background: var(--accent-soft); color: var(--accent-text); display: inline-flex; align-items: center; justify-content: center; font: 600 13px/1 var(--f-body); }
.widget-persona-card-avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
.widget-persona-card-name { font: 600 13px/1.3 var(--f-body); color: var(--fg); margin: 0; }
.widget-persona-card-role { font: 500 11px/1 var(--f-mono); text-transform: uppercase; letter-spacing: 0.04em; color: var(--fg-dim); margin: 0; }
.widget-persona-card-check {
position: absolute; top: 8px; right: 8px;
width: 18px; height: 18px; border-radius: 50%;
background: var(--accent); color: var(--on-accent);
display: none; align-items: center; justify-content: center;
}
.widget-persona-card[aria-pressed="true"] .widget-persona-card-check { display: inline-flex; }
.widget-persona-switcher-helper {
font: 400 12px/1.4 var(--f-body); color: var(--fg-faint);
text-align: center; margin: 0;
}
.widget-persona-switcher[data-variant="header"] .widget-persona-pill {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 10px; min-height: 32px;
background: var(--bg-sunk); border: 1px solid var(--hair); border-radius: 999px;
font: 500 13px/1 var(--f-body); color: var(--fg);
cursor: pointer;
}
.widget-persona-switcher[data-variant="header"] .widget-persona-pill[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.7;
}
/* …additional rules trimmed for brevity — see _shared.css */
import { WidgetPersonaSwitcher } from "@magicblocksai/ui";
// Composed inside the widget header — swap the active persona inline
// rather than via an in-conversation card. Hides the helper text row.
<WidgetPersonaSwitcher variant="header" hideHelperText />
| Prop | Type | Purpose |
|---|---|---|
| personas | WidgetPersona[] | The set of available personas. Each carries id, name, optional role, avatarSrc, and a theme overrides map. Order drives card order. Defaults to the three demo personas (Sasha / Jamie / Robin). |
| persona | string | Controlled selected persona id. Pair with onPersonaChange. |
| defaultPersona | string | Uncontrolled initial persona id. Defaults to the first entry in personas. |
| onPersonaChange | (persona) => void | Fires whenever the active persona changes. Always emits, whether controlled or not. |
| variant | "cards" | "header" | Card row for in-conversation choice; header pill for swap-anytime from the widget header. Defaults to "cards". |
| helperText | ReactNode | Copy beneath the card row (default: “Switch any time from the widget header”). |
| hideHelperText | boolean | Suppress the helper-text row entirely. |
| className | string | Class merged via the kit’s cn() helper. Caller wins over defaults. |