21.1 ColorField
The kit’s standard “pick a hex colour” field. Pairs a native <input type="color"> swatch with a hex text field so users can type, paste, or pick. Optional presets row beneath gives quick access to the brand palette. Used dozens of times across the <WidgetStyleEditor> form pane and any other “design your X” surface.
ColorField
.color-fieldThree side-by-side fields demonstrating the common shapes — default empty (black swatch, placeholder hex), filled with a brand colour and a presets row, and a captioned variant labelled for an icon foreground. The hex input is mono, tabular-numeric, and uppercased; the leading swatch is a 32×32 cell that opens the native picker.
<!-- .color-field wraps the label row, the swatch + hex input row, -->
<!-- and the optional .color-field-presets quick-pick group. The -->
<!-- swatch <label> carries a real <input type="color"> underneath. -->
<div class="color-field">
<div class="color-field-head">
<div class="color-field-head-text">
<label class="color-field-label">Widget background</label>
<span class="color-field-caption">Launcher + chat header.</span>
</div>
</div>
<div class="color-field-row">
<label class="color-field-swatch" aria-label="Pick a colour">
<input type="color" class="color-field-swatch-input" value="#C69C6D" aria-hidden="true" tabindex="-1">
<span class="color-field-swatch-preview" style="background: #C69C6D;"></span>
</label>
<input type="text" class="color-field-input" value="#C69C6D" spellcheck="false">
</div>
<div class="color-field-presets" role="group" aria-label="Preset colours">
<button class="color-field-preset is-on" style="background: #C69C6D;"></button>
<!-- …more presets… -->
</div>
</div>
.color-field { display: flex; flex-direction: column; gap: 6px; }
.color-field.is-disabled { opacity: 0.55; pointer-events: none; }
.color-field-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--s-2);
}
.color-field-head-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.color-field-label { font: 500 13px/1.3 var(--f-body); color: var(--fg); }
.color-field-caption { font: 400 12px/1.4 var(--f-body); color: var(--fg-soft); }
.color-field-meta { display: inline-flex; align-items: center; gap: 4px; }
.color-field-row {
display: grid;
grid-template-columns: 32px 1fr;
gap: 6px;
align-items: center;
}
.color-field-swatch {
position: relative;
width: 32px;
height: 32px;
border-radius: var(--r-sm);
border: 1px solid var(--hair);
overflow: hidden;
cursor: pointer;
display: block;
}
.color-field-swatch-input {
position: absolute;
inset: 0;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.color-field-swatch-preview {
position: absolute;
inset: 0;
}
.color-field-input {
height: 32px;
padding: 0 var(--s-2);
border: 1px solid var(--hair);
border-radius: var(--r-sm);
background: var(--bg-paper);
color: var(--fg);
font: 400 12.5px/1 var(--f-mono);
font-variant-numeric: tabular-nums;
text-transform: uppercase;
}
.color-field-input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.color-field-input[aria-invalid="true"] {
border-color: var(--error-text, #C0392B);
}
.color-field-presets {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.color-field-preset {
appearance: none;
width: 18px;
height: 18px;
border-radius: 999px;
border: 1.5px solid var(--hair);
cursor: pointer;
transition: transform var(--dur-2) var(--ease);
}
.color-field-preset:hover { transform: scale(1.1); }
.color-field-preset.is-on {
border-color: var(--ink);
box-shadow: 0 0 0 2px var(--bg-paper) inset;
}
@media (prefers-reduced-motion: reduce) {
.color-field-preset { transition: none; }
.color-field-preset:hover { transform: none; }
}
import { useState } from 'react';
import { ColorField } from '@magicblocksai/ui';
function Example() {
const [color, setColor] = useState('#C69C6D');
return (
<ColorField
label="Widget background"
caption="Launcher + chat header."
value={color}
onValueChange={setColor}
presets={['#C69C6D', '#18181B', '#FFFFFF', '#EC4899', '#2563EB']}
/>
);
}
// Uncontrolled: defaultValue + onValueChange, kit handles state.
<ColorField
label="Icon colour"
defaultValue="#FFFFFF"
onValueChange={(hex) => console.log(hex)}
/>
21.2 ColorSwatchPicker
The named-preset row beneath the ColorField. A horizontal grid of labelled discs — one per scheme — with the selected swatch carrying a ring + scale. The kit ships WIDGET_SCHEMES (Green / Yellow / Pink / Blue / Navy / Orange / Black / White) as a sensible default starting palette. Operators pick a scheme to flood the rest of the form with sensible colours, then fine-tune individual fields through the <ColorField> rows below.
ColorSwatchPicker
.color-swatch-pickerThe full eight-disc WIDGET_SCHEMES grid with the “Orange” swatch selected. Hover lifts the disc on the background; the active state rings the disc with the ink colour and scales it up slightly. Two-tone diagonal-split discs are also supported (see the second variant) for custom paired colour schemes.
<!-- .color-swatch-picker is a flex-wrap row of .color-swatch buttons. -->
<!-- Each swatch is a column of .color-swatch-disc + .color-swatch- -->
<!-- label. The is-on modifier rings + scales the active disc. Two- -->
<!-- tone discs use a 135deg linear-gradient between two colours. -->
<div class="color-swatch-picker" role="radiogroup">
<button class="color-swatch is-on" role="radio" aria-checked="true" title="Orange">
<span class="color-swatch-disc" style="background: #EA580C;"></span>
<span class="color-swatch-label">Orange</span>
</button>
<!-- …more swatches… -->
</div>
<!-- Two-tone swatch (accent = second colour): -->
<button class="color-swatch" role="radio">
<span class="color-swatch-disc"
style="background: linear-gradient(135deg, #C69C6D 50%, #FFE4C4 50%);"></span>
<span class="color-swatch-label">Warm Glow</span>
</button>
.color-swatch-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.color-swatch-picker.is-disabled { opacity: 0.55; pointer-events: none; }
.color-swatch {
appearance: none;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: transparent;
border: 0;
padding: 4px;
cursor: pointer;
border-radius: var(--r-sm);
transition: background var(--dur-2) var(--ease);
}
.color-swatch:hover { background: var(--bg-warm); }
.color-swatch:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.color-swatch.is-disabled { opacity: 0.5; cursor: not-allowed; }
.color-swatch-disc {
width: 32px;
height: 32px;
border-radius: 999px;
border: 1.5px solid var(--hair);
display: block;
position: relative;
transition: transform var(--dur-2) var(--ease);
}
.color-swatch.is-on .color-swatch-disc {
border-color: var(--ink);
transform: scale(1.05);
box-shadow: 0 0 0 2px var(--bg-paper) inset;
}
@media (prefers-reduced-motion: reduce) {
.color-swatch-disc { transition: none; }
.color-swatch.is-on .color-swatch-disc { transform: none; }
}
.color-swatch-label {
font: 500 11px/1 var(--f-body);
color: var(--fg);
}
import { useState } from 'react';
import { ColorSwatchPicker, WIDGET_SCHEMES } from '@magicblocksai/ui';
import type { ColorSwatch } from '@magicblocksai/ui';
function Example() {
const [scheme, setScheme] = useState<string | null>('orange');
return (
<ColorSwatchPicker
swatches={WIDGET_SCHEMES}
value={scheme}
onValueChange={setScheme}
/>
);
}
// Custom two-tone schemes: pass accent for the diagonal-split disc.
const twoTone: ColorSwatch[] = [
{ id: 'warm-glow', label: 'Warm Glow', color: '#C69C6D', accent: '#FFE4C4' },
{ id: 'deep-ocean', label: 'Deep Ocean', color: '#0F172A', accent: '#2563EB' },
];
<ColorSwatchPicker swatches={twoTone} defaultValue="warm-glow" />
21.3 WidgetStyleSection
A single section of the editor’s form pane. Carries a title, an optional caption, and a vertical stack of field children — typically <ColorField> rows. Provides consistent heading typography and divider spacing without forcing a layout; consumers slot in any field combination. Sections are stacked top-to-bottom inside the <WidgetStyleEditor>’s form pane, one per logical parameter family (Widget styling, Chat messages, Send style, Fonts, Buttons).
WidgetStyleSection
.widget-style-sectionTwo side-by-side variants — a collapsed section with just the header and a meta chip (the rest of the body hidden), and an expanded section showing two <ColorField> rows. The hairline divider runs along the bottom edge so sections stack cleanly in the form pane above and below.
<!-- .widget-style-section wraps a .widget-style-section-head (title + -->
<!-- caption + optional meta slot) and a .widget-style-section-body -->
<!-- (the field stack). The hairline divider runs along the bottom -->
<!-- edge; the last section in a stack drops its divider. -->
<section class="widget-style-section">
<header class="widget-style-section-head">
<div class="widget-style-section-head-text">
<h3 class="widget-style-section-title">Header</h3>
<p class="widget-style-section-caption">
The bar across the top of the chat shell.
</p>
</div>
</header>
<div class="widget-style-section-body">
<!-- field children: typically <div class="color-field">… rows -->
</div>
</section>
.btn-ghost {
background: transparent;
color: var(--fg);
border-color: transparent;
}
.btn-ghost:hover {
background: var(--bg-sunk);
border-color: var(--hair);
}
.unsaved-changes-bar .btn-ghost { color: var(--paper); }
.unsaved-changes-bar .btn-ghost:hover { background: color-mix(in oklab, var(--paper) 12%, transparent); }
.color-field { display: flex; flex-direction: column; gap: 6px; }
.color-field.is-disabled { opacity: 0.55; pointer-events: none; }
.color-field-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--s-2);
}
.color-field-head-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.color-field-label { font: 500 13px/1.3 var(--f-body); color: var(--fg); }
.color-field-caption { font: 400 12px/1.4 var(--f-body); color: var(--fg-soft); }
.color-field-meta { display: inline-flex; align-items: center; gap: 4px; }
.color-field-row {
display: grid;
grid-template-columns: 32px 1fr;
gap: 6px;
align-items: center;
}
.color-field-swatch {
position: relative;
width: 32px;
height: 32px;
border-radius: var(--r-sm);
border: 1px solid var(--hair);
overflow: hidden;
cursor: pointer;
display: block;
}
.color-field-swatch-input {
position: absolute;
inset: 0;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.color-field-swatch-preview {
position: absolute;
inset: 0;
}
.color-field-input {
height: 32px;
padding: 0 var(--s-2);
border: 1px solid var(--hair);
border-radius: var(--r-sm);
background: var(--bg-paper);
color: var(--fg);
font: 400 12.5px/1 var(--f-mono);
font-variant-numeric: tabular-nums;
text-transform: uppercase;
}
.color-field-input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.color-field-input[aria-invalid="true"] {
border-color: var(--error-text, #C0392B);
}
.color-field-presets {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.color-field-preset {
appearance: none;
width: 18px;
height: 18px;
border-radius: 999px;
border: 1.5px solid var(--hair);
cursor: pointer;
transition: transform var(--dur-2) var(--ease);
}
.color-field-preset:hover { transform: scale(1.1); }
.color-field-preset.is-on {
border-color: var(--ink);
box-shadow: 0 0 0 2px var(--bg-paper) inset;
}
/* …additional rules trimmed for brevity — see _shared.css */
import {
WidgetStyleSection,
ColorField,
} from '@magicblocksai/ui';
<WidgetStyleSection
title="Header"
caption="The bar across the top of the chat shell."
>
<ColorField label="Header background" defaultValue="#C69C6D" />
<ColorField label="Header text" defaultValue="#FCFCFC" />
</WidgetStyleSection>
// With meta slot — any ReactNode (count badge, status pill, action button).
<WidgetStyleSection
title="Chat messages"
caption="Bubble colour, text colour, and spacing."
meta={<button aria-label="Expand">+</button>}
>
{/* …fields */}
</WidgetStyleSection>
21.4 WidgetStyleEditor
The flagship designer surface. A split-pane shell composing the form pane on the left and a live-preview <WidgetShell> on the right. The form pane stacks <WidgetStyleSection> blocks; the preview pane reflects every keystroke through <WidgetThemeProvider>. The header slot carries the title and save controls; the optional sidebar slot adds a section TOC. Consumer owns the form fields and the preview content — the editor is chrome only, so reorganisation, validation, autosave, and version history live outside the kit.
WidgetStyleEditor
.widget-style-editorThe full split-pane shell — header strip with the appearance title and a Save control, form pane on the left stacking three <WidgetStyleSection> blocks (Header, Launcher, Composer) with their <ColorField> children, and a preview pane on the right rendering a themed <WidgetShell> in flow. Every form change drives the preview without prop-drilling, via the parent <WidgetThemeProvider>’s scoped CSS custom properties.
<!-- .widget-style-editor is a column of header + body. The body is a -->
<!-- two-column grid (form-pane + preview-pane) when no sidebar is set, -->
<!-- three columns when .has-sidebar is added. The form pane stacks -->
<!-- .widget-style-section blocks; the preview pane is a stage. -->
<div class="widget-style-editor">
<div class="widget-style-editor-header">
<h2>Charlie’s Wines · Chat Appearance</h2>
<button class="btn btn-primary btn-sm">Save</button>
</div>
<div class="widget-style-editor-body">
<div class="widget-style-editor-form" role="form">
<section class="widget-style-section">…</section>
<!-- …more sections… -->
</div>
<div class="widget-style-editor-preview">
<div class="widget-style-editor-preview-head">
<span class="widget-style-editor-preview-label">Live preview</span>
</div>
<div class="widget-style-editor-preview-stage">
<!-- <WidgetThemeProvider> + <WidgetShell> (or other widget runtime) -->
</div>
</div>
</div>
</div>
.btn-primary {
/* `color: var(--on-accent)` (added in v1.19.0) is the canonical
"text on --accent" token. Falls back to `var(--paper)` for any
consumer on an older @magicblocksai/css that pre-dates the
`--on-accent` token. */
background: var(--accent); color: var(--on-accent, var(--paper));
box-shadow: var(--sh-pink);
}
.btn-primary:hover { transform: translateY(-1px); filter: brightness(1.04); }
.btn-primary:active { transform: translateY(0); filter: brightness(0.96); }
.btn-sm { padding: 7px var(--s-4); font-size: 13px; border-radius: var(--r-sm); }
.section-card-action .btn-primary { box-shadow: none; }
@media (pointer: coarse) {
/* Compact buttons */
.btn-sm,
.btn-icon-sm { min-height: 44px; }
/* Modal / drawer close (×) */
.modal-close,
.drawer-close {
min-width: 44px;
min-height: 44px;
}
/* Section card action — usually a text link "All todos →".
Pad the click target out without growing the visual element by
pulling negative margin on the same axes. */
.section-card-action {
padding: 10px;
margin: -10px -10px -10px 10px; /* top/right/bottom -10, leave left auto-margin */
}
/* Section card count chip — 18px tall on desktop, expand the
click target via padding+margin trick. */
.section-card-count {
min-height: 28px;
padding-top: 6px;
padding-bottom: 6px;
margin-top: -6px;
margin-bottom: -6px;
}
/* Dropdown menu items — already close to 44px in `comfortable`
density; nudge dense menus to the floor. */
.dropdown-menu-item,
.menu-item {
min-height: 44px;
}
/* Inbox row action buttons — visible, but small (28×28). Touch
users get a 36×36 chevron with the same icon + the same row
hover behaviour. */
.inbox-row .ix-action {
min-width: 36px;
min-height: 36px;
}
/* Tab buttons in chapter-style demos — already largeish but
ensure none drop below 44px. */
.demo-tabs button { min-height: 44px; }
}
.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;
}
.color-field { display: flex; flex-direction: column; gap: 6px; }
/* …additional rules trimmed for brevity — see _shared.css */
import {
WidgetStyleEditor,
WidgetStyleSection,
ColorField,
WidgetThemeProvider,
WidgetShell,
WidgetLauncher,
} from '@magicblocksai/ui';
import type { WidgetTheme } from '@magicblocksai/ui';
import { useState } from 'react';
function Example() {
const [theme, setTheme] = useState<WidgetTheme>({
launcher: { bg: '#C69C6D', icon: '#FFFFFF' },
shell: { headerBg: '#C69C6D', headerText: '#FCFCFC', chatBg: '#FCFCFC' },
composer: { sendBg: '#C69C6D', sendText: '#FFFFFF' },
});
const set = (path: string, hex: string) =>
setTheme((t) => /* immutably merge by path */ ({ ...t }));
return (
<WidgetStyleEditor
header={
<>
<h2>Charlie’s Wines · Chat Appearance</h2>
<button className="btn btn-primary btn-sm">Save</button>
</>
}
form={
<>
<WidgetStyleSection title="Header" caption="The bar across the top.">
<ColorField label="Background" value={theme.shell?.headerBg}
onValueChange={(hex) => set('shell.headerBg', hex)} />
<ColorField label="Text" value={theme.shell?.headerText}
onValueChange={(hex) => set('shell.headerText', hex)} />
</WidgetStyleSection>
<WidgetStyleSection title="Launcher" caption="The floating bubble.">
<ColorField label="Launcher background" value={theme.launcher?.bg}
onValueChange={(hex) => set('launcher.bg', hex)} />
<ColorField label="Launcher icon" value={theme.launcher?.icon}
onValueChange={(hex) => set('launcher.icon', hex)} />
</WidgetStyleSection>
<WidgetStyleSection title="Composer" caption="The send box.">
<ColorField label="Send button" value={theme.composer?.sendBg}
onValueChange={(hex) => set('composer.sendBg', hex)} />
</WidgetStyleSection>
</>
}
preview={
<WidgetThemeProvider theme={theme}>
<WidgetShell floating={false} agentName="Charlie’s Wines" />
</WidgetThemeProvider>
}
/>
);
}
21.5 WidgetEmbedSnippet
The output of the designer surface — a copy-ready <script> snippet operators paste into their site to install the widget. Tab strip switches between five framework targets (HTML, React, Next.js App Router, Vue, WordPress) — same widget id, different wrapper. Built-in Copy button writes the active snippet to the clipboard. Pin to a specific appearance with appearanceId so the widget reads from that published theme.
WidgetEmbedSnippet
.widget-embedTwo side-by-side variants — the default HTML target with just a widget id, and the same widget id pinned to a published appearanceId. Both show the optional title + caption header, the framework target tabs, and the snippet block with the copy affordance pinned to the top-right corner.
<!-- .widget-embed wraps a head (title + caption), a tabs strip -->
<!-- (one .widget-embed-tab per target), and the body (the snippet -->
<!-- <pre> + the absolutely-positioned copy affordance). -->
<div class="widget-embed">
<div class="widget-embed-head">
<div class="widget-embed-title">Install on your site</div>
<div class="widget-embed-caption">Paste into <head> or footer.</div>
</div>
<div class="widget-embed-tabs" role="tablist" aria-label="Embed target">
<button class="widget-embed-tab is-on" role="tab" aria-selected="true">HTML</button>
<button class="widget-embed-tab" role="tab" aria-selected="false">React</button>
<!-- …more targets… -->
</div>
<div class="widget-embed-body">
<pre class="widget-embed-pre"><code><!-- snippet --></code></pre>
<button class="widget-embed-copy" aria-label="Copy embed snippet">Copy</button>
</div>
</div>
.widget-embed {
display: flex;
flex-direction: column;
gap: var(--s-3);
border: 1px solid var(--hair);
border-radius: var(--r-md);
background: var(--bg-paper);
overflow: hidden;
}
.widget-embed-head {
padding: var(--s-3) var(--s-4);
border-bottom: 1px solid var(--hair-soft);
display: flex;
flex-direction: column;
gap: 2px;
}
.widget-embed-title { font: 500 14px/1.3 var(--f-body); color: var(--fg); }
.widget-embed-caption { font: 400 12.5px/1.4 var(--f-body); color: var(--fg-soft); }
.widget-embed-tabs {
display: flex;
gap: 0;
padding: 0 var(--s-4);
border-bottom: 1px solid var(--hair-soft);
}
.widget-embed-tab {
appearance: none;
background: transparent;
border: 0;
padding: 8px 12px;
font: 500 12.5px/1 var(--f-body);
color: var(--fg-soft);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color var(--dur-2) var(--ease),
border-color var(--dur-2) var(--ease);
}
.widget-embed-tab:hover { color: var(--fg); }
.widget-embed-tab.is-on {
color: var(--fg);
border-bottom-color: var(--accent);
}
.widget-embed-body {
position: relative;
}
.widget-embed-pre {
margin: 0;
padding: var(--s-3) var(--s-4);
font: 400 12px/1.55 var(--f-mono);
color: var(--fg);
white-space: pre;
overflow-x: auto;
background: color-mix(in oklab, var(--bg-warm) 30%, var(--bg-paper));
}
.widget-embed-copy {
position: absolute;
top: 6px;
right: 6px;
appearance: none;
background: var(--bg-paper);
border: 1px solid var(--hair);
border-radius: var(--r-xs);
padding: 3px 8px;
font: 500 11.5px/1 var(--f-body);
color: var(--fg);
cursor: pointer;
transition: background var(--dur-2) var(--ease);
}
.widget-embed-copy:hover { background: var(--bg-warm); }
import { WidgetEmbedSnippet } from '@magicblocksai/ui';
// Default — all five framework targets visible, HTML active.
<WidgetEmbedSnippet
widgetId="wid_01H9XY"
title="Install on your site"
caption="Paste this into your site's <head> or footer."
/>
// Pinned to a specific published appearance.
<WidgetEmbedSnippet
widgetId="wid_01H9XY"
appearanceId="ap_42"
title="Charlie's Wines widget"
caption="Pinned to appearance ap_42 (Warm Glow)."
/>
// Restrict the visible targets — e.g. only HTML + React.
<WidgetEmbedSnippet
widgetId="wid_01H9XY"
targets={['html', 'react']}
defaultTarget="react"
/>