25.1 Theme provider
The root context component. Converts a WidgetTheme config object into scoped CSS custom properties — every child widget component reads from them. Wrap the entire widget tree in a single provider.
WidgetThemeProvider
.widget-theme-scopeRenders a div.widget-theme-scope that carries all --w-* custom properties derived from the theme prop. Stateless — re-renders only when theme changes.
<div class="widget-theme-scope" style="
--w-shell-header-bg: #C69C6D;
--w-shell-header-text: #FCFCFC;
--w-shell-chat-bg: #FCFCFC;
">
<!-- widget components go here -->
</div>
.widget-theme-scope {
--w-font-family: Inter, system-ui, sans-serif;
--w-font-weight: 400;
--w-font-size: 14px;
--w-line-height: 22px;
font-family: var(--w-font-family);
font-weight: var(--w-font-weight);
font-size: var(--w-font-size);
line-height: var(--w-line-height);
color-scheme: light;
}
import { WidgetThemeProvider } from '@magicblocksai/ui';
const theme = {
launcher: { bg: '#C69C6D', icon: '#FFFFFF' },
shell: { headerBg: '#C69C6D', headerText: '#FCFCFC', chatBg: '#FCFCFC' },
// … full WidgetTheme shape
};
<WidgetThemeProvider theme={theme}>
{/* widget components go here */}
</WidgetThemeProvider>
25.2 Launcher
The floating bubble that opens the chat panel. Reads colour, icon, size, position, and optional image background from WidgetTheme.launcher. Stateless — the consumer owns open + onClick.
WidgetLauncher
.widget-launcherDefault launcher with chat icon. Add a .widget-launcher-badge child for unread counts. Set has-image and the --w-launcher-image CSS variable for image backgrounds.
<!-- Default -->
<button class="widget-launcher" aria-label="Open chat">
<span class="widget-launcher-disc"></span>
<span class="widget-launcher-icon">
<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
<!-- Bubble fill follows --w-launcher-icon (currentColor);
dots knock through in --w-launcher-bg so they read on every variant. -->
<path d="M4 6a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H9l-5 4v-4a3 3 0 0 1 0-6V6z" fill="currentColor"/>
<circle cx="8.5" cy="10" r="1.45" fill="var(--w-launcher-bg, #18181B)"/>
<circle cx="12" cy="10" r="1.45" fill="var(--w-launcher-bg, #18181B)"/>
<circle cx="15.5" cy="10" r="1.45" fill="var(--w-launcher-bg, #18181B)"/>
</svg>
</span>
<!-- Optional badge: -->
<span class="widget-launcher-badge" aria-label="2 unread">2</span>
</button>
.widget-launcher {
appearance: none;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--w-launcher-size, 60px);
height: var(--w-launcher-size, 60px);
border-radius: 999px;
border: 0;
cursor: pointer;
color: var(--w-launcher-icon, #FFFFFF);
background: transparent;
transition: transform var(--dur-2) var(--ease), filter var(--dur-2) var(--ease);
}
.widget-launcher.is-floating {
position: fixed;
z-index: 9998;
bottom: var(--w-launcher-bottom, 40px);
}
.widget-launcher.is-floating.is-pos-right { right: var(--w-launcher-side, 40px); }
.widget-launcher.is-floating.is-pos-left { left: var(--w-launcher-side, 40px); }
.widget-launcher:hover { transform: translateY(-2px); }
.widget-launcher:focus-visible {
outline: 3px solid color-mix(in oklab, var(--w-launcher-bg, var(--ink)) 40%, transparent);
outline-offset: 2px;
}
.widget-launcher-disc {
position: absolute;
inset: 0;
border-radius: 999px;
background: var(--w-launcher-bg, #18181B);
background-image: var(--w-launcher-image, none);
background-size: cover;
background-position: center;
box-shadow: 0 8px 20px color-mix(in oklab, #000 22%, transparent);
transition: background var(--dur-2) var(--ease);
}
.widget-launcher.has-image .widget-launcher-disc {
/* When image is set, dim slightly so the icon stays visible. */
filter: brightness(0.9);
}
.widget-launcher-icon {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--w-launcher-icon, #FFFFFF);
}
.widget-launcher-badge {
position: absolute;
top: 0;
right: 0;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: #EC4899;
color: #FFFFFF;
font: 700 11px/1 var(--f-mono);
display: inline-flex;
align-items: center;
justify-content: center;
border: 2px solid var(--w-launcher-bg, #18181B);
z-index: 2;
}
@media (prefers-reduced-motion: reduce) {
.widget-launcher,
.widget-launcher-disc { transition: none; }
.widget-launcher:hover { transform: none; }
}
import { WidgetLauncher } from '@magicblocksai/ui';
<WidgetLauncher
onClick={() => setOpen(true)}
unreadCount={2}
/>
{/* With image background */}
<WidgetLauncher
onClick={() => setOpen(true)}
imageUrl="https://example.com/logo.png"
/>
25.3 Proactive message
The pre-engagement bubble that pops out beside the launcher. The visitor sees it before clicking open. Clicking the body opens the chat; clicking × dismisses the proactive bubble only — the launcher stays.
WidgetProactiveMessage
.widget-proactiveThemed via --w-proactive-bg, --w-proactive-text, --w-proactive-border, and --w-proactive-radius. Renders an avatar, agent name, and message preview.
<div class="widget-proactive"
style="--w-proactive-bg:#FFFFFF;--w-proactive-text:#18181B;
--w-proactive-border:#E4E4E7;--w-proactive-radius:12px;">
<button class="widget-proactive-body" type="button">
<span class="widget-proactive-avatar">CW</span>
<span class="widget-proactive-text">
<span class="widget-proactive-title">Charlie's Wines</span>
<span class="widget-proactive-message">Hi! Looking for the perfect wine?</span>
</span>
</button>
<button class="widget-proactive-dismiss" aria-label="Dismiss">×</button>
</div>
.widget-proactive {
display: inline-flex;
align-items: stretch;
max-width: 320px;
background: var(--w-proactive-bg, #FFFFFF);
border: 1px solid var(--w-proactive-border, var(--hair));
border-radius: var(--w-proactive-radius, 12px);
color: var(--w-proactive-text, var(--ink));
box-shadow: 0 8px 24px color-mix(in oklab, #000 16%, transparent);
overflow: hidden;
}
.widget-proactive.is-floating {
position: fixed;
z-index: 9997;
bottom: calc(var(--w-launcher-bottom, 40px) + var(--w-proactive-offset, 88px));
}
.widget-proactive.is-floating.is-pos-right { right: var(--w-launcher-side, 40px); }
.widget-proactive.is-floating.is-pos-left { left: var(--w-launcher-side, 40px); }
.widget-proactive-body {
appearance: none;
background: transparent;
border: 0;
flex: 1;
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
padding: 12px 14px;
text-align: left;
cursor: pointer;
color: inherit;
font: inherit;
}
.widget-proactive-body:hover {
background: color-mix(in oklab, var(--w-proactive-bg, #FFFFFF) 88%, var(--ink));
}
.widget-proactive-avatar {
width: 32px;
height: 32px;
border-radius: 999px;
background: color-mix(in oklab, var(--accent) 20%, var(--bg-warm));
color: var(--accent);
display: inline-flex;
align-items: center;
justify-content: center;
font: 600 12px/1 var(--f-mono);
}
.widget-proactive-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.widget-proactive-title { font: 600 13px/1.3 var(--f-body); }
.widget-proactive-message { font: 400 13px/1.5 var(--f-body); }
.widget-proactive-dismiss {
appearance: none;
background: transparent;
border: 0;
border-left: 1px solid var(--w-proactive-border, var(--hair));
width: 32px;
cursor: pointer;
color: color-mix(in oklab, var(--w-proactive-text, var(--ink)) 60%, transparent);
display: inline-flex;
align-items: center;
justify-content: center;
transition: background var(--dur-2) var(--ease);
}
.widget-proactive-dismiss:hover {
background: color-mix(in oklab, var(--w-proactive-bg, #FFFFFF) 90%, var(--ink));
}
import { WidgetProactiveMessage } from '@magicblocksai/ui';
<WidgetProactiveMessage
agentName="Charlie's Wines"
agentInitials="CW"
message="Hi! Looking for the perfect wine?"
onOpen={() => setOpen(true)}
onDismiss={() => setProactiveVisible(false)}
/>
25.4 Full shell
The expanded chat panel assembled into a complete widget. Header → welcome disclaimer → transcript → quick-reply row → composer → legal disclaimer → branding marker. Every slot is driven by the theme; consumers override individual slots with explicit nodes where needed.
WidgetShell
.widget-shellThe container that composes all sub-components into the full chat experience. Wraps in a widget-theme-scope and sizes to --w-shell-width × --w-shell-height.
<div class="widget-theme-scope" style="--w-shell-header-bg:#C69C6D; …">
<div class="widget-shell">
<header class="widget-shell-header">
<span class="widget-shell-avatar">CW</span>
<div class="widget-shell-header-text">
<span class="widget-shell-agent">Charlie's Wines</span>
<span class="widget-shell-subtitle">Online · usually replies in 1 min</span>
</div>
<button class="widget-shell-close" aria-label="Close chat">×</button>
</header>
<div class="widget-shell-welcome">You're chatting with an AI assistant.</div>
<div class="widget-shell-scroll">
<div class="widget-shell-list">
<!-- WidgetMessage entries -->
</div>
</div>
<div class="widget-shell-composer"><!-- WidgetComposer --></div>
<div class="widget-shell-legal">I'm an AI agent…</div>
<div class="widget-shell-branding"><!-- WidgetBrandingMarker --></div>
</div>
</div>
.widget-shell {
display: flex;
flex-direction: column;
width: var(--w-shell-width, 380px);
height: var(--w-shell-height, 560px);
max-height: calc(100vh - 80px);
background: var(--w-shell-chat-bg, #FCFCFC);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 50px color-mix(in oklab, #000 24%, transparent);
border: 1px solid color-mix(in oklab, #000 8%, transparent);
}
.widget-shell.is-floating {
position: fixed;
z-index: 9999;
bottom: calc(var(--w-launcher-bottom, 40px) + var(--w-launcher-size, 60px) + 16px);
}
.widget-shell.is-floating.is-pos-right { right: var(--w-launcher-side, 40px); }
.widget-shell.is-floating.is-pos-left { left: var(--w-launcher-side, 40px); }
@media (max-width: 540px) {
.widget-shell.is-floating {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
max-height: 100vh;
border-radius: 0;
}
}
.widget-shell-header {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 10px;
padding: 14px 16px;
background: var(--w-shell-header-bg, #18181B);
background-image: var(--w-shell-bg-image, none);
background-size: cover;
background-position: center;
color: var(--w-shell-header-text, #FCFCFC);
flex-shrink: 0;
}
.widget-shell-avatar {
width: 32px;
height: 32px;
border-radius: 999px;
background: color-mix(in oklab, var(--w-shell-header-text, #FFFFFF) 18%, transparent);
color: var(--w-shell-header-text, #FFFFFF);
display: inline-flex;
align-items: center;
justify-content: center;
font: 600 13px/1 var(--f-mono);
}
.widget-shell-header-text {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.widget-shell-agent {
font: 600 15px/1.3 var(--f-display);
color: inherit;
}
.widget-shell-subtitle {
font: 400 12px/1.3 var(--f-body);
opacity: 0.78;
}
.widget-shell-actions { display: inline-flex; align-items: center; gap: 4px; }
.widget-shell-close {
appearance: none;
background: transparent;
border: 0;
width: 28px;
height: 28px;
border-radius: 999px;
color: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background var(--dur-2) var(--ease);
}
.widget-shell-close:hover {
background: color-mix(in oklab, var(--w-shell-header-text, #FFFFFF) 12%, transparent);
}
/* …additional rules trimmed for brevity — see _shared.css */
import { WidgetThemeProvider, WidgetShell } from '@magicblocksai/ui';
<WidgetThemeProvider theme={theme}>
<WidgetShell
agentName="Charlie's Wines"
agentInitials="CW"
subtitle="Online · usually replies in 1 min"
messages={messages}
onClose={() => setOpen(false)}
onSend={handleSend}
/>
</WidgetThemeProvider>
25.5 Messages
Two sides — is-from-user and is-from-ai. Theme-driven colours, border, and radius per side. Smart 12h or 24h timestamp formatting. Optional AI-name row. Streaming and failed-delivery states built in.
WidgetMessage
.widget-msgAdd is-streaming for the mid-stream caret, or is-failed (with role="alert") for failed delivery with a retry prompt.
<!-- AI message -->
<div class="widget-msg is-from-ai">
<div class="widget-msg-avatar">CW</div>
<div class="widget-msg-stack">
<div class="widget-msg-name">Charlie</div>
<div class="widget-msg-bubble">
<div class="widget-msg-body">Hi there 👋</div>
</div>
<div class="widget-msg-time">2:14 PM</div>
</div>
</div>
<!-- User message -->
<div class="widget-msg is-from-user">
<div class="widget-msg-stack">
<div class="widget-msg-bubble">
<div class="widget-msg-body">Quick question about pricing.</div>
</div>
</div>
</div>
<!-- Streaming -->
<div class="widget-msg is-from-ai is-streaming">…<span class="widget-msg-caret" aria-hidden="true"></span></div>
<!-- Failed -->
<div class="widget-msg is-from-user is-failed" role="alert">
<div class="widget-msg-stack">
<div class="widget-msg-bubble"><div class="widget-msg-body">…</div></div>
<div class="widget-msg-failed">Couldn't deliver — tap to retry.</div>
</div>
</div>
.widget-msg {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: flex-end;
}
.widget-msg.is-from-user {
grid-template-columns: minmax(0, 1fr);
justify-items: end;
}
.widget-msg-avatar {
width: 28px;
height: 28px;
border-radius: 999px;
background: color-mix(in oklab, var(--w-msg-ai-bubble, #FFFFFF) 80%, var(--ink) 20%);
color: var(--w-msg-ai-text, var(--ink));
display: inline-flex;
align-items: center;
justify-content: center;
font: 600 11px/1 var(--f-mono);
align-self: flex-end;
margin-bottom: 4px;
flex-shrink: 0;
}
.widget-msg-stack {
display: flex;
flex-direction: column;
gap: 2px;
max-width: 86%;
}
.widget-msg.is-from-user .widget-msg-stack { align-items: flex-end; }
.widget-msg-name {
font: 500 11.5px/1 var(--f-body);
color: var(--w-msg-ai-name, #2B2B30);
padding: 0 4px 2px;
}
.widget-msg-bubble {
position: relative;
padding: 10px 14px;
font: 400 14px/1.5 var(--f-body);
word-break: break-word;
}
.widget-msg.is-from-user .widget-msg-bubble {
color: var(--w-msg-user-text, #FFFFFF);
background: var(--w-msg-user-bubble, #18181B);
border: 1px solid var(--w-msg-user-border, transparent);
border-radius: var(--w-msg-user-radius, 8px);
}
.widget-msg.is-from-ai .widget-msg-bubble {
color: var(--w-msg-ai-text, #18181B);
background: var(--w-msg-ai-bubble, #FFFFFF);
border: 1px solid var(--w-msg-ai-border, var(--hair));
border-radius: var(--w-msg-ai-radius, 8px);
}
.widget-msg.is-failed.is-from-user .widget-msg-bubble,
.widget-msg.is-failed.is-from-ai .widget-msg-bubble {
box-shadow: 0 0 0 1.5px #C0392B;
}
.widget-msg-body { white-space: pre-wrap; }
.widget-msg-body > *:first-child { margin-top: 0; }
.widget-msg-body > *:last-child { margin-bottom: 0; }
.widget-msg-caret {
display: inline-block;
width: 2px;
height: 1em;
background: currentColor;
margin-left: 2px;
vertical-align: text-bottom;
animation: widget-msg-caret 1s steps(2, end) infinite;
}
@media (prefers-reduced-motion: reduce) {
.widget-msg-caret { animation: none; opacity: 0.6; }
}
.widget-msg-time {
font: 400 10.5px/1 var(--f-mono);
padding: 2px 4px 0;
font-variant-numeric: tabular-nums;
}
.widget-msg.is-from-user .widget-msg-time { color: var(--w-msg-user-time, var(--fg-soft)); }
.widget-msg.is-from-ai .widget-msg-time { color: var(--w-msg-ai-time, #2B2B30); }
/* …additional rules trimmed for brevity — see _shared.css */
import { WidgetMessage } from '@magicblocksai/ui';
<WidgetMessage
role="ai"
agentInitials="CW"
agentName="Charlie"
body="Hi there 👋"
timestamp="2:14 PM"
/>
<WidgetMessage role="user" body="Hi! Quick question." timestamp="2:14 PM" />
{/* Streaming */}
<WidgetMessage role="ai" agentInitials="CW" body="Looking that up…" streaming />
{/* Failed */}
<WidgetMessage role="user" body="Can I get a refund?" failed onRetry={handleRetry} />
25.6 Composer
Auto-growing textarea plus a customisable Send button. The Send label, icon, icon position, and all colours read from theme.send. The textarea grows up to maxRows then scrolls.
WidgetComposer
.widget-composerThree Send variants via modifier classes on .widget-composer-send: is-icon-before (icon left of label), is-icon-after (icon right), and is-icon-only (compact circle).
<form class="widget-composer">
<div class="widget-composer-row">
<textarea class="widget-composer-input" rows="1"
placeholder="Ask me anything…" aria-label="Message"></textarea>
<!-- is-icon-before | is-icon-after | is-icon-only -->
<button type="submit"
class="widget-composer-send is-icon-before" aria-label="Send">
<span class="widget-composer-send-icon"><!-- SVG --></span>
<span class="widget-composer-send-label">Send</span>
</button>
</div>
</form>
.widget-composer { display: flex; flex-direction: column; gap: 4px; }
.widget-composer.is-disabled { opacity: 0.55; pointer-events: none; }
.widget-composer-row {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 4px 4px 4px 10px;
background: var(--w-composer-bg, #FFFFFF);
border: 1px solid var(--w-composer-border, var(--hair));
border-radius: 12px;
transition: border-color var(--dur-2) var(--ease),
box-shadow var(--dur-2) var(--ease);
}
.widget-composer-row:focus-within {
border-color: var(--w-msg-user-bubble, var(--ink));
box-shadow: 0 0 0 3px color-mix(in oklab, var(--w-msg-user-bubble, var(--ink)) 20%, transparent);
}
.widget-composer-attachments {
display: inline-flex;
align-items: center;
gap: 2px;
}
.widget-composer-input {
flex: 1;
appearance: none;
background: transparent;
border: 0;
outline: none;
resize: none;
box-sizing: border-box;
padding: 8px 0;
font: 400 var(--w-composer-font-size, 16px) / 1.4 var(--w-font-family, var(--f-body));
color: var(--w-composer-text, var(--ink));
/* Auto-grow up to three lines, then scroll. */
field-sizing: content;
min-height: 40px;
max-height: 84px;
overflow-y: auto;
}
.widget-composer-input::placeholder { color: var(--w-composer-placeholder, #A1A1AA); }
.widget-composer-send {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
padding: 0 14px;
border: 1px solid var(--w-send-border, transparent);
border-radius: var(--w-send-radius, 8px);
background: var(--w-send-bg, #18181B);
color: var(--w-send-text, #FFFFFF);
font: 600 13px/1 var(--w-font-family, var(--f-body));
cursor: pointer;
transition: filter var(--dur-2) var(--ease);
flex-shrink: 0;
}
.widget-composer-send.is-icon-only { padding: 0 10px; }
.widget-composer-send.is-label-only { gap: 0; }
.widget-composer-send:hover:not(:disabled) { filter: brightness(0.9); }
.widget-composer-send:disabled { opacity: 0.5; cursor: not-allowed; }
.widget-composer-send-icon { display: inline-flex; }
.widget-composer-send-label { line-height: 1; }
.widget-composer-spinner {
width: 14px;
height: 14px;
border-radius: 999px;
border: 1.5px solid currentColor;
border-right-color: transparent;
animation: widget-composer-spin 0.7s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.widget-composer-spinner { animation: none; opacity: 0.5; }
}
import { WidgetComposer } from '@magicblocksai/ui';
<WidgetComposer
placeholder="Ask me anything…"
sendLabel="Send"
sendIconPosition="before" // 'before' | 'after' | 'only'
onSend={handleSend}
/>
25.7 Buttons
Three variants — primary, secondary, and suggestion. Each carries independently themable text colour, background, border, and radius. Use primary for CTAs inside bubbles, secondary for alternatives, and suggestion for quick-reply chips above the composer.
WidgetButton
.widget-buttonModifier classes: is-primary, is-secondary, is-suggestion. Add <span class="widget-button-spinner"></span> for a loading state.
<!-- Three variants -->
<button class="widget-button is-primary">Book a demo</button>
<button class="widget-button is-secondary">Not now</button>
<button class="widget-button is-suggestion">Hunter Valley</button>
<!-- Loading state -->
<button class="widget-button is-primary">
<span class="widget-button-spinner"></span>
</button>
<!-- Themed via CSS custom props on a parent -->
<div style="
--w-btn-primary-bg: #C69C6D;
--w-btn-primary-text: #FFFFFF;
--w-btn-primary-radius: 8px;">
<button class="widget-button is-primary">Add to cart</button>
</div>
.widget-button {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid transparent;
cursor: pointer;
font: 600 13px/1 var(--w-font-family, var(--f-body));
transition: filter var(--dur-2) var(--ease);
}
.widget-button:hover:not(:disabled) { filter: brightness(0.92); }
.widget-button:disabled { opacity: 0.5; cursor: not-allowed; }
.widget-button:focus-visible {
outline: 2px solid color-mix(in oklab, var(--accent) 40%, transparent);
outline-offset: 2px;
}
.widget-button.is-primary {
color: var(--w-btn-primary-text, #FFFFFF);
background: var(--w-btn-primary-bg, #18181B);
border-color: var(--w-btn-primary-border, transparent);
border-radius: var(--w-btn-primary-radius, 8px);
}
.widget-button.is-secondary {
color: var(--w-btn-secondary-text, #18181B);
background: var(--w-btn-secondary-bg, #FFFFFF);
border-color: var(--w-btn-secondary-border, var(--hair));
border-radius: var(--w-btn-secondary-radius, 8px);
}
.widget-button.is-suggestion {
color: var(--w-btn-suggest-text, #18181B);
background: var(--w-btn-suggest-bg, transparent);
border-color: var(--w-btn-suggest-border, var(--hair));
border-radius: var(--w-btn-suggest-radius, 999px);
padding: 6px 12px;
font-size: 12.5px;
}
.widget-button-icon { display: inline-flex; }
.widget-button-spinner {
width: 14px;
height: 14px;
border-radius: 999px;
border: 1.5px solid currentColor;
border-right-color: transparent;
animation: widget-composer-spin 0.7s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.widget-button { transition: none; }
}
import { WidgetButton } from '@magicblocksai/ui';
<WidgetButton variant="primary" onClick={handleCTA}>Book a demo</WidgetButton>
<WidgetButton variant="secondary" onClick={handleDismiss}>Not now</WidgetButton>
<WidgetButton variant="suggestion" onClick={() => prefill('Hunter Valley')}>
Hunter Valley
</WidgetButton>
{/* Loading state */}
<WidgetButton variant="primary" loading />
25.8 Feedback
Thumbs up / down pair rendered beneath AI messages. Active state colours the icon via --w-feedback-icon-active. Three states: idle, up-active, and down-active.
WidgetFeedback
.widget-feedbackEach button carries aria-pressed and aria-label. Add is-active to the pressed button and update aria-pressed="true".
<div class="widget-feedback" role="group" aria-label="Message feedback">
<button type="button" class="widget-feedback-btn is-up"
aria-pressed="false" aria-label="Helpful">
<!-- thumbs-up SVG -->
</button>
<button type="button" class="widget-feedback-btn is-down"
aria-pressed="false" aria-label="Not helpful">
<!-- thumbs-down SVG -->
</button>
</div>
<!-- Active state: add is-active + set aria-pressed="true" -->
.widget-feedback {
display: inline-flex;
gap: 2px;
padding: 2px;
background: var(--w-feedback-bg, #FFFFFF);
border-radius: 999px;
border: 1px solid color-mix(in oklab, #000 6%, transparent);
}
.widget-feedback.is-disabled { opacity: 0.55; pointer-events: none; }
.widget-feedback-btn {
appearance: none;
background: transparent;
border: 0;
width: 26px;
height: 26px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--w-feedback-icon, #18181B);
cursor: pointer;
transition: background var(--dur-2) var(--ease),
color var(--dur-2) var(--ease);
}
.widget-feedback-btn:hover {
background: color-mix(in oklab, var(--w-feedback-bg, #FFFFFF) 80%, var(--ink));
}
.widget-feedback-btn.is-active {
color: var(--w-feedback-icon-active, var(--accent));
background: color-mix(in oklab, var(--w-feedback-icon-active, var(--accent)) 14%, var(--w-feedback-bg, #FFFFFF));
}
import { WidgetFeedback } from '@magicblocksai/ui';
<WidgetFeedback
value={feedback} // null | 'up' | 'down'
onFeedback={setFeedback}
/>
25.9 Disclaimers
Two variants for two positions. The welcome disclaimer sits at the top of the chat scroll area. The legal disclaimer sits at the bottom, beneath the composer.
WidgetDisclaimer
.widget-disclaimerAdd is-welcome for the top banner (themed via --w-disclaimer-welcome-bg + --w-disclaimer-welcome-text). Add is-legal for the bottom note (themed via --w-disclaimer-legal-text + --w-disclaimer-legal-align).
<!-- Welcome variant -->
<div class="widget-disclaimer is-welcome" role="note"
style="--w-disclaimer-welcome-bg:#E4E4E7;--w-disclaimer-welcome-text:#2B2B30;">
You're chatting with an AI assistant.
</div>
<!-- Legal variant -->
<div class="widget-disclaimer is-legal" role="note"
style="--w-disclaimer-legal-text:#18181B;--w-disclaimer-legal-align:left;">
I'm an AI agent and may not be 100% accurate.
</div>
.widget-disclaimer { font: inherit; }
.widget-disclaimer.is-welcome {
padding: 10px 14px;
background: var(--w-disclaimer-welcome-bg, #E4E4E7);
color: var(--w-disclaimer-welcome-text, #2B2B30);
font: 400 12.5px/1.5 var(--f-body);
}
.widget-disclaimer.is-legal {
padding: 8px 14px;
font: 400 11px/1.4 var(--f-mono);
color: var(--w-disclaimer-legal-text, var(--fg-soft));
text-align: var(--w-disclaimer-legal-align, left);
}
import { WidgetDisclaimer } from '@magicblocksai/ui';
<WidgetDisclaimer
variant="welcome"
text="You're chatting with an AI assistant. It may make mistakes sometimes."
/>
<WidgetDisclaimer
variant="legal"
text="I'm an AI agent and may not be 100% accurate."
align="left"
/>
25.10 Branding marker
The "Powered by MagicBlocks" footer. Plan-gated by the consumer — render it on free plans, omit it on paid plans, or override the label entirely on enterprise.
WidgetBrandingMarker
.widget-branding-markerThree variants: linked (widget-branding-link wrapping the mark + text), static text (widget-branding-static), and omitted entirely when the consumer's plan suppresses it.
<!-- Linked (default — links to magicblocks.ai) -->
<div class="widget-branding-marker">
<a class="widget-branding-link" href="https://magicblocks.ai"
target="_blank" rel="noopener">
<span class="widget-branding-mark"><!-- mark SVG --></span>
<span class="widget-branding-text">Powered by MagicBlocks</span>
</a>
</div>
<!-- Static (non-linked) -->
<div class="widget-branding-marker">
<span class="widget-branding-static">
<span class="widget-branding-text">An AI experience by Acme Co.</span>
</span>
</div>
.widget-branding-marker {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.widget-branding-link,
.widget-branding-static {
display: inline-flex;
align-items: center;
gap: 6px;
text-decoration: none;
color: inherit;
font: 500 10.5px/1 var(--f-mono);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.widget-branding-link:hover { opacity: 0.7; }
.widget-branding-mark { display: inline-flex; }
.widget-branding-text { line-height: 1.2; }
import { WidgetBrandingMarker } from '@magicblocksai/ui';
{/* Default: shows "Powered by MagicBlocks" linked */}
<WidgetBrandingMarker />
{/* Enterprise: custom label and link */}
<WidgetBrandingMarker
label="Powered by Tyrrells AI"
href="https://tyrrells.com.au"
/>
{/* Static (no link) */}
<WidgetBrandingMarker
label="An AI experience by Acme Co."
static
/>
25.11 Brand themes
The same component tree, four different WidgetTheme configs. Every visual lever — bubble shape, colour, header chrome, launcher pill, AI-name display, timestamp format — driven by tokens. Total rebrandability.
Four widgets, four brands
.widget-theme-scopeWarm wines (earthy gold), tech SaaS (deep blue), vibrant retail (hot pink), and dark minimal (monochrome). Each uses the identical widget HTML; only the CSS custom-property set differs.
<!-- Warm brand -->
<div class="widget-theme-scope" style="
--w-shell-header-bg: #C69C6D;
--w-msg-user-bubble: #C69C6D;
--w-msg-user-radius: 8px;
--w-send-bg: #C69C6D;
/* … full token set */">
<div class="widget-shell">…</div>
</div>
<!-- Dark minimal brand -->
<div class="widget-theme-scope" style="
--w-shell-header-bg: #27272A;
--w-shell-chat-bg: #18181B;
--w-msg-user-bubble: #FAFAFA;
--w-msg-user-radius: 4px;
--w-send-bg: #FAFAFA;
/* … */">
<div class="widget-shell">…</div>
</div>
.widget-theme-scope {
--w-font-family: Inter, system-ui, sans-serif;
--w-font-weight: 400;
--w-font-size: 14px;
--w-line-height: 22px;
font-family: var(--w-font-family);
font-weight: var(--w-font-weight);
font-size: var(--w-font-size);
line-height: var(--w-line-height);
color-scheme: light;
}
import { WidgetThemeProvider, WidgetShell } from '@magicblocksai/ui';
const warmTheme = {
shell: { headerBg: '#C69C6D', headerText: '#FCFCFC', chatBg: '#FCFCFC' },
messages: { userBubble: '#C69C6D', userText: '#FFFFFF', userRadius: 8 },
send: { bg: '#C69C6D', text: '#FFFFFF', radius: 8 },
};
const darkTheme = {
shell: { headerBg: '#27272A', headerText: '#FAFAFA', chatBg: '#18181B' },
messages: { userBubble: '#FAFAFA', userText: '#18181B', userRadius: 4 },
send: { bg: '#FAFAFA', text: '#18181B', radius: 4 },
};
<WidgetThemeProvider theme={warmTheme}>
<WidgetShell agentName="Tyrrells" messages={messages} />
</WidgetThemeProvider>
25.12 Channels — Chat Appearances
The workspace-level catalog of chat-widget styling presets that any agent can re-use. Top sub-nav (Chat / Email / API) reflects the three workspace channel surfaces. Below: a master-detail with the appearances list on the left and a live widget preview on the right — styled with the chapter's actual widget primitives so what the operator sees here matches what the consumer's site renders.
Channels shell — Chat tab · 6 appearances · CREFCO Financial Group selected
.ch-screen · .ch-list · .ch-detail · .ch-preview-widgetEach appearance row reads its name + source URL + status (Used / Unused) at a glance. Click one and the right pane updates: the title and meta line shift to that appearance's details, the live widget preview re-renders with the chosen colours, font, and persona greeting. The bottom strip shows a 4-tile config glance (accent colour, position, font, persona name) so the operator can confirm at a glance without scrolling into the editor. Delete / Duplicate / Edit Widget actions sit in the head.
Chat Appearances · 6
Style your AI chat — design how messages, inputs, and branding show up. More on Chat Appearance →
CREFCO Financial Group Used by 2 agents
<div class="ch-screen">
<nav class="ch-subnav">
<a class="ch-subnav-tab is-active">Chat</a>
<a class="ch-subnav-tab">Email <span class="ch-soon">Soon</span></a>
<a class="ch-subnav-tab">API</a>
</nav>
<div class="ch-body">
<div class="ch-list">
<div class="ch-list-head">…Chat Appearances · 6…</div>
<div class="ch-list-toolbar">…search + Create…</div>
<div class="ch-list-status-tabs">
<button class="is-active">All <span class="ch-status-count">6</span></button>
<button>Used <span class="ch-status-count">5</span></button>
<button>Unused <span class="ch-status-count">1</span></button>
</div>
<div class="ch-appearances">
<article class="ch-appearance-row is-selected">
<span class="ch-appearance-icon">…palette…</span>
<div class="ch-appearance-body">
<div class="ch-appearance-name">CREFCO Financial Group</div>
<div class="ch-appearance-sub">Wizard-built from cfgohio.com</div>
</div>
<div class="ch-appearance-meta">
<span class="badge"><span class="dot dot-green"></span> Used</span>
<span class="ch-appearance-time">2d ago</span>
</div>
</article>
…more rows…
</div>
</div>
<div class="ch-detail">
<header class="ch-detail-head">…title + status + Delete/Duplicate/Edit Widget…</header>
<div class="ch-preview-frame">
<span class="ch-preview-label">Live preview</span>
<div class="ch-preview-widget">
<div class="ch-preview-widget-head">avatar + name + status</div>
<div class="ch-preview-widget-body">
<div class="ch-preview-bubble">Hi! I'm Charlie…</div>
<div class="ch-preview-replies">
<button class="ch-preview-reply">First-time buyer</button>
<button class="ch-preview-reply">Refinance</button>
<button class="ch-preview-reply">Just exploring</button>
</div>
</div>
<div class="ch-preview-widget-foot">input + send</div>
<div class="ch-preview-widget-brand">powered by MagicBlocks</div>
</div>
</div>
<div class="ch-config-glance">
<div class="ch-config-tile">Accent · Pink #FF3F7A</div>
<div class="ch-config-tile">Position · Bottom right</div>
<div class="ch-config-tile">Font · DM Sans</div>
<div class="ch-config-tile">Persona · Charlie</div>
</div>
</div>
</div>
</div>
/* Chapter-private channels shell. The preview widget is built with
chapter-local primitives that mirror the chapter 17 widget shape
(head + body + composer + brand strip); a production version would
compose the actual .widget-* primitives instead. */
.ch-screen { display: flex; flex-direction: column; }
.ch-subnav { display: flex; gap: var(--s-4); border-bottom: 1px solid var(--hair); padding: 0 var(--s-6); }
.ch-body { display: grid; grid-template-columns: 360px 1fr; flex: 1; }
.ch-list { border-right: 1px solid var(--hair); background: var(--bg-paper); }
.ch-detail { background: var(--bg-2); padding: var(--s-5); }
.ch-preview-frame {
background-image:
linear-gradient(135deg, color-mix(in oklab, var(--accent) 3%, transparent), transparent 60%),
repeating-linear-gradient(45deg, color-mix(in oklab, var(--ink) 2%, transparent) 0, color-mix(in oklab, var(--ink) 2%, transparent) 1px, transparent 1px, transparent 12px);
}
.ch-preview-widget-head { background: var(--accent); color: var(--paper); }
/* PROVISIONAL — composes kit primitives.
Existing kit exports used: Badge, Dot, Av. Future: WidgetShell from 17.4. */
export function ChannelsChatPage({ appearances, selectedId, onSelect }) {
const selected = appearances.find(a => a.id === selectedId);
return (
<div className="ch-screen">
<ChannelsSubnav active="chat" />
<div className="ch-body">
<ChatAppearancesList items={appearances} selectedId={selectedId} onSelect={onSelect} />
{selected && <ChatAppearanceDetail appearance={selected} />}
</div>
</div>
);
}