Chapter 26 · Embed · Sandbox, voice, persona switch

Embed extras. Everything else that ships beside the widget.

Embed-side surfaces beyond the chat panel. ChannelSandbox previews the widget across web, SMS and voice; VoicePlayer carries audio playback for voice agents; WidgetPersonaSwitcher lets visitors pick a persona mid-conversation. Then the Mortgage POS — a conversational point-of-sale that asks one question at a time, fills a live application card as you answer, branches on your goal, prices against a panel of lenders and takes the application to submission. It ships wired end to end as <MortgagePos>, and as nineteen components plus a pure, testable flow state machine for teams that compose their own.

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.

26.4 Mortgage POS

The conversational point-of-sale, wired end to end. The agent asks one question at a time; every answer fills the live Your application card on the right; the script branches on the goal (a purchase asks price and down payment, a refinance asks balance and current rate, a co-borrower unlocks three more); pricing returns three quotes; the visitor verifies their phone, ticks the three legal consents and submits. Change an earlier answer on the card and the flow reprices or re-asks — the state machine handles that. Shown here mid-flow, at the quotes step.

MortgagePos

.pos

Drop <MortgagePos> inside <PosThemeProvider> and it runs. Every piece below is also exported on its own, and usePosApplication() exposes the flow to teams that compose their own layout. Seed a moment with initialState — that is how this demo, and a resumed application, start mid-conversation.

Northgate Mortgage
Apply with Ava · 4 min
Loan · 82% complete
Secure application · nothing shared until you submit
Quick check that this number is really yours — I just texted you a 6-digit code.
Verified
Here is what 14 lenders came back with.
Pricing engine · 14 lenders · updated just now
30-yr fixedLowest payment
5.84%rate
5.91% APR · 0 points
Monthly$2,830
Cash to close$14,120
LenderCardinal
20-yr fixed
5.61%rate
5.72% APR · 0.5 points
Monthly$3,340
Cash to close$16,480
LenderPennymac
7/6 ARMCheapest now
5.12%rate
5.88% APR · 0 points
Monthly$2,610
Cash to close$13,950
LenderUWM
<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos is-embedded">
    <header class="pos-header">
      <span class="pos-header-mark">
        <svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
          <rect x="3" y="10" width="2" height="4" rx="1" fill="currentColor" opacity="0.55">
        </rect>
        <rect x="6" y="8" width="2" height="8" rx="1" fill="currentColor" opacity="0.7">
      </rect>
      <rect x="9" y="5" width="2" height="14" rx="1" fill="currentColor">
    </rect>
    <rect x="12" y="3" width="2" height="18" rx="1" fill="currentColor">
  </rect>
  <rect x="15" y="6" width="2" height="12" rx="1" fill="currentColor">
</rect>
<rect x="18" y="9" width="2" height="6" rx="1" fill="currentColor" opacity="0.7">
</rect>
<rect x="21" y="11" width="2" height="2" rx="1" fill="currentColor" opacity="0.45">
</rect>
</svg>
</span>
<div class="pos-header-text">
  <div class="pos-header-title">
    Northgate Mortgage
  </div>
  <div class="pos-header-meta">
    Apply with Ava · 4 min
  </div>
</div>
<div class="pos-header-actions">
  <button type="button" class="pos-save-btn">
    Save &amp; finish later
  </button>
  <span class="pos-progress-label">
    Loan · 82% complete
  </span>
  <span class="pos-progress" role="progressbar" aria-valuenow="82" aria-valuemin="0" aria-valuemax="100">
    <span class="pos-progress-fill" style="width:82%">
    </span>
  </span>
</div>
</header>
<div class="pos-grid">
  <div class="pos-stream">
    <div class="pos-thread">
      <div class="pos-secure">
        Secure application · nothing shared until you submit
      </div>
      <div class="pos-msg is-agent">
        <div class="pos-msg-bubble">
          Quick check that this number is really yours — I just texted you a 6-digit code.
        </div>
      </div>
      <div class="pos-msg is-user">
        <div class="pos-msg-bubble">
          Verified
        </div>
      </div>
      <div class="pos-msg is-agent">
        <div class="pos-msg-bubble">
          Here is what 14 lenders came back with.
        </div>
      </div>
      <div class="pos-quotes">
        <div class="pos-quotes-eyebrow">
          Pricing engine · 14 lenders · updated just now
        </div>
        <div class="pos-quote-grid">
          <div class="quote-card is-pos is-edge-accent">
            <div class="quote-card-head">
              <span class="quote-card-term">
                30-yr fixed
              </span>
              <span class="quote-card-badge">
                Lowest payment
              </span>
            </div>
            <div>
              <div class="quote-card-rate-row">
                <span class="quote-card-rate">
                  5.84%
                </span>
                <span class="quote-card-rate-unit">
                  rate
                </span>
              </div>
              <div class="quote-card-meta">
                5.91% APR · 0 points
              </div>
            </div>
            <div class="quote-card-rows">
              <div class="quote-card-row">
                <span class="quote-card-row-label">
                  Monthly
                </span>
                <span class="quote-card-row-value">
                  $2,830
                </span>
              </div>
              <div class="quote-card-row">
                <span class="quote-card-row-label">
                  Cash to close
                </span>
                <span class="quote-card-row-value">
                  $14,120
                </span>
              </div>
              <div class="quote-card-row">
                <span class="quote-card-row-label">
                  Lender
                </span>
                <span class="quote-card-row-value">
                  Cardinal
                </span>
              </div>
            </div>
            <button type="button" class="quote-card-cta">
              Choose this
            </button>
          </div>
          <div class="quote-card is-pos">
            <div class="quote-card-head">
              <span class="quote-card-term">
                20-yr fixed
              </span>
            </div>
            <div>
              <div class="quote-card-rate-row">
                <span class="quote-card-rate">
                  5.61%
                </span>
                <span class="quote-card-rate-unit">
                  rate
                </span>
              </div>
              <div class="quote-card-meta">
                5.72% APR · 0.5 points
              </div>
            </div>
            <div class="quote-card-rows">
              <div class="quote-card-row">
                <span class="quote-card-row-label">
                  Monthly
                </span>
                <span class="quote-card-row-value">
                  $3,340
                </span>
              </div>
              <div class="quote-card-row">
                <span class="quote-card-row-label">
                  Cash to close
                </span>
                <span class="quote-card-row-value">
                  $16,480
                </span>
              </div>
              <div class="quote-card-row">
                <span class="quote-card-row-label">
                  Lender
                </span>
                <span class="quote-card-row-value">
                  Pennymac
                </span>
              </div>
            </div>
            <button type="button" class="quote-card-cta">
              Choose this
            </button>
          </div>
          <div class="quote-card is-pos">
            <div class="quote-card-head">
              <span class="quote-card-term">
                7/6 ARM
              </span>
              <span class="quote-card-badge">
                Cheapest now
              </span>
            </div>
            <div>
              <div class="quote-card-rate-row">
                <span class="quote-card-rate">
                  5.12%
                </span>
                <span class="quote-card-rate-unit">
                  rate
                </span>
              </div>
              <div class="quote-card-meta">
                5.88% APR · 0 points
              </div>
            </div>
            <div class="quote-card-rows">
              <div class="quote-card-row">
                <span class="quote-card-row-label">
                  Monthly
                </span>
                <span class="quote-card-row-value">
                  $2,610
                </span>
              </div>
              <div class="quote-card-row">
                <span class="quote-card-row-label">
                  Cash to close
                </span>
                <span class="quote-card-row-value">
                  $13,950
                </span>
              </div>
              <div class="quote-card-row">
                <span class="quote-card-row-label">
                  Lender
                </span>
                <span class="quote-card-row-value">
                  UWM
                </span>
              </div>
            </div>
            <button type="button" class="quote-card-cta">
              Choose this
            </button>
          </div>
        </div>
      </div>
      <button type="button" class="pos-help-cta">
        <svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
          <circle cx="12" cy="12" r="9" fill="currentColor" opacity="0.14">
        </circle>
        <path d="M9.4 9.3 A2.7 2.7 0 1 1 12.6 12 C12.1 12.3 12 12.7 12 13.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" fill="none">
      </path>
      <circle cx="12" cy="16.6" r="1.15" fill="currentColor">
    </circle>
  </svg>
  Not sure? Ask about this question
</button>
<div style="height:8px">
</div>
</div>
</div>
<aside class="pos-side">
  <div class="pos-application">
    <div class="pos-side-head">
      <span class="pos-side-title">
        Your application
      </span>
      <span class="pos-eyebrow is-count">
        82% complete
      </span>
    </div>
    <p class="pos-side-hint">
      Fills in as you answer. Click any value to change it.
    </p>
    <div class="pos-group">
      <div class="pos-group-title">
        Loan
      </div>
      <div class="pos-row">
        <span class="pos-row-label">
          Goal
        </span>
        <button type="button" class="pos-row-edit" title="Edit">
          <span class="pos-row-value">
            Buying a home
          </span>
          <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
            <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
          </path>
          <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
        </path>
      </svg>
    </button>
  </div>
  <div class="pos-row">
    <span class="pos-row-label">
      Purchase price
    </span>
    <button type="button" class="pos-row-edit" title="Edit">
      <span class="pos-row-value">
        $600,000
      </span>
      <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
      </path>
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
    </path>
  </svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Down payment
  </span>
  <button type="button" class="pos-row-edit" title="Edit">
    <span class="pos-row-value">
      $120,000
    </span>
    <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
    </path>
    <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
  </path>
</svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Product
  </span>
  <span class="pos-row-empty">
    —
  </span>
</div>
</div>
<div class="pos-group">
  <div class="pos-group-title">
    Property
  </div>
  <div class="pos-row">
    <span class="pos-row-label">
      Address
    </span>
    <button type="button" class="pos-row-edit" title="Edit">
      <span class="pos-row-value">
        1420 Fillmore St, San Francisco, CA 94115
      </span>
      <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
      </path>
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
    </path>
  </svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Type
  </span>
  <button type="button" class="pos-row-edit" title="Edit">
    <span class="pos-row-value">
      Condo
    </span>
    <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
    </path>
    <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
  </path>
</svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Occupancy
  </span>
  <button type="button" class="pos-row-edit" title="Edit">
    <span class="pos-row-value">
      Primary home
    </span>
    <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
    </path>
    <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
  </path>
</svg>
</button>
</div>
</div>
<div class="pos-group">
  <div class="pos-group-title">
    Borrower
  </div>
  <div class="pos-row">
    <span class="pos-row-label">
      Credit
    </span>
    <button type="button" class="pos-row-edit" title="Edit">
      <span class="pos-row-value">
        760+
      </span>
      <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
      </path>
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
    </path>
  </svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Annual income
  </span>
  <button type="button" class="pos-row-edit" title="Edit">
    <span class="pos-row-value">
      $145,000
    </span>
    <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
    </path>
    <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
  </path>
</svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Employment
  </span>
  <button type="button" class="pos-row-edit" title="Edit">
    <span class="pos-row-value">
      W-2 employee
    </span>
    <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
    </path>
    <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
  </path>
</svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Monthly debts
  </span>
  <button type="button" class="pos-row-edit" title="Edit">
    <span class="pos-row-value">
      $850
    </span>
    <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
    </path>
    <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
  </path>
</svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Applying
  </span>
  <button type="button" class="pos-row-edit" title="Edit">
    <span class="pos-row-value">
      Just me
    </span>
    <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
    </path>
    <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
  </path>
</svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Declarations
  </span>
  <span class="pos-row-fixed">
    All clear
  </span>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Documents
  </span>
  <span class="pos-row-empty">
    —
  </span>
</div>
</div>
<div class="pos-group">
  <div class="pos-group-title">
    Contact
  </div>
  <div class="pos-row">
    <span class="pos-row-label">
      Contact
    </span>
    <button type="button" class="pos-row-edit" title="Edit">
      <span class="pos-row-value">
        Maria Alvarez
      </span>
      <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
      </path>
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
    </path>
  </svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Phone
  </span>
  <span class="pos-row-fixed">
    (415) 555-0134 · verified
  </span>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Monitoring
  </span>
  <span class="pos-row-empty">
    —
  </span>
</div>
</div>
<div class="pos-pending">
  <svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true">
    <rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" opacity="0.35">
  </rect>
  <path d="M7 10 V7 A5 5 0 0 1 17 7 V10" stroke="currentColor" stroke-width="2" fill="none">
</path>
<circle cx="12" cy="15.5" r="1.6" fill="currentColor">
</circle>
</svg>
Nothing is submitted until you press the button.
</div>
<div class="pos-nmls">
  <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
    <path d="M12 3 L21 10 H19 V20 H5 V10 H3 Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round">
  </path>
  <path d="M12 8.5 L16 11.5 V17 H8 V11.5 Z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round">
</path>
<path d="M10.5 17 V13.5 H13.5 V17" fill="none" stroke="currentColor" stroke-width="1.4">
</path>
</svg>
NMLS #1824561 · Equal Housing Lender
</div>
</div>
</aside>
</div>
</div>
</div>
.pos-header {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 14px clamp(16px, 3vw, 28px);
  background: var(--p-card);
  border-bottom: 1px solid var(--p-hair-soft);
}

.pos-header-mark {
  width: 34px;
  height: 34px;
  border-radius: 10px;
  background: var(--p-accent-soft);
  color: var(--p-accent);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.pos-header-text {
  flex: 1;
  min-width: 0;
}

.pos-header-title {
  font: 600 15px/1.2 var(--f-display);
  letter-spacing: -.01em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pos-header-meta {
  font: 400 10px/1.4 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: .08em;
  color: var(--p-dim);
  margin-top: 2px;
}

.pos-header-actions {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-shrink: 0;
}

.pos-grid {
  flex: 1;
  min-height: 0;
  display: grid;
  grid-template-columns: minmax(0, 1fr) var(--p-side-width, 380px);
}

.pos-stream {
  min-width: 0;
  overflow-y: auto;
  padding: clamp(18px, 3vw, 30px) clamp(16px, 3vw, 28px) 30px;
}

.pos-thread {
  max-width: 680px;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.pos-grid {
  grid-template-columns: 1fr;
}

.pos-stream {
  padding-bottom: calc(96px + var(--p-safe));
}
import { PosThemeProvider, MortgagePos } from '@magicblocksai/ui';

<PosThemeProvider
  config={{ primary: '#FF3F7A', secondary: '#191E32', corners: 'rounded', colorMode: 'auto' }}
  labels={{ businessName: 'Northgate Mortgage', agentName: 'Ava', estimatedMinutes: 4 }}>
  <MortgagePos onSubmit={(answers) => send(answers)} />
</PosThemeProvider>

// Resume a saved application, or render a specific moment:
<MortgagePos initialState={saved} />

26.5 POS theme provider

Two source colours and a handful of presets produce the whole surface. resolvePosTheme() derives every --p-* property — foregrounds by WCAG luminance, neutrals from one base colour on a tint ladder, three-slot corner presets — and the provider applies them as a scoped inline style, so two instances on a page cannot fight. It shares its core with the chat widget's engine: the same primary resolves to the same foreground on both.

PosThemeProvider

.pos-theme-scope

Shown with a blue brand, a navy secondary and corners: "soft". colorMode: "auto" follows the OS; dark and the ≤900px mobile breakpoint are detected on the client, so the server renders the light desktop state with no hydration mismatch.

Let's get you priced. What are we doing today?
Buying a home
<div class="pos-theme-scope" style="--p-accent:#0D9EDF;--p-accent-fg:#FFFFFF;--p-secondary:#0E1B2E;--p-secondary-fg:#FFFFFF;--p-page:color-mix(in oklab, #0E1B2E 5.9%, #FFFFFF);--p-card:color-mix(in oklab, #0E1B2E 1%, #FFFFFF);--p-field:color-mix(in oklab, #0E1B2E 8.5%, #FFFFFF);--p-field-hover:color-mix(in oklab, #0E1B2E 14.3%, #FFFFFF);--p-bubble:color-mix(in oklab, #0E1B2E 12.4%, #FFFFFF);--p-fg:#191E32;--p-dim:rgba(25,30,50,0.66);--p-faint:rgba(25,30,50,0.48);--p-hair:rgba(25,30,50,0.12);--p-hair-soft:rgba(25,30,50,0.07);--p-font:var(--f-body);--p-font-display:var(--f-display);--p-accent-text:color-mix(in oklab, #0D9EDF 72%, var(--ink));--p-r-card:12px;--p-r-bubble:12px;--p-r-field:10px;--p-tap:42px;--p-safe:env(safe-area-inset-bottom, 0px);--p-side-width:380px">
  <div class="pos-msg is-agent">
    <div class="pos-msg-bubble">
      Let&#x27;s get you priced. What are we doing today?
    </div>
  </div>
  <div class="pos-msg is-user">
    <div class="pos-msg-bubble">
      Buying a home
    </div>
  </div>
</div>
import { PosThemeProvider, PosMessage } from '@magicblocksai/ui';

<PosThemeProvider
  config={{ primary: '#0D9EDF', secondary: '#0E1B2E', corners: 'soft',
            baseFrom: 'secondary', surfaceTint: 65, font: 'brand', colorMode: 'light' }}
  labels={{ businessName: 'Northgate Mortgage', agentName: 'Ava' }}>
  <PosMessage from="agent">Let's get you priced. What are we doing today?</PosMessage>
  <PosMessage from="user">Buying a home</PosMessage>
</PosThemeProvider>

26.6 POS shell

The frame: a header with the brand mark, business name, "Apply with Ava · 4 min", save button and progress bar, over a two-column grid — the transcript stream on the left, the application card on the right. Under 900px the grid collapses to one column and the card moves into the bottom bar and sheet.

PosShell

.pos

Slots, not state: children is the transcript, side the desktop card, mobileBar and overlays the phone chrome. embedded renders inside a container instead of filling the viewport.

Northgate Mortgage
Apply with Ava · 4 min
Property · 32% complete
Secure application · nothing shared until you submit
What property are we talking about?
<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos is-embedded">
    <header class="pos-header">
      <span class="pos-header-mark">
        <svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
          <rect x="3" y="10" width="2" height="4" rx="1" fill="currentColor" opacity="0.55">
        </rect>
        <rect x="6" y="8" width="2" height="8" rx="1" fill="currentColor" opacity="0.7">
      </rect>
      <rect x="9" y="5" width="2" height="14" rx="1" fill="currentColor">
    </rect>
    <rect x="12" y="3" width="2" height="18" rx="1" fill="currentColor">
  </rect>
  <rect x="15" y="6" width="2" height="12" rx="1" fill="currentColor">
</rect>
<rect x="18" y="9" width="2" height="6" rx="1" fill="currentColor" opacity="0.7">
</rect>
<rect x="21" y="11" width="2" height="2" rx="1" fill="currentColor" opacity="0.45">
</rect>
</svg>
</span>
<div class="pos-header-text">
  <div class="pos-header-title">
    Northgate Mortgage
  </div>
  <div class="pos-header-meta">
    Apply with Ava · 4 min
  </div>
</div>
<div class="pos-header-actions">
  <button type="button" class="pos-save-btn">
    Save &amp; finish later
  </button>
  <span class="pos-progress-label">
    Property · 32% complete
  </span>
  <span class="pos-progress" role="progressbar" aria-valuenow="32" aria-valuemin="0" aria-valuemax="100">
    <span class="pos-progress-fill" style="width:32%">
    </span>
  </span>
</div>
</header>
<div class="pos-grid">
  <div class="pos-stream">
    <div class="pos-thread">
      <div class="pos-secure">
        Secure application · nothing shared until you submit
      </div>
      <div class="pos-msg is-agent">
        <div class="pos-msg-bubble">
          What property are we talking about?
        </div>
      </div>
      <div style="height:8px">
      </div>
    </div>
  </div>
  <aside class="pos-side">
    <div class="pos-application">
      <div class="pos-side-head">
        <span class="pos-side-title">
          Your application
        </span>
        <span class="pos-eyebrow is-count">
          32% complete
        </span>
      </div>
      <p class="pos-side-hint">
        Fills in as you answer. Click any value to change it.
      </p>
      <div class="pos-group">
        <div class="pos-group-title">
          Loan
        </div>
        <div class="pos-row">
          <span class="pos-row-label">
            Goal
          </span>
          <button type="button" class="pos-row-edit" title="Edit">
            <span class="pos-row-value">
              Buying a home
            </span>
            <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
              <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
            </path>
            <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
          </path>
        </svg>
      </button>
    </div>
    <div class="pos-row">
      <span class="pos-row-label">
        Purchase price
      </span>
      <button type="button" class="pos-row-edit" title="Edit">
        <span class="pos-row-value">
          $600,000
        </span>
        <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
          <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
        </path>
        <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
      </path>
    </svg>
  </button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Down payment
  </span>
  <span class="pos-row-empty">
    —
  </span>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Product
  </span>
  <span class="pos-row-empty">
    —
  </span>
</div>
</div>
<div class="pos-group">
  <div class="pos-group-title">
    Property
  </div>
  <div class="pos-row">
    <span class="pos-row-label">
      Address
    </span>
    <button type="button" class="pos-row-edit" title="Edit">
      <span class="pos-row-value">
        1420 Fillmore St, San Francisco, CA 94115
      </span>
      <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
      </path>
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
    </path>
  </svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Type
  </span>
  <button type="button" class="pos-row-edit" title="Edit">
    <span class="pos-row-value">
      Condo
    </span>
    <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
    </path>
    <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
  </path>
</svg>
</button>
</div>
</div>
<div class="pos-group">
  <div class="pos-group-title">
    Contact
  </div>
  <div class="pos-row">
    <span class="pos-row-label">
      Contact
    </span>
    <button type="button" class="pos-row-edit" title="Edit">
      <span class="pos-row-value">
        Maria Alvarez
      </span>
      <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
      </path>
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
    </path>
  </svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Phone
  </span>
  <span class="pos-row-fixed">
    (415) 555-0134 · verified
  </span>
</div>
</div>
<div class="pos-pending">
  <svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true">
    <rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" opacity="0.35">
  </rect>
  <path d="M7 10 V7 A5 5 0 0 1 17 7 V10" stroke="currentColor" stroke-width="2" fill="none">
</path>
<circle cx="12" cy="15.5" r="1.6" fill="currentColor">
</circle>
</svg>
Nothing is submitted until you press the button.
</div>
<div class="pos-nmls">
  <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
    <path d="M12 3 L21 10 H19 V20 H5 V10 H3 Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round">
  </path>
  <path d="M12 8.5 L16 11.5 V17 H8 V11.5 Z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round">
</path>
<path d="M10.5 17 V13.5 H13.5 V17" fill="none" stroke="currentColor" stroke-width="1.4">
</path>
</svg>
NMLS #1824561 · Equal Housing Lender
</div>
</div>
</aside>
</div>
</div>
</div>
.pos-header {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 14px clamp(16px, 3vw, 28px);
  background: var(--p-card);
  border-bottom: 1px solid var(--p-hair-soft);
}

.pos-header-mark {
  width: 34px;
  height: 34px;
  border-radius: 10px;
  background: var(--p-accent-soft);
  color: var(--p-accent);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.pos-header-text {
  flex: 1;
  min-width: 0;
}

.pos-header-title {
  font: 600 15px/1.2 var(--f-display);
  letter-spacing: -.01em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pos-header-meta {
  font: 400 10px/1.4 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: .08em;
  color: var(--p-dim);
  margin-top: 2px;
}

.pos-header-actions {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-shrink: 0;
}

.pos-save-btn {
  padding: 8px 13px;
  min-height: 36px;
  border-radius: var(--r-pill);
  border: 1px solid var(--p-hair);
  background: transparent;
  cursor: pointer;
  font: 500 12.5px/1.2 var(--p-font);
  color: var(--p-dim);
  white-space: nowrap;
  transition: all var(--dur-2) var(--ease);
}

.pos-save-btn:hover {
  border-color: var(--p-accent);
  color: var(--p-accent-text);
}

.pos-progress-label {
  font: 400 10.5px/1 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--p-dim);
  white-space: nowrap;
}

.pos-progress {
  width: 120px;
  height: 6px;
  border-radius: var(--r-pill);
  background: var(--p-field);
  overflow: hidden;
  display: block;
}

.pos-progress-fill {
  display: block;
  height: 100%;
  background: var(--p-accent);
  border-radius: var(--r-pill);
  transition: width var(--dur-4) var(--ease);
}

.pos-side {
  min-width: 0;
  border-left: 1px solid var(--p-hair-soft);
  background: var(--p-card);
  overflow-y: auto;
  padding: 22px 20px 28px;
}

.pos-side-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 4px;
}

.pos-side-title {
  font: 700 17px/1.2 var(--f-display);
  letter-spacing: -.015em;
}
import { PosThemeProvider, PosShell, PosMessage, PosApplicationCard } from '@magicblocksai/ui';

<PosShell progress={32} sectionLabel="Property" onSave={() => setSaveOpen(true)}
  side={<PosApplicationCard title="Your application" progressLabel="32% complete" groups={groups} />}
  mobileBar={<PosMobileBar count={4} title="Your application" summary="$600,000 · Condo" onOpen={() => setSheetOpen(true)} />}>
  <PosMessage from="agent">What property are we talking about?</PosMessage>
</PosShell>

26.7 POS messages

One turn of the transcript. Agent bubbles sit left on the bubble surface with a squared-off bottom-left corner; visitor bubbles sit right on the accent. The typing indicator is the same three-dot bubble the widget uses.

PosMessage

.pos-msg

from="agent" | "user". Every message enters with pos-in.

Let's get you priced. What are we doing today?
Refinancing
<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos-msg is-agent">
    <div class="pos-msg-bubble">
      Let&#x27;s get you priced. What are we doing today?
    </div>
  </div>
  <div class="pos-msg is-user">
    <div class="pos-msg-bubble">
      Refinancing
    </div>
  </div>
  <div class="pos-typing" role="status" aria-label="Typing">
    <span class="pos-typing-dot">
    </span>
    <span class="pos-typing-dot">
    </span>
    <span class="pos-typing-dot">
    </span>
  </div>
</div>
.pos-msg-bubble {
  max-width: 88%;
  padding: 12px 15px;
  font-size: 15.5px;
  line-height: 1.5;
  text-wrap: pretty;
}

.pos-msg.is-agent .pos-msg-bubble {
  align-self: flex-start;
  background: var(--p-bubble);
  color: var(--p-fg);
  border-radius: var(--p-r-bubble) var(--p-r-bubble) var(--p-r-bubble) 5px;
}

.pos-msg.is-user .pos-msg-bubble {
  align-self: flex-end;
  background: var(--p-accent);
  color: var(--p-accent-fg);
  border-radius: var(--p-r-bubble) var(--p-r-bubble) 5px var(--p-r-bubble);
}

.pos-typing {
  align-self: flex-start;
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 14px 17px;
  background: var(--p-bubble);
  border-radius: var(--p-r-bubble) var(--p-r-bubble) var(--p-r-bubble) 5px;
  animation: pos-in 260ms var(--ease) both;
}

.pos-typing-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--p-fg);
  animation: pos-dot 1.25s var(--ease) infinite;
}

.pos-typing-dot:nth-child(2) {
  animation-delay: 160ms;
}

.pos-typing-dot:nth-child(3) {
  animation-delay: 320ms;
}

.pos-msg, .pos-typing, .pos-typing-dot, .pos-chips, .pos-chip, .pos-card, .pos-doc-chip, .pos-quotes, .pos-submit,
  .pos-consent-box svg, .pos-consent-disc, .pos-help-cta, .pos-help, .pos-help-msg, .pos-submitted, .pos-step-dot,
  .pos-row-fixed, .pos-row-edit, .pos-backdrop, .pos-dialog, .pos-sheet {
  animation: none;
}
import { PosThemeProvider, PosMessage, PosTypingIndicator } from '@magicblocksai/ui';

<PosMessage from="agent">Let's get you priced. What are we doing today?</PosMessage>
<PosMessage from="user">Refinancing</PosMessage>
<PosTypingIndicator />

26.8 POS chips

Quick answers for a chips question. Pills on the card surface that hover into the accent and lift a pixel; each enters 70ms after the last.

PosChips

.pos-chips

options + onSelect(option, index).

<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos-chips" role="group">
    <button type="button" class="pos-chip" style="animation-delay:0ms">
      Buying a home
    </button>
    <button type="button" class="pos-chip" style="animation-delay:70ms">
      Refinancing
    </button>
    <button type="button" class="pos-chip" style="animation-delay:140ms">
      Taking cash out
    </button>
  </div>
</div>
.pos-chip {
  padding: 11px 17px;
  min-height: var(--p-tap);
  border-radius: var(--r-pill);
  border: 1px solid var(--p-hair);
  background: var(--p-card);
  color: var(--p-fg);
  font: 500 14.5px/1.2 var(--p-font);
  cursor: pointer;
  transition: all var(--dur-2) var(--ease);
  animation: pos-in 340ms var(--ease) both;
}

.pos-chip:hover {
  border-color: var(--p-accent);
  color: var(--p-accent-text);
  transform: translateY(-1px);
}

.pos-msg, .pos-typing, .pos-typing-dot, .pos-chips, .pos-chip, .pos-card, .pos-doc-chip, .pos-quotes, .pos-submit,
  .pos-consent-box svg, .pos-consent-disc, .pos-help-cta, .pos-help, .pos-help-msg, .pos-submitted, .pos-step-dot,
  .pos-row-fixed, .pos-row-edit, .pos-backdrop, .pos-dialog, .pos-sheet {
  animation: none;
}

.pos-progress-fill, .pos-chip, .pos-btn, .pos-send, .pos-save-btn, .pos-consent-box, .pos-decl-toggle, .pos-hmda-opt {
  transition: none;
}

.pos-chip:hover, .pos-btn:hover:not(:disabled), .pos-send:hover:not(:disabled), .pos-help-send:hover {
  transform: none;
}
import { PosThemeProvider, PosChips } from '@magicblocksai/ui';

<PosChips options={['Buying a home', 'Refinancing', 'Taking cash out']} onSelect={(o) => answer('goal', o)} />

26.9 POS input card

The ask card for free text, money and address questions. An eyebrow label, the input with an optional $ prefix and a round send button (dimmed while empty), then money quick picks — or, for an address, suggestions beneath a hairline once three characters are typed.

PosInputCard

.pos-card

Shown as a money question with quick picks. Enter submits. inputMode="decimal" brings up the numeric keyboard on phones.

Purchase price
$
<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos-card">
    <span class="pos-eyebrow">
      Purchase price
    </span>
    <div class="pos-input-row">
      <span class="pos-input-prefix">
        $
      </span>
      <input class="pos-input" placeholder="600,000" inputMode="decimal" aria-label="Purchase price" value=""/>
      <button type="button" class="pos-send" disabled="" aria-label="Send">
        <svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
          <path d="M4 12 H18 M13 6 L19 12 L13 18" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" fill="none">
        </path>
      </svg>
    </button>
  </div>
  <div class="pos-quick-picks">
    <button type="button" class="pos-quick-pick">
      $450,000
    </button>
    <button type="button" class="pos-quick-pick">
      $600,000
    </button>
    <button type="button" class="pos-quick-pick">
      $750,000
    </button>
    <button type="button" class="pos-quick-pick">
      $1,000,000
    </button>
  </div>
</div>
</div>
.pos-card.is-accent-top {
  border-top: 2px solid var(--p-accent);
  box-shadow: var(--sh-2);
}

.pos-card.is-dashed {
  background: color-mix(in oklab, var(--p-field) 55%, transparent);
  border-style: dashed;
  box-shadow: none;
}

.pos-card-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 12px;
}

.pos-card-title {
  font: 600 15.5px/1.3 var(--f-display);
}

.pos-card-title.is-tight {
  margin-bottom: 3px;
}

.pos-card-title-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 4px;
  color: var(--p-accent);
}

.pos-card-sub {
  font-size: 12.5px;
  line-height: 1.55;
  color: var(--p-dim);
  margin: 0 0 13px;
  text-wrap: pretty;
}

.pos-input-prefix {
  font: 600 19px/1 var(--f-display);
  color: var(--p-dim);
}

.pos-input, .pos-field-input, .pos-otp-input, .pos-dialog-input {
  width: 100%;
  flex: 1;
  min-width: 0;
  box-sizing: border-box;
  padding: 12px 14px;
  min-height: var(--p-tap);
  border-radius: var(--p-r-field);
  border: 1px solid var(--p-hair);
  background: var(--p-field);
  font: 400 16px/1.3 var(--p-font);
  color: var(--p-fg);
  outline: none;
  transition: all var(--dur-2) var(--ease);
}

.pos-input:focus, .pos-field-input:focus, .pos-otp-input:focus, .pos-dialog-input:focus {
  border-color: var(--p-accent);
  background: var(--p-card);
  box-shadow: none;
}

.pos-send {
  width: 46px;
  height: 46px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
  background: var(--p-accent);
  color: var(--p-accent-fg);
  transition: all var(--dur-2) var(--ease);
}

.pos-send:hover:not(:disabled) {
  transform: translateY(-1px);
}

.pos-send:disabled {
  opacity: .45;
  cursor: default;
}

.pos-suggestions {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--p-hair-soft);
}
import { PosThemeProvider, PosInputCard } from '@magicblocksai/ui';

<PosInputCard label="Purchase price" value={draft} onChange={setDraft} onSubmit={commit}
  placeholder="600,000" inputMode="decimal" prefix="$"
  quickPicks={['$450,000', '$600,000', '$750,000', '$1,000,000']} onQuickPick={(p) => answer('price', p)} />

// Address mode:
<PosInputCard label="Property address" value={draft} onChange={setDraft} onSubmit={commit}
  suggestions={draft.length > 2 ? suggestions : undefined} onSuggestion={(s) => answer('address', s)} />

26.10 POS contact card

"Where do we send the terms?" — the last thing before pricing. Name, email and mobile on the field surface with an accent top edge; See my numbers stays dimmed until all three are filled.

PosContactCard

.pos-card.is-accent-top

Controlled: value + onChange(patch).

Where do we send the terms?
<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos-card is-accent-top">
    <div class="pos-card-title" style="margin-bottom:12px">
      Where do we send the terms?
    </div>
    <div class="pos-fields">
      <label class="pos-field">
        <span class="pos-field-label">
          Full name
        </span>
        <input class="pos-field-input" type="text" placeholder="Maria Alvarez" value="Maria Alvarez"/>
      </label>
      <label class="pos-field">
        <span class="pos-field-label">
          Email
        </span>
        <input class="pos-field-input" type="email" placeholder="maria@email.com" value=""/>
      </label>
      <label class="pos-field">
        <span class="pos-field-label">
          Mobile
        </span>
        <input class="pos-field-input" type="tel" placeholder="(415) 555-0134" value=""/>
      </label>
      <button type="button" class="pos-btn" style="margin-top:2px" disabled="">
        See my numbers
      </button>
    </div>
  </div>
</div>
.pos-input, .pos-field-input, .pos-otp-input, .pos-dialog-input {
  width: 100%;
  flex: 1;
  min-width: 0;
  box-sizing: border-box;
  padding: 12px 14px;
  min-height: var(--p-tap);
  border-radius: var(--p-r-field);
  border: 1px solid var(--p-hair);
  background: var(--p-field);
  font: 400 16px/1.3 var(--p-font);
  color: var(--p-fg);
  outline: none;
  transition: all var(--dur-2) var(--ease);
}

.pos-input:focus, .pos-field-input:focus, .pos-otp-input:focus, .pos-dialog-input:focus {
  border-color: var(--p-accent);
  background: var(--p-card);
  box-shadow: none;
}

.pos-field {
  display: block;
}

.pos-field-label {
  display: block;
  font: 400 10px/1.4 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: .08em;
  color: var(--p-dim);
  margin-bottom: 5px;
}

.pos-btn:hover:not(:disabled) {
  transform: translateY(-1px);
}

.pos-btn:disabled {
  opacity: .45;
  cursor: default;
}

.pos-btn.is-sm {
  padding: 12px;
  font-size: 14px;
}

.pos-btn.is-secondary {
  background: var(--p-field);
  color: var(--p-fg);
  border: 1px solid var(--p-hair);
}

.pos-btn.is-secondary:hover:not(:disabled) {
  background: var(--p-accent);
  color: var(--p-accent-fg);
  border-color: transparent;
}

.pos-btn.is-ghost {
  background: transparent;
  color: var(--p-dim);
  border: 1px solid var(--p-hair);
  font-size: 13.5px;
}

.pos-btn.is-ghost:hover:not(:disabled) {
  color: var(--p-fg);
  transform: none;
}

.pos-btn.is-inline {
  width: auto;
  padding: 13px 22px;
}

.pos-btn-row {
  display: flex;
  gap: 8px;
  margin-top: 4px;
}

.pos-btn-row > .pos-btn {
  flex: 1;
  width: auto;
}
import { PosThemeProvider, PosContactCard } from '@magicblocksai/ui';

<PosContactCard value={contact} onChange={(patch) => setContact({ ...contact, ...patch })} onSubmit={commitContact} />

26.11 POS verification

"Enter the code." A six-digit, letter-spaced numeric input with autocomplete="one-time-code" so the phone offers the SMS, an expiry note, and a resend link. Verifies the moment six digits land.

PosOtpCard

.pos-otp-input

sentTo is the number the code went to.

Enter the code

Sent to (415) 555-0134 · expires in 10 minutes

<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos-card is-accent-top">
    <div class="pos-card-title-row">
      <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
        <rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" opacity="0.35">
      </rect>
      <path d="M7 10 V7 A5 5 0 0 1 17 7 V10" stroke="currentColor" stroke-width="2" fill="none">
    </path>
    <circle cx="12" cy="15.5" r="1.6" fill="currentColor">
  </circle>
</svg>
<span class="pos-card-title">
  Enter the code
</span>
</div>
<p class="pos-card-sub">
  Sent to (415) 555-0134 · expires in 10 minutes
</p>
<input class="pos-otp-input" inputMode="numeric" autoComplete="one-time-code" placeholder="••••••" maxLength="6" aria-label="Verification code" value=""/>
<button type="button" class="pos-link-btn">
  Didn&#x27;t get it? Send again
</button>
</div>
</div>
.pos-card-title-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 4px;
  color: var(--p-accent);
}

.pos-input, .pos-field-input, .pos-otp-input, .pos-dialog-input {
  width: 100%;
  flex: 1;
  min-width: 0;
  box-sizing: border-box;
  padding: 12px 14px;
  min-height: var(--p-tap);
  border-radius: var(--p-r-field);
  border: 1px solid var(--p-hair);
  background: var(--p-field);
  font: 400 16px/1.3 var(--p-font);
  color: var(--p-fg);
  outline: none;
  transition: all var(--dur-2) var(--ease);
}

.pos-input:focus, .pos-field-input:focus, .pos-otp-input:focus, .pos-dialog-input:focus {
  border-color: var(--p-accent);
  background: var(--p-card);
  box-shadow: none;
}

.pos-link-btn {
  margin-top: 10px;
  padding: 6px 2px;
  border: 0;
  background: transparent;
  cursor: pointer;
  font: 400 10.5px/1.4 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--p-dim);
  text-decoration: underline;
  text-underline-offset: 3px;
}

.pos-link-btn:hover {
  color: var(--p-accent-text);
}
import { PosThemeProvider, PosOtpCard } from '@magicblocksai/ui';

<PosOtpCard value={otp} onChange={enterOtp} sentTo={contact.phone} onResend={resend} />

26.12 POS documents

"Proof of income." Two dashed drop targets — take a photo (camera capture on phones) or upload a file. Attached files pop in as green chips; the button reads That's everything once there is one and Do this later otherwise. Skipping is a first-class answer.

PosDocsCard

.pos-doc-drop

onFiles(names) receives each pick; the input resets after so the same file can be added twice.

Proof of income

Paystub from the last 30 days, or last year's return if self-employed.

paystub-aug.jpg
<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos-card">
    <div class="pos-card-title is-tight">
      Proof of income
    </div>
    <p class="pos-card-sub">
      Paystub from the last 30 days, or last year&#x27;s return if self-employed.
    </p>
    <div class="pos-docs-grid">
      <label class="pos-doc-drop">
        <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
          <path d="M4 8 A2 2 0 0 1 6 6 H8.5 L10 4 H14 L15.5 6 H18 A2 2 0 0 1 20 8 V17 A2 2 0 0 1 18 19 H6 A2 2 0 0 1 4 17 Z" fill="currentColor" opacity="0.3">
        </path>
        <path d="M4 8 A2 2 0 0 1 6 6 H8.5 L10 4 H14 L15.5 6 H18 A2 2 0 0 1 20 8 V17 A2 2 0 0 1 18 19 H6 A2 2 0 0 1 4 17 Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round">
      </path>
      <circle cx="12" cy="12.5" r="3" fill="none" stroke="currentColor" stroke-width="1.8">
    </circle>
  </svg>
  <span>
    Take a photo
  </span>
  <input type="file" accept="image/*" capture="environment"/>
</label>
<label class="pos-doc-drop">
  <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
    <path d="M12 16 V5 M8 8.5 L12 4.5 L16 8.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none">
  </path>
  <path d="M5 15 V18 A1.5 1.5 0 0 0 6.5 19.5 H17.5 A1.5 1.5 0 0 0 19 18 V15" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none">
</path>
</svg>
<span>
  Upload a file
</span>
<input type="file" multiple="" accept="image/*,.pdf"/>
</label>
</div>
<div class="pos-doc-chips">
  <span class="pos-doc-chip">
    <svg width="12" height="12" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M5 13 L10 18 L19 6" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" fill="none">
    </path>
  </svg>
  paystub-aug.jpg
</span>
</div>
<button type="button" class="pos-btn is-secondary is-sm is-stack">
  That&#x27;s everything
</button>
</div>
</div>
.pos-doc-drop {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 7px;
  padding: 16px 10px;
  min-height: var(--p-tap);
  border-radius: var(--p-r-field);
  border: 1px dashed var(--p-hair);
  background: var(--p-field);
  cursor: pointer;
  font: 600 13px/1.2 var(--p-font);
  color: var(--p-fg);
  transition: all var(--dur-2) var(--ease);
}

.pos-doc-drop:hover {
  border-color: var(--p-accent);
  background: var(--p-card);
}

.pos-doc-drop svg {
  color: var(--p-accent);
}

.pos-doc-drop input {
  display: none;
}

.pos-doc-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 11px;
}

.pos-doc-chip {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 11px;
  border-radius: var(--r-pill);
  background: var(--success-soft);
  color: var(--success-text);
  font: 500 12px/1.2 var(--p-font);
  animation: pos-pop 260ms var(--ease) both;
}

.pos-msg, .pos-typing, .pos-typing-dot, .pos-chips, .pos-chip, .pos-card, .pos-doc-chip, .pos-quotes, .pos-submit,
  .pos-consent-box svg, .pos-consent-disc, .pos-help-cta, .pos-help, .pos-help-msg, .pos-submitted, .pos-step-dot,
  .pos-row-fixed, .pos-row-edit, .pos-backdrop, .pos-dialog, .pos-sheet {
  animation: none;
}
import { PosThemeProvider, PosDocsCard } from '@magicblocksai/ui';

<PosDocsCard files={docs} onFiles={(names) => setDocs([...docs, ...names])} onContinue={commitDocs} />

26.13 POS declarations

The eight yes/no declarations from the Uniform Residential Loan Application, each with a Yes / No pill pair. The header counts "3 of 8"; Continue stays dimmed until all eight are answered.

PosDeclarationsCard

.pos-decl-row

questions defaults to POS_DECLARATIONS; pass your own list to change the set.

Declarations3 of 8
Will you occupy the property as your primary residence?
Are you a party to any lawsuit?
Have you declared bankruptcy in the past 7 years?
Had a foreclosure or short sale in the past 7 years?
Are you obligated to pay alimony or child support?
Is any part of the down payment borrowed?
Are you a co-signer or guarantor on another loan?
Are you a U.S. citizen or permanent resident?
<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos-card">
    <div class="pos-card-head">
      <span class="pos-card-title">
        Declarations
      </span>
      <span class="pos-eyebrow is-count">
        3 of 8
      </span>
    </div>
    <div>
      <div class="pos-decl-row">
        <span class="pos-decl-q">
          Will you occupy the property as your primary residence?
        </span>
        <span class="pos-decl-toggles">
          <button type="button" class="pos-decl-toggle is-on" aria-pressed="true">
            Yes
          </button>
          <button type="button" class="pos-decl-toggle" aria-pressed="false">
            No
          </button>
        </span>
      </div>
      <div class="pos-decl-row">
        <span class="pos-decl-q">
          Are you a party to any lawsuit?
        </span>
        <span class="pos-decl-toggles">
          <button type="button" class="pos-decl-toggle" aria-pressed="false">
            Yes
          </button>
          <button type="button" class="pos-decl-toggle is-on" aria-pressed="true">
            No
          </button>
        </span>
      </div>
      <div class="pos-decl-row">
        <span class="pos-decl-q">
          Have you declared bankruptcy in the past 7 years?
        </span>
        <span class="pos-decl-toggles">
          <button type="button" class="pos-decl-toggle" aria-pressed="false">
            Yes
          </button>
          <button type="button" class="pos-decl-toggle is-on" aria-pressed="true">
            No
          </button>
        </span>
      </div>
      <div class="pos-decl-row">
        <span class="pos-decl-q">
          Had a foreclosure or short sale in the past 7 years?
        </span>
        <span class="pos-decl-toggles">
          <button type="button" class="pos-decl-toggle" aria-pressed="false">
            Yes
          </button>
          <button type="button" class="pos-decl-toggle" aria-pressed="false">
            No
          </button>
        </span>
      </div>
      <div class="pos-decl-row">
        <span class="pos-decl-q">
          Are you obligated to pay alimony or child support?
        </span>
        <span class="pos-decl-toggles">
          <button type="button" class="pos-decl-toggle" aria-pressed="false">
            Yes
          </button>
          <button type="button" class="pos-decl-toggle" aria-pressed="false">
            No
          </button>
        </span>
      </div>
      <div class="pos-decl-row">
        <span class="pos-decl-q">
          Is any part of the down payment borrowed?
        </span>
        <span class="pos-decl-toggles">
          <button type="button" class="pos-decl-toggle" aria-pressed="false">
            Yes
          </button>
          <button type="button" class="pos-decl-toggle" aria-pressed="false">
            No
          </button>
        </span>
      </div>
      <div class="pos-decl-row">
        <span class="pos-decl-q">
          Are you a co-signer or guarantor on another loan?
        </span>
        <span class="pos-decl-toggles">
          <button type="button" class="pos-decl-toggle" aria-pressed="false">
            Yes
          </button>
          <button type="button" class="pos-decl-toggle" aria-pressed="false">
            No
          </button>
        </span>
      </div>
      <div class="pos-decl-row">
        <span class="pos-decl-q">
          Are you a U.S. citizen or permanent resident?
        </span>
        <span class="pos-decl-toggles">
          <button type="button" class="pos-decl-toggle" aria-pressed="false">
            Yes
          </button>
          <button type="button" class="pos-decl-toggle" aria-pressed="false">
            No
          </button>
        </span>
      </div>
    </div>
    <button type="button" class="pos-btn is-sm is-stack" disabled="">
      Continue
    </button>
  </div>
</div>
.pos-card-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 12px;
}

.pos-decl-toggles {
  display: flex;
  gap: 4px;
  flex-shrink: 0;
}

.pos-decl-toggle {
  padding: 7px 14px;
  min-height: 36px;
  border-radius: var(--r-pill);
  border: 0;
  cursor: pointer;
  font: 600 12.5px/1.2 var(--p-font);
  background: var(--p-field);
  color: var(--p-dim);
  transition: all var(--dur-1) var(--ease);
}

.pos-decl-toggle.is-on {
  background: var(--p-accent);
  color: var(--p-accent-fg);
}

.pos-progress-fill, .pos-chip, .pos-btn, .pos-send, .pos-save-btn, .pos-consent-box, .pos-decl-toggle, .pos-hmda-opt {
  transition: none;
}
import { PosThemeProvider, PosDeclarationsCard } from '@magicblocksai/ui';

<PosDeclarationsCard value={decl} onChange={(i, v) => setDecl({ ...decl, [i]: v })} onContinue={commitDeclarations} />

26.14 POS government monitoring

The optional HMDA section. A dashed, half-transparent card that reads as optional: three groups of pills, Skip this section always available, Continue only once something is answered. Selected pills use the secondary colour rather than the accent — this is not a sales moment.

PosHmdaCard

.pos-card.is-dashed

groups defaults to POS_HMDA.

Optional · government monitoring (HMDA)

Collected industry-wide to check for fair lending. Answering or skipping can never affect your rate or approval.

Ethnicity
Race
Sex
<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos-card is-dashed">
    <div class="pos-eyebrow is-faint">
      Optional · government monitoring (HMDA)
    </div>
    <p class="pos-card-sub">
      Collected industry-wide to check for fair lending. Answering or skipping can never affect your rate or approval.
    </p>
    <div class="pos-hmda-group">
      <span class="pos-eyebrow">
        Ethnicity
      </span>
      <div class="pos-hmda-opts">
        <button type="button" class="pos-hmda-opt" aria-pressed="false">
          Hispanic or Latino
        </button>
        <button type="button" class="pos-hmda-opt" aria-pressed="false">
          Not Hispanic or Latino
        </button>
        <button type="button" class="pos-hmda-opt" aria-pressed="false">
          Prefer not to say
        </button>
      </div>
    </div>
    <div class="pos-hmda-group">
      <span class="pos-eyebrow">
        Race
      </span>
      <div class="pos-hmda-opts">
        <button type="button" class="pos-hmda-opt" aria-pressed="false">
          American Indian
        </button>
        <button type="button" class="pos-hmda-opt" aria-pressed="false">
          Asian
        </button>
        <button type="button" class="pos-hmda-opt" aria-pressed="false">
          Black
        </button>
        <button type="button" class="pos-hmda-opt" aria-pressed="false">
          Pacific Islander
        </button>
        <button type="button" class="pos-hmda-opt" aria-pressed="false">
          White
        </button>
        <button type="button" class="pos-hmda-opt" aria-pressed="false">
          Prefer not to say
        </button>
      </div>
    </div>
    <div class="pos-hmda-group">
      <span class="pos-eyebrow">
        Sex
      </span>
      <div class="pos-hmda-opts">
        <button type="button" class="pos-hmda-opt is-on" aria-pressed="true">
          Female
        </button>
        <button type="button" class="pos-hmda-opt" aria-pressed="false">
          Male
        </button>
        <button type="button" class="pos-hmda-opt" aria-pressed="false">
          Prefer not to say
        </button>
      </div>
    </div>
    <div class="pos-btn-row">
      <button type="button" class="pos-btn is-ghost">
        Skip this section
      </button>
      <button type="button" class="pos-btn is-sm">
        Continue
      </button>
    </div>
  </div>
</div>
.pos-card.is-dashed {
  background: color-mix(in oklab, var(--p-field) 55%, transparent);
  border-style: dashed;
  box-shadow: none;
}

.pos-btn.is-ghost {
  background: transparent;
  color: var(--p-dim);
  border: 1px solid var(--p-hair);
  font-size: 13.5px;
}

.pos-btn.is-ghost:hover:not(:disabled) {
  color: var(--p-fg);
  transform: none;
}

.pos-hmda-opts {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.pos-hmda-opt {
  padding: 8px 13px;
  min-height: 36px;
  border-radius: var(--r-pill);
  border: 1px solid var(--p-hair);
  cursor: pointer;
  font: 500 12.5px/1.2 var(--p-font);
  background: var(--p-field);
  color: var(--p-dim);
  transition: all var(--dur-1) var(--ease);
}

.pos-hmda-opt.is-on {
  background: var(--p-secondary);
  color: var(--p-secondary-fg);
}

.pos-progress-fill, .pos-chip, .pos-btn, .pos-send, .pos-save-btn, .pos-consent-box, .pos-decl-toggle, .pos-hmda-opt {
  transition: none;
}
import { PosThemeProvider, PosHmdaCard } from '@magicblocksai/ui';

<PosHmdaCard value={hmda} onChange={(k, v) => setHmda({ ...hmda, [k]: v })}
  onSkip={() => commitHmda(true)} onContinue={() => commitHmda(false)} />

26.15 POS quotes

"Here is what 14 lenders came back with." A responsive grid of <QuoteCard surface="pos"> — Monthly · Cash to close · Lender — one column on phones. The same card as the widget's rail, mapped onto the POS namespace by its surface modifier.

PosQuoteGrid

.pos-quote-grid

quotes is the pricing result; posQuotes() returns the design's three. onSelect receives the chosen quote.

Pricing engine · 14 lenders · updated just now
30-yr fixedLowest payment
5.84%rate
5.91% APR · 0 points
Monthly$2,830
Cash to close$14,120
LenderCardinal
20-yr fixed
5.61%rate
5.72% APR · 0.5 points
Monthly$3,340
Cash to close$16,480
LenderPennymac
7/6 ARMCheapest now
5.12%rate
5.88% APR · 0 points
Monthly$2,610
Cash to close$13,950
LenderUWM
<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos-quotes">
    <div class="pos-quotes-eyebrow">
      Pricing engine · 14 lenders · updated just now
    </div>
    <div class="pos-quote-grid">
      <div class="quote-card is-pos is-edge-accent">
        <div class="quote-card-head">
          <span class="quote-card-term">
            30-yr fixed
          </span>
          <span class="quote-card-badge">
            Lowest payment
          </span>
        </div>
        <div>
          <div class="quote-card-rate-row">
            <span class="quote-card-rate">
              5.84%
            </span>
            <span class="quote-card-rate-unit">
              rate
            </span>
          </div>
          <div class="quote-card-meta">
            5.91% APR · 0 points
          </div>
        </div>
        <div class="quote-card-rows">
          <div class="quote-card-row">
            <span class="quote-card-row-label">
              Monthly
            </span>
            <span class="quote-card-row-value">
              $2,830
            </span>
          </div>
          <div class="quote-card-row">
            <span class="quote-card-row-label">
              Cash to close
            </span>
            <span class="quote-card-row-value">
              $14,120
            </span>
          </div>
          <div class="quote-card-row">
            <span class="quote-card-row-label">
              Lender
            </span>
            <span class="quote-card-row-value">
              Cardinal
            </span>
          </div>
        </div>
        <button type="button" class="quote-card-cta">
          Choose this
        </button>
      </div>
      <div class="quote-card is-pos">
        <div class="quote-card-head">
          <span class="quote-card-term">
            20-yr fixed
          </span>
        </div>
        <div>
          <div class="quote-card-rate-row">
            <span class="quote-card-rate">
              5.61%
            </span>
            <span class="quote-card-rate-unit">
              rate
            </span>
          </div>
          <div class="quote-card-meta">
            5.72% APR · 0.5 points
          </div>
        </div>
        <div class="quote-card-rows">
          <div class="quote-card-row">
            <span class="quote-card-row-label">
              Monthly
            </span>
            <span class="quote-card-row-value">
              $3,340
            </span>
          </div>
          <div class="quote-card-row">
            <span class="quote-card-row-label">
              Cash to close
            </span>
            <span class="quote-card-row-value">
              $16,480
            </span>
          </div>
          <div class="quote-card-row">
            <span class="quote-card-row-label">
              Lender
            </span>
            <span class="quote-card-row-value">
              Pennymac
            </span>
          </div>
        </div>
        <button type="button" class="quote-card-cta">
          Choose this
        </button>
      </div>
      <div class="quote-card is-pos">
        <div class="quote-card-head">
          <span class="quote-card-term">
            7/6 ARM
          </span>
          <span class="quote-card-badge">
            Cheapest now
          </span>
        </div>
        <div>
          <div class="quote-card-rate-row">
            <span class="quote-card-rate">
              5.12%
            </span>
            <span class="quote-card-rate-unit">
              rate
            </span>
          </div>
          <div class="quote-card-meta">
            5.88% APR · 0 points
          </div>
        </div>
        <div class="quote-card-rows">
          <div class="quote-card-row">
            <span class="quote-card-row-label">
              Monthly
            </span>
            <span class="quote-card-row-value">
              $2,610
            </span>
          </div>
          <div class="quote-card-row">
            <span class="quote-card-row-label">
              Cash to close
            </span>
            <span class="quote-card-row-value">
              $13,950
            </span>
          </div>
          <div class="quote-card-row">
            <span class="quote-card-row-label">
              Lender
            </span>
            <span class="quote-card-row-value">
              UWM
            </span>
          </div>
        </div>
        <button type="button" class="quote-card-cta">
          Choose this
        </button>
      </div>
    </div>
  </div>
</div>
.quote-card.is-pos {
  --q-accent: var(--p-accent);
  --q-accent-fg: var(--p-accent-fg);
  --q-accent-soft: var(--p-accent-soft);
  --q-accent-text: var(--p-accent-text);
  --q-card: var(--p-card);
  --q-hair: var(--p-hair);
  --q-hair-soft: var(--p-hair-soft);
  --q-field: var(--p-field);
  --q-fg: var(--p-fg);
  --q-dim: var(--p-dim);
  --q-r: var(--p-r-card);
  --q-tap: var(--p-tap);
  --q-font: var(--p-font);
  --q-glow: none;
  padding: 15px;
  gap: 11px;
  box-shadow: var(--sh-1);
}

.quote-card.is-pos .quote-card-rate {
  font-size: 32px;
}

.quote-card.is-pos .quote-card-rows {
  gap: 6px;
  padding-top: 10px;
}

.quote-card.is-pos .quote-card-row {
  font-size: 13px;
}

.quote-card.is-pos .quote-card-cta {
  padding: 11px;
  font-size: 14px;
}

.pos-quotes-eyebrow {
  font: 400 9.5px/1.4 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: .08em;
  color: var(--p-dim);
  margin-bottom: 9px;
}

.pos-quote-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 11px;
}

.pos-quote-grid {
  grid-template-columns: 1fr;
}

.pos-msg, .pos-typing, .pos-typing-dot, .pos-chips, .pos-chip, .pos-card, .pos-doc-chip, .pos-quotes, .pos-submit,
  .pos-consent-box svg, .pos-consent-disc, .pos-help-cta, .pos-help, .pos-help-msg, .pos-submitted, .pos-step-dot,
  .pos-row-fixed, .pos-row-edit, .pos-backdrop, .pos-dialog, .pos-sheet {
  animation: none;
}
import { PosThemeProvider, PosQuoteGrid, QuoteCard, posQuotes } from '@magicblocksai/ui';

<PosQuoteGrid quotes={posQuotes()} onSelect={(q) => pickQuote(q)} />

// Or one card anywhere:
<QuoteCard surface="pos" term="7/6 ARM" rate="5.12%" apr="5.88%" points="0 points"
  rows={[{ label: 'Monthly', value: '$2,610' }, { label: 'Cash to close', value: '$13,950' }]} />

26.16 POS submit + consents

"Everything look right?" on the secondary surface. The three legal consents — FCRA credit pull, E-SIGN disclosures, TCPA contact — each with a checkbox and a Read toggle that expands the disclosure inline. The submit button is dimmed until all three are ticked. Shown with two ticked and the TCPA disclosure open.

PosSubmitCard

.pos-submit

businessName is substituted into the contact consent.

Last step
Everything look right?
Check the card on the right and change anything that's off — then send it to underwriting.
<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos-submit">
    <div class="pos-submit-eyebrow">
      Last step
    </div>
    <div class="pos-submit-title">
      Everything look right?
    </div>
    <div class="pos-submit-text">
      Check the card on the right and change anything that&#x27;s off — then send it to underwriting.
    </div>
    <div class="pos-consents">
      <div class="pos-consent">
        <div class="pos-consent-row">
          <button type="button" class="pos-consent-box is-checked" role="checkbox" aria-checked="true">
            <svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
              <path d="M5 13 L10 18 L19 6" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none">
            </path>
          </svg>
        </button>
        <div class="pos-consent-text">
          <div class="pos-consent-label">
            You may pull my credit when I submit
          </div>
        </div>
        <button type="button" class="pos-consent-read">
          Read
        </button>
      </div>
    </div>
    <div class="pos-consent">
      <div class="pos-consent-row">
        <button type="button" class="pos-consent-box is-checked" role="checkbox" aria-checked="true">
          <svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
            <path d="M5 13 L10 18 L19 6" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none">
          </path>
        </svg>
      </button>
      <div class="pos-consent-text">
        <div class="pos-consent-label">
          Send my disclosures electronically
        </div>
      </div>
      <button type="button" class="pos-consent-read">
        Read
      </button>
    </div>
  </div>
  <div class="pos-consent">
    <div class="pos-consent-row">
      <button type="button" class="pos-consent-box" role="checkbox" aria-checked="false">
      </button>
      <div class="pos-consent-text">
        <div class="pos-consent-label">
          Northgate Mortgage may call or text me about this application
        </div>
        <div class="pos-consent-disc">
          TCPA consent: contact at the number provided, including autodialed text. Message and data rates may apply; reply STOP to opt out.
        </div>
      </div>
      <button type="button" class="pos-consent-read">
        Hide
      </button>
    </div>
  </div>
</div>
<button type="button" class="pos-btn is-inline" disabled="">
  Submit application
  <svg width="17" height="17" viewBox="0 0 24 24" aria-hidden="true">
    <path d="M4 12 H18 M13 6 L19 12 L13 18" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" fill="none">
  </path>
</svg>
</button>
</div>
</div>
.pos-submit-eyebrow {
  font: 400 9.5px/1.4 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: .08em;
  opacity: .7;
}

.pos-submit-title {
  font: 700 21px/1.2 var(--f-display);
  letter-spacing: -.02em;
  margin: 8px 0;
}

.pos-submit-text {
  font-size: 14px;
  line-height: 1.5;
  opacity: .82;
  text-wrap: pretty;
}

.pos-consents {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin: 16px 0 2px;
}

.pos-consent {
  border-top: 1px solid color-mix(in oklab, var(--p-secondary-fg) 12%, transparent);
  padding: 9px 0 5px;
}

.pos-consent-row {
  display: flex;
  align-items: flex-start;
  gap: 11px;
}

.pos-consent-box {
  width: 24px;
  height: 24px;
  flex-shrink: 0;
  margin-top: -1px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 7px;
  border: 1.5px solid color-mix(in oklab, var(--p-secondary-fg) 42%, transparent);
  background: transparent;
  cursor: pointer;
  color: var(--p-accent-fg);
  transition: all var(--dur-2) var(--ease);
}

.pos-consent-box.is-checked {
  background: var(--p-accent);
}

.pos-consent-box svg {
  animation: pos-pop 200ms var(--ease) both;
}

.pos-consent-text {
  flex: 1;
  min-width: 0;
}

.pos-consent-label {
  font-size: 13.5px;
  line-height: 1.45;
  cursor: pointer;
}

.pos-consent-disc {
  font-size: 11.5px;
  line-height: 1.55;
  opacity: .74;
  margin-top: 6px;
  animation: pos-in 220ms var(--ease) both;
}

.pos-consent-read {
  flex-shrink: 0;
  padding: 3px 8px;
  border: 0;
  background: transparent;
  cursor: pointer;
  font: 400 9.5px/1.4 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: .06em;
  color: color-mix(in oklab, var(--p-secondary-fg) 62%, transparent);
  text-decoration: underline;
  text-underline-offset: 3px;
}

.pos-submit .pos-btn {
  margin-top: 12px;
}
import { PosThemeProvider, PosSubmitCard } from '@magicblocksai/ui';

<PosSubmitCard consents={consents} onToggleConsent={toggle}
  openDisclosure={open} onToggleDisclosure={setOpen}
  onSubmit={submit} businessName="Northgate Mortgage" />

26.17 POS help

The side chat. A dashed "Not sure? Ask about this question" button appears beneath any question with an explanation; it opens a dashed panel where the agent answers in prose without touching the application. Closing it discards the side chat.

PosHelp

.pos-help

<PosHelpCta> is the trigger, <PosHelp> the panel. Both shown.

Side chat with Ava · does not touch your application
A range is enough to price accurately — this is NOT a credit pull and will not touch your score. The exact score gets checked only after you submit.
So it won't ding my credit?
<div class="pos-theme-scope" style="--p-side-width:380px">
  <button type="button" class="pos-help-cta">
    <svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
      <circle cx="12" cy="12" r="9" fill="currentColor" opacity="0.14">
    </circle>
    <path d="M9.4 9.3 A2.7 2.7 0 1 1 12.6 12 C12.1 12.3 12 12.7 12 13.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" fill="none">
  </path>
  <circle cx="12" cy="16.6" r="1.15" fill="currentColor">
</circle>
</svg>
Not sure? Ask about this question
</button>
<div class="pos-help">
  <div class="pos-help-head">
    <span class="pos-help-title">
      Side chat with Ava · does not touch your application
    </span>
    <button type="button" class="pos-help-close" aria-label="Close help">
      <svg width="12" height="12" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M6 6 L18 18 M18 6 L6 18" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
      </path>
    </svg>
  </button>
</div>
<div class="pos-help-msgs">
  <div class="pos-help-msg is-agent">
    <div class="pos-help-bubble">
      A range is enough to price accurately — this is NOT a credit pull and will not touch your score. The exact score gets checked only after you submit.
    </div>
  </div>
  <div class="pos-help-msg is-user">
    <div class="pos-help-bubble">
      So it won&#x27;t ding my credit?
    </div>
  </div>
</div>
<div class="pos-help-row">
  <input class="pos-help-input" placeholder="Ask anything about this…" aria-label="Ask a question" value=""/>
  <button type="button" class="pos-help-send" aria-label="Send">
    <svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M4 12 H18 M13 6 L19 12 L13 18" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" fill="none">
    </path>
  </svg>
</button>
</div>
</div>
</div>
.pos-help-cta:hover {
  border-color: var(--p-accent);
  color: var(--p-accent-text);
}

.pos-help {
  border: 1px dashed var(--p-hair);
  border-radius: var(--p-r-card);
  background: color-mix(in oklab, var(--p-field) 55%, transparent);
  padding: 13px 14px;
  animation: pos-in 300ms var(--ease) both;
}

.pos-help-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 10px;
}

.pos-help-title {
  font: 400 9.5px/1.4 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: .07em;
  color: var(--p-faint);
}

.pos-help-close {
  width: 28px;
  height: 28px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  background: transparent;
  border-radius: 8px;
  cursor: pointer;
  color: var(--p-faint);
}

.pos-help-close:hover {
  background: var(--p-field);
  color: var(--p-fg);
}

.pos-help-msgs {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 11px;
}

.pos-help-msg {
  display: flex;
  flex-direction: column;
  animation: pos-in 260ms var(--ease) both;
}

.pos-help-msg.is-agent .pos-help-bubble {
  align-self: flex-start;
  max-width: 92%;
  font-size: 13.5px;
  line-height: 1.55;
  color: var(--p-fg);
  text-wrap: pretty;
}

.pos-help-msg.is-user .pos-help-bubble {
  align-self: flex-end;
  max-width: 88%;
  background: var(--p-bubble);
  padding: 8px 12px;
  border-radius: 12px 12px 4px 12px;
  font-size: 13.5px;
  line-height: 1.5;
  color: var(--p-fg);
}

.pos-help-row {
  display: flex;
  align-items: center;
  gap: 7px;
}

.pos-help-input {
  flex: 1;
  min-width: 0;
  box-sizing: border-box;
  padding: 9px 12px;
  min-height: 38px;
  border-radius: var(--r-pill);
  border: 1px solid var(--p-hair);
  background: var(--p-card);
  font: 400 13.5px/1.3 var(--p-font);
  color: var(--p-fg);
  outline: none;
}

.pos-help-input:focus {
  border-color: var(--p-accent);
  box-shadow: none;
}

.pos-help-send {
  width: 38px;
  height: 38px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
  background: var(--p-secondary);
  color: var(--p-secondary-fg);
  transition: all var(--dur-2) var(--ease);
}
import { PosThemeProvider, PosHelp, PosHelpCta } from '@magicblocksai/ui';

{step.help && !helpOpen && <PosHelpCta onClick={askHelp} />}
{helpOpen && (
  <PosHelp title="Side chat with Ava · does not touch your application"
    messages={helpMsgs} draft={draft} onDraft={setDraft} onSend={sendHelp} onClose={() => setHelpOpen(false)} />
)}

26.18 POS submitted

"Submitted · NG-48120" and exactly what happens next. Four step states: done (green tick), now (pulsing accent dot), todo (dashed ring with an Add now file picker) and wait (clock).

PosSubmitted

.pos-submitted

posNextSteps(docCount) builds the design's four; the income-document step flips to done once a file is attached.

<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos-submitted">
    <div class="pos-submitted-head">
      <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M5 13 L10 18 L19 6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none">
      </path>
    </svg>
    <span class="pos-submitted-title">
      Submitted · NG-48120
    </span>
  </div>
  <p class="pos-submitted-text">
    An underwriter has it. Here is exactly what happens next:
  </p>
  <div class="pos-steps">
    <div class="pos-step">
      <span class="pos-step-icon is-done">
        <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
          <path d="M5 13 L10 18 L19 6" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" fill="none">
        </path>
      </svg>
    </span>
    <span class="pos-step-text">
      <span class="pos-step-label">
        Application received
      </span>
      <span class="pos-step-sub">
        Reference NG-48120
      </span>
    </span>
  </div>
  <div class="pos-step">
    <span class="pos-step-icon is-now">
      <span class="pos-step-dot">
      </span>
    </span>
    <span class="pos-step-text">
      <span class="pos-step-label">
        Credit check &amp; rate lock
      </span>
      <span class="pos-step-sub">
        Running now
      </span>
    </span>
  </div>
  <div class="pos-step">
    <span class="pos-step-icon is-todo">
      !
    </span>
    <span class="pos-step-text">
      <span class="pos-step-label">
        Income document
      </span>
      <span class="pos-step-sub">
        Waiting on you — photo is fine
      </span>
    </span>
    <label class="pos-step-action">
      Add now
      <input type="file" multiple="" accept="image/*,.pdf"/>
    </label>
  </div>
  <div class="pos-step">
    <span class="pos-step-icon is-wait">
      <svg width="12" height="12" viewBox="0 0 24 24" aria-hidden="true">
        <circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.8">
      </circle>
      <path d="M12 7.5 V12 L15 14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" fill="none">
    </path>
  </svg>
</span>
<span class="pos-step-text">
  <span class="pos-step-label">
    Conditional decision
  </span>
  <span class="pos-step-sub">
    By email today
  </span>
</span>
</div>
</div>
</div>
</div>
.pos-submitted-head {
  display: flex;
  align-items: center;
  gap: 10px;
  color: var(--success-text);
}

.pos-submitted-title {
  font: 600 17px/1.3 var(--f-display);
  color: var(--p-fg);
}

.pos-submitted-text {
  font-size: 14px;
  line-height: 1.6;
  margin: 10px 0 14px;
  color: var(--p-dim);
}

.pos-steps {
  display: flex;
  flex-direction: column;
}

.pos-step {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 0;
  border-top: 1px solid var(--p-hair-soft);
}

.pos-step-icon {
  width: 26px;
  height: 26px;
  flex-shrink: 0;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.pos-step-icon.is-done {
  background: var(--success-soft);
  color: var(--success-text);
}

.pos-step-icon.is-now {
  background: var(--p-accent-soft);
}

.pos-step-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--p-accent);
  animation: pos-dot 1.2s var(--ease) infinite;
}

.pos-step-icon.is-todo {
  border: 1.5px dashed var(--p-hair);
  font: 400 10px/1 var(--f-mono);
  color: var(--p-dim);
}

.pos-step-icon.is-wait {
  border: 1.5px solid var(--p-hair);
  color: var(--p-dim);
}

.pos-step-text {
  flex: 1;
  min-width: 0;
}

.pos-step-label {
  display: block;
  font-size: 13.5px;
  font-weight: 600;
}

.pos-step-sub {
  display: block;
  font-size: 12px;
  color: var(--p-dim);
  margin-top: 1px;
}
import { PosThemeProvider, PosSubmitted, posNextSteps } from '@magicblocksai/ui';

<PosSubmitted reference="NG-48120" steps={posNextSteps(docs.length)} onFiles={addDocs} />

26.19 POS application card

"Your application." The live card, grouped Loan · Property · Borrower · Contact. Each row is one of four states: empty (—), fixed (verified phone, documents, declarations, HMDA — shown but never edited), reading (click to edit, pencil on hover) or editing (an input that commits on blur or Enter). Submit appears when the flow is complete; until then a lock line reads "Nothing is submitted until you press the button."

PosApplicationCard

.pos-application

The same markup renders in the desktop side column and inside the phone sheet. usePosApplication().groups produces groups; posCardRows / posCardGroups do it without the hook.

Your application46% complete

Fills in as you answer. Click any value to change it.

Loan
Goal
Purchase price
Down payment
Product
Property
Address
Type
Contact
Contact
Phone(415) 555-0134 · verified
Nothing is submitted until you press the button.
NMLS #1824561 · Equal Housing Lender
<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos-application">
    <div class="pos-side-head">
      <span class="pos-side-title">
        Your application
      </span>
      <span class="pos-eyebrow is-count">
        46% complete
      </span>
    </div>
    <p class="pos-side-hint">
      Fills in as you answer. Click any value to change it.
    </p>
    <div class="pos-group">
      <div class="pos-group-title">
        Loan
      </div>
      <div class="pos-row">
        <span class="pos-row-label">
          Goal
        </span>
        <button type="button" class="pos-row-edit" title="Edit">
          <span class="pos-row-value">
            Buying a home
          </span>
          <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
            <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
          </path>
          <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
        </path>
      </svg>
    </button>
  </div>
  <div class="pos-row">
    <span class="pos-row-label">
      Purchase price
    </span>
    <button type="button" class="pos-row-edit" title="Edit">
      <span class="pos-row-value">
        $600,000
      </span>
      <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
      </path>
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
    </path>
  </svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Down payment
  </span>
  <span class="pos-row-empty">
    —
  </span>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Product
  </span>
  <span class="pos-row-empty">
    —
  </span>
</div>
</div>
<div class="pos-group">
  <div class="pos-group-title">
    Property
  </div>
  <div class="pos-row">
    <span class="pos-row-label">
      Address
    </span>
    <button type="button" class="pos-row-edit" title="Edit">
      <span class="pos-row-value">
        1420 Fillmore St, San Francisco, CA 94115
      </span>
      <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
      </path>
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
    </path>
  </svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Type
  </span>
  <button type="button" class="pos-row-edit" title="Edit">
    <span class="pos-row-value">
      Condo
    </span>
    <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
    </path>
    <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
  </path>
</svg>
</button>
</div>
</div>
<div class="pos-group">
  <div class="pos-group-title">
    Contact
  </div>
  <div class="pos-row">
    <span class="pos-row-label">
      Contact
    </span>
    <button type="button" class="pos-row-edit" title="Edit">
      <span class="pos-row-value">
        Maria Alvarez
      </span>
      <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
      </path>
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
    </path>
  </svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Phone
  </span>
  <span class="pos-row-fixed">
    (415) 555-0134 · verified
  </span>
</div>
</div>
<div class="pos-pending">
  <svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true">
    <rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" opacity="0.35">
  </rect>
  <path d="M7 10 V7 A5 5 0 0 1 17 7 V10" stroke="currentColor" stroke-width="2" fill="none">
</path>
<circle cx="12" cy="15.5" r="1.6" fill="currentColor">
</circle>
</svg>
Nothing is submitted until you press the button.
</div>
<div class="pos-nmls">
  <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
    <path d="M12 3 L21 10 H19 V20 H5 V10 H3 Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round">
  </path>
  <path d="M12 8.5 L16 11.5 V17 H8 V11.5 Z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round">
</path>
<path d="M10.5 17 V13.5 H13.5 V17" fill="none" stroke="currentColor" stroke-width="1.4">
</path>
</svg>
NMLS #1824561 · Equal Housing Lender
</div>
</div>
</div>
.pos-group {
  margin-bottom: 18px;
}

.pos-group-title {
  font: 400 9.5px/1.4 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: .08em;
  color: var(--p-faint);
  padding-bottom: 7px;
  border-bottom: 1px solid var(--p-hair-soft);
  margin-bottom: 4px;
}

.pos-row {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 38px;
  padding: 3px 0;
}

.pos-row-label {
  width: 108px;
  flex-shrink: 0;
  font-size: 12.5px;
  color: var(--p-dim);
}

.pos-row-empty {
  flex: 1;
  font-size: 13.5px;
  color: var(--p-faint);
}

.pos-row-fixed {
  flex: 1;
  min-width: 0;
  font-size: 13.5px;
  font-weight: 600;
  color: var(--p-fg);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  animation: pos-pop 300ms var(--ease) both;
}

.pos-row-edit {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 6px 8px;
  margin: 0 -8px;
  border: 0;
  background: transparent;
  border-radius: 8px;
  cursor: pointer;
  text-align: left;
  font-family: var(--p-font);
  animation: pos-pop 300ms var(--ease) both;
  transition: background var(--dur-1) var(--ease);
}

.pos-row-edit:hover {
  background: var(--p-field);
}

.pos-row-value {
  font-size: 13.5px;
  font-weight: 600;
  color: var(--p-fg);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pos-row-edit svg {
  color: var(--p-faint);
  flex-shrink: 0;
}

.pos-row-input {
  flex: 1;
  min-width: 0;
  box-sizing: border-box;
  padding: 7px 9px;
  border-radius: 8px;
  border: 1px solid var(--p-accent);
  background: var(--p-field);
  font: 600 13.5px/1.3 var(--p-font);
  color: var(--p-fg);
  outline: none;
}

.pos-pending {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 12px 14px;
  border-radius: var(--p-r-field);
  background: var(--p-field);
  font-size: 12.5px;
  color: var(--p-dim);
  text-wrap: pretty;
}

.pos-pending svg {
  color: var(--p-faint);
  flex-shrink: 0;
}

.pos-nmls {
  display: flex;
  align-items: center;
  gap: 7px;
  margin-top: 16px;
  padding-top: 12px;
  border-top: 1px solid var(--p-hair-soft);
  font: 400 9.5px/1.4 var(--f-mono);
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--p-faint);
}
import { PosThemeProvider, PosApplicationCard, usePosApplication } from '@magicblocksai/ui';

const app = usePosApplication();

<PosApplicationCard title="Your application" hint="Fills in as you answer. Click any value to change it."
  progressLabel={`${app.progress.pct}% complete`} groups={app.groups}
  onEdit={app.editRow} onEditChange={(v) => app.dispatch({ type: 'editValue', value: v })} onEditCommit={app.commitEdit}
  canSubmit={app.canSubmit} onSubmit={app.submit} />

26.20 POS mobile bar

On phones the card collapses into this fixed bottom bar: a count badge, the card title, a one-line summary of the last two captured values, and a chevron. Tapping it opens the sheet.

PosMobileBar

.pos-mobile-bar

Positioned fixed in a real page; anchored to the demo stage here.

<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos-mobile-bar">
    <button type="button" class="pos-mobile-bar-btn" aria-haspopup="dialog">
      <span class="pos-mobile-count">
        6
      </span>
      <span class="pos-mobile-text">
        <span class="pos-mobile-title">
          Your application
        </span>
        <span class="pos-mobile-summary">
          $600,000 · Condo
        </span>
      </span>
      <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M6 14 L12 8 L18 14" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" fill="none">
      </path>
    </svg>
  </button>
</div>
</div>
.pos-mobile-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 40;
  background: var(--p-card);
  border-top: 1px solid var(--p-hair);
  box-shadow: 0 -8px 28px rgba(25,30,50,.1);
}

.pos-mobile-bar-btn {
  width: 100%;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 16px calc(12px + var(--p-safe));
  min-height: var(--p-tap);
  border: 0;
  background: transparent;
  cursor: pointer;
  text-align: left;
  font-family: var(--p-font);
  color: var(--p-fg);
}

.pos-mobile-count {
  width: 30px;
  height: 30px;
  border-radius: 8px;
  background: var(--p-accent-soft);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  font: 600 11px/1 var(--f-mono);
  color: var(--p-accent-text);
}

.pos-mobile-summary {
  display: block;
  font: 400 10px/1.4 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--p-dim);
  margin-top: 2px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pos-mobile-bar-btn svg {
  color: var(--p-dim);
  flex-shrink: 0;
}
import { PosThemeProvider, PosMobileBar } from '@magicblocksai/ui';

<PosMobileBar count={6} title="Your application" summary="$600,000 · Condo" onOpen={() => setSheetOpen(true)} />

26.21 POS sheet

The bottom sheet the application card opens into on phones. Backdrop fades, the sheet slides up (pos-sheet-in) under a grab handle; the handle, the backdrop and Escape all close it. Built on the kit's focus-trap, scroll-lock and escape primitives.

PosSheet

.pos-sheet

Renders nothing while closed. Shown open, anchored to the demo stage.

<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos-sheet-wrap">
    <div class="pos-backdrop">
    </div>
    <div class="pos-sheet" role="dialog" aria-modal="true">
      <div class="pos-sheet-handle">
        <span>
        </span>
      </div>
      <div class="pos-application">
        <div class="pos-side-head">
          <span class="pos-side-title">
            Your application
          </span>
          <span class="pos-eyebrow is-count">
            46% complete
          </span>
        </div>
        <div class="pos-group">
          <div class="pos-group-title">
            Loan
          </div>
          <div class="pos-row">
            <span class="pos-row-label">
              Goal
            </span>
            <button type="button" class="pos-row-edit" title="Edit">
              <span class="pos-row-value">
                Buying a home
              </span>
              <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
                <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
              </path>
              <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
            </path>
          </svg>
        </button>
      </div>
      <div class="pos-row">
        <span class="pos-row-label">
          Purchase price
        </span>
        <button type="button" class="pos-row-edit" title="Edit">
          <span class="pos-row-value">
            $600,000
          </span>
          <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
            <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
          </path>
          <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
        </path>
      </svg>
    </button>
  </div>
  <div class="pos-row">
    <span class="pos-row-label">
      Down payment
    </span>
    <span class="pos-row-empty">
      —
    </span>
  </div>
  <div class="pos-row">
    <span class="pos-row-label">
      Product
    </span>
    <span class="pos-row-empty">
      —
    </span>
  </div>
</div>
<div class="pos-group">
  <div class="pos-group-title">
    Property
  </div>
  <div class="pos-row">
    <span class="pos-row-label">
      Address
    </span>
    <button type="button" class="pos-row-edit" title="Edit">
      <span class="pos-row-value">
        1420 Fillmore St, San Francisco, CA 94115
      </span>
      <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
      </path>
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
    </path>
  </svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Type
  </span>
  <button type="button" class="pos-row-edit" title="Edit">
    <span class="pos-row-value">
      Condo
    </span>
    <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
    </path>
    <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
  </path>
</svg>
</button>
</div>
</div>
<div class="pos-group">
  <div class="pos-group-title">
    Contact
  </div>
  <div class="pos-row">
    <span class="pos-row-label">
      Contact
    </span>
    <button type="button" class="pos-row-edit" title="Edit">
      <span class="pos-row-value">
        Maria Alvarez
      </span>
      <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="currentColor" opacity="0.3">
      </path>
      <path d="M4 20 H8 L19 9 A2.1 2.1 0 0 0 16 6 L5 17 Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
    </path>
  </svg>
</button>
</div>
<div class="pos-row">
  <span class="pos-row-label">
    Phone
  </span>
  <span class="pos-row-fixed">
    (415) 555-0134 · verified
  </span>
</div>
</div>
<div class="pos-pending">
  <svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true">
    <rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" opacity="0.35">
  </rect>
  <path d="M7 10 V7 A5 5 0 0 1 17 7 V10" stroke="currentColor" stroke-width="2" fill="none">
</path>
<circle cx="12" cy="15.5" r="1.6" fill="currentColor">
</circle>
</svg>
Nothing is submitted until you press the button.
</div>
</div>
</div>
</div>
</div>
.pos-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(25,30,50,.36);
  animation: pos-fade-in 220ms var(--ease) both;
}

.pos-sheet-wrap {
  position: fixed;
  inset: 0;
  z-index: 50;
}

.pos-sheet-wrap .pos-backdrop {
  animation-duration: 240ms;
}

.pos-sheet {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  max-height: 82vh;
  overflow-y: auto;
  background: var(--p-card);
  border-radius: 22px 22px 0 0;
  padding: 10px 18px calc(22px + var(--p-safe));
  animation: pos-sheet-in 340ms var(--ease) both;
}

.pos-sheet-handle {
  display: flex;
  justify-content: center;
  padding: 6px 0 12px;
  cursor: pointer;
}

.pos-sheet-handle span {
  width: 40px;
  height: 4px;
  border-radius: var(--r-pill);
  background: var(--p-hair);
}

.pos-sheet .pos-side-head {
  margin-bottom: 14px;
}

.pos-sheet .pos-side-title {
  font-size: 18px;
}

.pos-sheet .pos-row {
  min-height: var(--p-tap);
}

.pos-sheet .pos-row-fixed, .pos-sheet .pos-row-value {
  font-size: 14px;
}

.pos-sheet .pos-row-edit {
  padding: 8px;
  min-height: var(--p-tap);
}

.pos-sheet .pos-row-input {
  padding: 10px;
  min-height: var(--p-tap);
  font-size: 16px;
}

.pos-sheet .pos-btn {
  padding: 14px;
}

.pos-msg, .pos-typing, .pos-typing-dot, .pos-chips, .pos-chip, .pos-card, .pos-doc-chip, .pos-quotes, .pos-submit,
  .pos-consent-box svg, .pos-consent-disc, .pos-help-cta, .pos-help, .pos-help-msg, .pos-submitted, .pos-step-dot,
  .pos-row-fixed, .pos-row-edit, .pos-backdrop, .pos-dialog, .pos-sheet {
  animation: none;
}
import { PosThemeProvider, PosSheet, PosApplicationCard } from '@magicblocksai/ui';

<PosSheet open={sheetOpen} onClose={() => setSheetOpen(false)}>
  <PosApplicationCard title="Your application" progressLabel="46% complete" groups={groups} footer={null} />
</PosSheet>

26.22 POS save dialog

"Save & finish later." A centred dialog over a dimmed backdrop: the email pre-filled from the contact step, Keep going and Send my link. The link drops the visitor back on the exact question; answers stay for 30 days.

PosSaveDialog

.pos-dialog

Renders nothing while closed. Shown open, anchored to the demo stage.

<div class="pos-theme-scope" style="--p-side-width:380px">
  <div class="pos-overlay">
    <div class="pos-backdrop">
    </div>
    <div class="pos-dialog" role="dialog" aria-modal="true" aria-labelledby="_R_0_">
      <div class="pos-dialog-title" id="_R_0_">
        Save &amp; finish later
      </div>
      <p class="pos-dialog-text">
        We&#x27;ll email a link that drops you back on the exact question you&#x27;re on. Answers stay for 30 days.
      </p>
      <input class="pos-dialog-input" type="email" placeholder="you@email.com" aria-label="Email" value="maria@email.com"/>
      <div class="pos-btn-row">
        <button type="button" class="pos-btn is-ghost">
          Keep going
        </button>
        <button type="button" class="pos-btn is-sm">
          Send my link
        </button>
      </div>
    </div>
  </div>
</div>
.pos-input, .pos-field-input, .pos-otp-input, .pos-dialog-input {
  width: 100%;
  flex: 1;
  min-width: 0;
  box-sizing: border-box;
  padding: 12px 14px;
  min-height: var(--p-tap);
  border-radius: var(--p-r-field);
  border: 1px solid var(--p-hair);
  background: var(--p-field);
  font: 400 16px/1.3 var(--p-font);
  color: var(--p-fg);
  outline: none;
  transition: all var(--dur-2) var(--ease);
}

.pos-input:focus, .pos-field-input:focus, .pos-otp-input:focus, .pos-dialog-input:focus {
  border-color: var(--p-accent);
  background: var(--p-card);
  box-shadow: none;
}

.pos-dialog {
  position: relative;
  width: 100%;
  max-width: 380px;
  box-sizing: border-box;
  background: var(--p-card);
  border: 1px solid var(--p-hair);
  border-radius: var(--p-r-card);
  box-shadow: var(--sh-4);
  padding: 20px;
  animation: pos-in 300ms var(--ease) both;
}

.pos-dialog-title {
  font: 700 18px/1.2 var(--f-display);
  letter-spacing: -.015em;
  margin-bottom: 6px;
}

.pos-dialog-text {
  font-size: 13px;
  line-height: 1.55;
  color: var(--p-dim);
  margin: 0 0 14px;
}

.pos-dialog .pos-btn-row {
  margin-top: 12px;
}

.pos-msg, .pos-typing, .pos-typing-dot, .pos-chips, .pos-chip, .pos-card, .pos-doc-chip, .pos-quotes, .pos-submit,
  .pos-consent-box svg, .pos-consent-disc, .pos-help-cta, .pos-help, .pos-help-msg, .pos-submitted, .pos-step-dot,
  .pos-row-fixed, .pos-row-edit, .pos-backdrop, .pos-dialog, .pos-sheet {
  animation: none;
}
import { PosThemeProvider, PosSaveDialog } from '@magicblocksai/ui';

<PosSaveDialog open={saveOpen} email={contact.email} onEmail={(v) => setContact({ ...contact, email: v })}
  onSend={sendSaveLink} onClose={() => setSaveOpen(false)} />