Chapter 26 · Embed · Sandbox, voice, persona switch

Embed extras. The trio that ships next to the widget.

Three components for embed-side surfaces. ChannelSandbox previews the widget across multiple channels (web, SMS, voice). VoicePlayer carries audio playback for voice agents. WidgetPersonaSwitcher lets visitors choose between named personas mid-conversation. Each composes on top of the chapter-17 widget primitives — the trio is the second half of the embed surface.

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.

CW
Charlie’s WinesOnline · usually replies in 1 min
CW
Hi! Looking for the perfect wine, or browsing today?
Looking for a Hunter Valley red under $50.
<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.

Hi! Looking to renew my plan — is the family rate still available?Yes — the family rate’s $48 / mo for up to four lines. Want me to switch you over?Please do. Same card on file is fine.
<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.

0:00 / 1:04
<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" />
Props ChannelSandboxProps
PropTypePurpose
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) => voidFires whenever the active channel changes. Always emits, whether controlled or not.
channelsArray<"web" | "sms" | "voice">Restrict the tab set. Order drives tab order. Defaults to all three (["web", "sms", "voice"]).
themeWidgetThemeA WidgetTheme partial. Reserved for downstream theme composition (consumed by <WidgetThemeProvider>, chapter 17.1). Falls through to the wrapping theme provider when omitted.
previewReactNode | { web, sms, voice }Override the preview pane per channel. Single ReactNode renders for every channel; the object form keys by activeChannel.
classNamestringClass 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.

0:00 / 1:04
<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.

0:00 / 1:04
<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)}
/>
Props VoicePlayerProps
PropTypePurpose
srcstringAudio source URL (mp3 / wav / ogg). Optional — the player renders chrome without a source so design surfaces can wire the buffer in later.
durationMsnumberTotal duration in milliseconds, used for the time readout before metadata loads. Falls back to HTMLMediaElement.duration once decoded.
autoplaybooleanBegin playback on mount. Defaults to false; honours prefers-reduced-motion — if the user prefers reduced motion the flag is ignored.
playingbooleanControlled playback state. Pair with onPlayingChange to drive the player from outside.
onPlayingChange(playing) => voidFires whenever play / pause toggles, controlled or not.
onTimeUpdate(ms) => voidFires roughly every animation frame while playing. Throttled internally via requestAnimationFrame; safe to call setState in the handler.
transcriptstringPlain-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".
classNamestringClass 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.

Switch any time from the widget header

<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 />
Props WidgetPersonaSwitcherProps
PropTypePurpose
personasWidgetPersona[]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).
personastringControlled selected persona id. Pair with onPersonaChange.
defaultPersonastringUncontrolled initial persona id. Defaults to the first entry in personas.
onPersonaChange(persona) => voidFires 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".
helperTextReactNodeCopy beneath the card row (default: “Switch any time from the widget header”).
hideHelperTextbooleanSuppress the helper-text row entirely.
classNamestringClass merged via the kit’s cn() helper. Caller wins over defaults.