Exclusivobeta
Tone Dial <mv-tone-dial>
Configurações de personalidade e de tom com uma prévia imediata e concreta, para assistentes de IA, chatbots, textos gerados, tom de notificações ou de e-mails, voz da marca e respostas prontas de suporte. O app declara traços (Formality, Warmth, Humor, Detail, Directness, Emoji use por padrão), cada um ajustado de 0 a 100% em um range nativo desenhado como segmentos de zona (“Terse · Balanced · Thorough”) com a zona ativa em destaque, um campo numérico (digite 75), dígitos digitados direto em um slider em foco, Shift+setas de 10 em 10, Page Up/Down até a próxima marca, encaixe magnético nas marcas durante o arraste, um entalhe mostrando cada valor padrão e um reset por traço. A parte principal é a amostra ao vivo: a função preview(settings) do app (síncrona, ou assíncrona com debounce, sinal de cancelamento e estados de carregamento e de erro) a reescreve a cada movimento, e as palavras que mudaram desde o último ajuste são destacadas no lugar (ou mostradas como inserções e remoções com diff="inline"), com uma linha de status como “Humor 20% → 72% · +18 −4 words”. Combinações incompatíveis geram notas gentis (muito humor com muita formalidade) que sinalizam os traços envolvidos. Os presets são chips de alternância (o que corresponde aos ajustes fica pressionado, um alterado mostra “edited”), os presets do usuário podem ser salvos, apagados e persistidos, e trocar de preset faz cada slider deslizar até o novo valor. As visualizações Sample, Prompt (um trecho no estilo de system prompt montado a partir de orientações por zona) e JSON podem ser copiadas para a área de transferência por um mv-export cancelável. Integrado a formulários (JSON ou texto de prompt), com mv-input, mv-change, mv-preview, mv-hint e um mv-preset cancelável.
| Categoria | Formulários |
|---|---|
| Tipo | Web Component (<mv-tone-dial>) |
| Status | beta |
| Kit | IA que você pode verificar |
| Também instala | button |
| Keywords | exclusive, culture, form, tone, voice, personality, brand-voice, ai, llm, chatbot, system-prompt, copywriting, preview, diff, presets, slider, range, settings, notifications, export, json |
When to use
- Users configure the voice of an AI assistant or chatbot and must see how a real reply changes before saving
- A marketing or support team tunes brand voice or macro tone and wants a copyable system prompt or JSON config
- Notification or email copy is generated from a few tone parameters and the effect of each one must be obvious
- Several related 0-100% preferences need named ranges, presets and warnings about combinations that clash
Avoid when
- There is a single numeric value to pick with no preview to show → use Slider instead
- The page groups unrelated account preferences (profile, notifications, security) → use Settings instead
- Whole variants must be compared side by side on computed outputs rather than tuned one sample at a time → use Fork instead
Instalação
node scripts/add.mjs tone-dial --out ./src/marvelousAgente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["tone-dial"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Arquivos copiados (dependências incluídas): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/form.js, core/motion.js, components/tone-dial/tone-dial.js, components/tone-dial/tone-dial.css, components/button/button.css.
Uso
Início rápido, a menor marcação que funciona:
<mv-tone-dial name="voice" label="Assistant voice"></mv-tone-dial>Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis CSS:
<div id="td-demo" style="width:min(100%,72rem);margin-inline:auto">
<style>
#td-demo { display:grid; gap:1.5rem; align-content:start }
#td-demo .td-msg { display:grid; grid-template-columns:auto minmax(0,1fr); gap:.125rem .625rem; align-items:start; padding:.75rem .875rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-muted); font-size:.8125rem; line-height:1.5 }
#td-demo .td-av { grid-row:span 2; display:grid; place-items:center; width:1.875rem; height:1.875rem; border-radius:50%; background:var(--mv-bg-emphasis); color:var(--mv-fg); font-size:.6875rem; font-weight:650 }
#td-demo .td-who { display:flex; gap:.5rem; align-items:baseline; flex-wrap:wrap }
#td-demo .td-who b { font-weight:600 }
#td-demo .td-who span { color:var(--mv-fg-muted); font-size:.75rem }
#td-demo .td-msg p { margin:0; color:var(--mv-fg) }
#td-demo .td-bar { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; margin-top:.875rem }
#td-demo .td-out { flex:1 1 20rem; min-width:0; margin:0; color:var(--mv-fg-muted); font:.75rem/1.5 var(--mv-font-mono); overflow-wrap:anywhere }
#td-demo .td-out b { color:var(--mv-fg); font-weight:600 }
#td-demo .td-actions { display:flex; gap:.5rem; flex-wrap:wrap }
#td-demo .td-caption { margin:0 0 .625rem; color:var(--mv-fg-muted); font-size:.8125rem }
#td-demo .td-caption b { color:var(--mv-fg); font-weight:600 }
</style>
<!-- 1 · AI support assistant: sync preview from a deterministic phrase-bank generator -->
<form id="td-form">
<mv-tone-dial id="td-main" name="voice" label="Support assistant voice"
description="How the AI assistant answers customers on email and chat. Every change rewrites the sample reply.">
<div slot="context" class="td-msg">
<span class="td-av" aria-hidden="true">PS</span>
<span class="td-who"><b>Priya Shah</b><span>Order #48213 · 9:41 AM</span></span>
<p>Hi, my order was supposed to arrive Monday and it still hasn’t. What’s going on?</p>
</div>
<script type="application/json">
{ "presets": [
{ "id": "support", "label": "Support agent", "description": "The product defaults", "settings": {} },
{ "id": "enterprise", "label": "Enterprise account", "settings": { "formality": 85, "warmth": 45, "humor": 0, "detail": 75, "directness": 75, "emoji": 0 } },
{ "id": "community", "label": "Community forum", "settings": { "formality": 15, "warmth": 80, "humor": 70, "detail": 40, "directness": 50, "emoji": 70 } },
{ "id": "incident", "label": "Incident update", "settings": { "formality": 60, "warmth": 30, "humor": 0, "detail": 90, "directness": 90, "emoji": 0 } }
] }
</script>
</mv-tone-dial>
<div class="td-bar">
<p class="td-out" id="td-out" aria-live="polite">Submitted value appears here.</p>
<div class="td-actions">
<button class="mv-button" data-variant="outline" type="reset">Discard changes</button>
<button class="mv-button" type="submit">Save voice</button>
</div>
</div>
</form>
<!-- 2 · Push notification tone: async preview (simulated 450 ms server call) with its own traits and a conflict note -->
<section>
<p class="td-caption"><b>Async preview</b> · the notification copy comes from a (simulated) server call, debounced while you drag.</p>
<mv-tone-dial id="td-push" label="Failed payment reminder" description="Push notification sent when a Lumen Pro renewal can’t be charged." debounce="350"
value='{"urgency":75,"warmth":72,"length":45}'>
<script type="application/json">
{
"traits": [
{ "id": "urgency", "label": "Urgency", "value": 45, "zones": [
{ "label": "Calm", "prompt": "Reassure; the issue fixes itself if ignored for a while." },
{ "label": "Clear", "prompt": "State the problem and the one action plainly." },
{ "label": "Urgent", "prompt": "Lead with the deadline and the consequence." } ] },
{ "id": "warmth", "label": "Warmth", "value": 50, "zones": [
{ "label": "Neutral", "prompt": "Plain and factual." },
{ "label": "Kind", "prompt": "Polite, one friendly touch." },
{ "label": "Caring", "prompt": "Apologize for the hassle and offer help." } ] },
{ "id": "length", "label": "Length", "value": 40, "marks": [0, 33, 50, 67, 100], "zones": [
{ "label": "Short", "prompt": "One line." },
{ "label": "Standard", "prompt": "Two short sentences." },
{ "label": "Detailed", "prompt": "Amount, card, retry date and consequence." } ] }
],
"presets": [
{ "id": "gentle", "label": "Gentle reminder", "settings": { "urgency": 20, "warmth": 70, "length": 50 } },
{ "id": "final", "label": "Final notice", "settings": { "urgency": 90, "warmth": 20, "length": 80 } }
],
"hints": [
{ "when": { "urgency": ">=67", "warmth": ">=67" }, "message": "Very urgent and very warm at once can read as alarmed. Let one of them lead." }
]
}
</script>
</mv-tone-dial>
</section>
<script type="module">
await customElements.whenDefined("mv-tone-dial");
const main = document.getElementById("td-main");
const push = document.getElementById("td-push");
const zone = (v) => (v < 34 ? 0 : v < 67 ? 1 : 2);
const pick = (i, list) => list[i];
// Formal voice: no contractions.
const EXPAND = { "I'm": "I am", "I've": "I have", "I'd": "I would", "it's": "it is", "It's": "It is", "didn't": "did not", "hasn't": "has not", "doesn't": "does not", "we'll": "we will", "can't": "cannot", "Can't": "Cannot", "there's": "there is", "that's": "that is", "couldn't": "could not", "We'll": "We will", "We're": "We are" };
const formal = (t) => t.replace(/\b[A-Za-z]+'[a-z]+\b/g, (w) => EXPAND[w] ?? w);
// 1 · Support reply generator: phrase banks per trait zone, no AI involved.
function supportReply(s) {
const F = zone(s.formality), W = zone(s.warmth), H = zone(s.humor), D = zone(s.detail), R = zone(s.directness), E = zone(s.emoji);
const greet = (F === 2 ? (W === 2 ? "Dear Priya," : "Dear Ms. Shah,") : F === 1 ? "Hi Priya," : W ? "Hey Priya!" : "Hey Priya,") + (E ? " 👋" : "");
const ack = pick(W, [
"",
F === 2 ? "Thank you for contacting us about order #48213." : "Thanks for reaching out about order #48213.",
F === 2 ? "I sincerely apologize that order #48213 did not arrive on Monday as promised." : F === 1 ? "I'm really sorry your order #48213 didn't show up on Monday. I know you were counting on it." : "So sorry your order #48213 didn't make it Monday, that's frustrating!",
]);
const it = W ? "it" : "your order #48213";
const answer = pick(R, [
`From what I can see, ${it} may now arrive around Friday, September 25.`,
W ? "The good news: it's on its way and should arrive Friday, September 25." : "Your order #48213 is on its way and should arrive Friday, September 25.",
W ? "It will arrive Friday, September 25." : "Your order #48213 will arrive Friday, September 25.",
]) + (E === 2 ? " 🚚" : "");
const detail = [
D >= 1 ? (F === 2 ? "The parcel was held at the carrier's Memphis hub following last weekend's storm." : "It was held at the carrier's Memphis hub after last weekend's storm.") : "",
D === 2 ? "It left the hub this morning; you can follow it with tracking number 1Z 84F 2E0 36 8120 4471. If it hasn't arrived by Saturday, reply to this email and we'll ship a replacement at no cost." : "",
];
const joke = pick(H, [
"",
"Even parcels need a rain delay now and then.",
F === 2 ? "It appears the parcel took an unscheduled layover in Memphis to wait out the weather." : `Apparently your parcel decided to wait out the storm in Memphis. Can't blame it, but it's back on the road now.${E ? " 📦" : ""}`,
]);
const offer = W === 2 || D === 2 ? pick(R, [
"If it helps, I'd be happy to refund the $7.95 shipping fee.",
"I've gone ahead and refunded the $7.95 shipping fee.",
"I've refunded the $7.95 shipping fee to your card.",
]) : "";
const close = (W === 0
? (F === 2 ? "Please contact us if the parcel does not arrive." : "Let us know if it doesn't arrive.")
: pick(F, ["Anything else, just shout!", "Let me know if there's anything else I can do.", "Please do not hesitate to contact us should you need anything further."])) + (E === 2 ? " 🙌" : "");
const sign = pick(F, [`Cheers,\nAlex${E === 2 ? " 🙂" : ""}`, "Best,\nAlex", "Kind regards,\nAlex Moreau\nCustomer Care, Juniper & Co."]);
const join = (...parts) => parts.flat().filter(Boolean).join(" ");
const text = [greet, join(ack, answer, detail), joke, join(offer, close), sign].filter(Boolean).join("\n\n");
return F === 2 ? formal(text) : text;
}
main.preview = (settings) => supportReply(settings);
// Show the mechanic right away: the assistant was just made more playful.
main.set("humor", 72, { emit: false });
// Form association: the submitted value is the settings JSON.
const form = document.getElementById("td-form");
const out = document.getElementById("td-out");
form.addEventListener("submit", (e) => {
e.preventDefault();
const v = new FormData(form).get("voice");
out.replaceChildren(Object.assign(document.createElement("b"), { textContent: "Saved voice = " }), v);
});
form.addEventListener("reset", () => { out.textContent = "Changes discarded."; });
// 2 · Async generator: resolves after a fake round trip, honors the abort signal.
function notification(s) {
const U = zone(s.urgency), W = zone(s.warmth), L = zone(s.length);
const title = pick(U, ["About your Lumen Pro payment", "Payment failed for Lumen Pro", "Action needed: your Lumen Pro payment failed"]);
const body = [
W === 2 ? "Sorry for the hassle!" : "",
pick(L, [
"Update your card to keep access.",
"We couldn't charge your Visa ending 4417 for $29.00. Update your card to keep access.",
"We couldn't charge your Visa ending 4417 for $29.00 on September 24. We'll retry on September 27; update your card before then to keep your projects and exports available.",
]),
pick(U, [L ? "No rush, we'll try again automatically." : "", "", "Your workspace pauses in 3 days."]),
W === 2 ? "We're here if you need a hand." : W === 1 && L ? "Thanks for being with us." : "",
].filter(Boolean).join(" ");
return `${title}\n${body}`;
}
push.preview = (settings, { signal }) => new Promise((resolve, reject) => {
const t = setTimeout(() => resolve(notification(settings)), 450);
signal.addEventListener("abort", () => { clearTimeout(t); reject(new DOMException("Aborted", "AbortError")); });
});
</script>
</div>Referência cultural
Interestelar, Christopher Nolan (2014, filme). O robô da tripulação tem configurações de personalidade expressas em porcentagens, entre elas honestidade e humor, que as pessoas ajustam na conversa e ouvem fazer efeito logo na fala seguinte. Na interface, os traços de tom de um assistente ou de uma mensagem são ajustados em porcentagens com zonas nomeadas, e uma amostra ao vivo se reescreve a cada movimento com as palavras alteradas em destaque, para que o efeito de cada ajuste seja percebido na hora.
API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
label | string | Tone | Heading of the component; also its accessible name (role="group"). |
description | string | Muted line under the heading, wired as the group's aria-describedby. | |
value | JSON | Initial settings, e.g. {"humor":75,"formality":40}. Missing traits use their default; the value property set by the app wins. | |
debounce | number (ms) | 300 | Delay before calling an async preview() again while the user keeps moving. Sync previews run on every move. |
snap | number (%) | 2 | Magnetic distance to the trait's marks while dragging with a pointer (0 disables). Keyboard and typed values are never snapped. |
diff | "words" | "inline" | "off" | words | How the sample shows what changed since the last setting: inserted words highlighted, insertions plus struck-through deletions, or nothing. The Highlight changes toggle hides it temporarily. |
announce | "changes" | "full" | "off" | changes | What the polite live region reads once the sample settles: the sentences that changed, the whole sample, or nothing (notes, presets and copies are still announced). |
view | "sample" | "prompt" | "json" | sample | Preview tab shown first; changing the attribute switches tabs. |
form-format | "json" | "prompt" | json | What the form submits: the settings JSON ({"formality":45,…}) or the prompt text. |
persist | string | localStorage key suffix (mv-tone-dial:{persist}) where presets saved by the user are kept. | |
name / disabled | form | Form association. disabled (or a disabled fieldset) locks the traits and presets; tabs and Copy keep working. |
Properties
| Name | Tipo | Description |
|---|---|---|
traits | Array<{ id, label, value?, zones?, marks?, step?, description? }> | Trait definitions. value = default % (50). zones = strings (split evenly) or { label, from?, prompt? } objects; prompt is the guidance used in the Prompt export. marks default to 0/25/50/75/100. Also readable from a <script type="application/json"> child ({ traits, presets, hints }). Without traits, six built-in ones are used (formality, warmth, humor, detail, directness, emoji) with their notes. |
preview | (settings, ctx) => string | { text } | Promise | Builds the sample. ctx = { signal (aborted when superseded), traits, zones, previous (settings before this change), changed (trait ids), previousText }. Blank lines separate paragraphs, single newlines break lines. Without it, the sample shows the prompt text. |
presets | Array<{ id, label, settings, description? }> | Ready-made settings shown as toggle chips; traits missing from settings use their default. Reading it also returns user presets ({ user: true }). |
hints | Array<{ when: { traitId: ">=67" | "<34" | [min, max] }, message, traits? } | (settings) => string | { message, traits } | null> | Incompatibility notes. Every condition in when must hold; the traits involved get a note icon and aria-describedby to the note. |
formatPrompt | (settings, { traits, zones, hints }) => string | Replaces the built-in prompt text (Prompt tab, toPrompt(), form-format="prompt"). |
value | { [traitId]: number } | Current settings (0-100 integers). Assigning an object or JSON string applies it without emitting mv-change. |
defaults / zones / activeHints / previewText / preset | read-only | Trait defaults, current zone label per trait, notes shown, text of the sample, id of the preset matching exactly (or null). |
strings | object | Overrides for every visible text and announcement (partial object, English defaults). |
Methods
| Name | Description |
|---|---|
set(id, value, { emit? }) | Sets one trait (clamped), updates the sample and emits mv-input / mv-change unless emit is false. |
reset(id?) | Resets one trait, or all traits to their defaults (sliders glide back). |
applyPreset(id) / savePreset(label) / deletePreset(id) | Preset management. savePreset returns the new preset (or null if mv-preset was canceled); only user presets can be deleted. |
refreshPreview() | Calls preview() again with the current settings (e.g. the source data changed). |
toPrompt() / toJSON() | System-prompt-style text, and { preset, settings, traits: { id: { label, value, zone } } }. |
copy(format?) | Copies "sample", "prompt" or "json" (default: the tab shown). Resolves to true on success. |
Events
| Name | Description |
|---|---|
mv-input | On every move. detail: { settings, trait, value, zone, source: "slider" | "input" | "keyboard" | "reset" | "api" }. |
mv-change | When a change is committed (pointer released, number entered, key step, preset, reset). detail: { settings, zones, changed (ids), preset, source, hints }. |
mv-preview | The sample was rendered with new text. detail: { text, settings, added, removed } (word counts against the previous setting). |
mv-preview-error | preview() threw or rejected. detail: { error, settings }. The previous sample stays, with a Retry button. |
mv-hint | The set of notes changed. detail: { hints: [{ message, traits }], settings }. |
mv-preset | Cancelable. detail: { action: "save" | "delete", preset }. preventDefault() refuses the save or deletion (e.g. to store presets on the server instead). |
mv-export | Cancelable, before copying. detail: { format: "sample" | "prompt" | "json", text }. |
Content structure
| Name | Description |
|---|---|
context | Content shown above the sample (the incoming message being answered, the event that triggers the notification…). Styled by the app. |
CSS classes
| Name | Description |
|---|---|
mv-tone-dial-trait | One trait row. data-trait, data-zone (index), data-modified, data-hint. |
mv-tone-dial-range / -num / -segs / -zones | The native range, its numeric field, the zone segments (--_f = fill 0..1 per segment) and the zone labels (data-active on the current one). |
mv-tone-dial-preset | Preset chip: aria-pressed when it matches, data-edited when it was applied then tweaked. |
mv-tone-dial-sample / -ins / -del | The sample, inserted words (<mark>) and deleted words (<del>, diff="inline"). |
mv-tone-dial-root | Inner layout; data-loading while an async preview is pending, data-error after a failure. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-tone-dial-fill | var(--mv-accent) | Slider fill, thumb ring and loading bar. |
--mv-tone-dial-highlight | var(--mv-accent) | Tint of changed words in the sample. |
--mv-tone-dial-hint | var(--mv-warning) | Tint of incompatibility notes. |
--mv-tone-dial-thumb-size / --mv-tone-dial-track-size | 1.125rem / 0.375rem | Slider geometry. |
Accessibility
The component is a labelled group (role="group" named by its heading, described by its description). Every trait is a native <input type="range"> labelled by the trait name, with aria-valuetext such as “Humor 72%, Playful”, so the zone is spoken with the number; arrows step by 1, Shift+arrows by 10, Page Up/Down jump to the next or previous mark, Home/End go to 0/100%, and typing digits on the focused slider sets the value directly (“7”, “5” → 75%, Enter applies at once, Escape cancels). Each trait also has a text field labelled “Humor, percent” (Up/Down step, Enter commits, Escape restores, invalid input is marked aria-invalid) and a reset button named “Reset Humor to 20%”. Zones are never shown by color alone: the active zone label is bold, segments show fill, and the default notch is a shape. Presets are toggle buttons with aria-pressed; a tweaked preset adds the word “edited” to its name; user presets have a named delete button; saving happens in an inline text field (Enter saves, Escape cancels, focus returns to the chip). Notes are real text in a list, and each slider involved points to its note with aria-describedby (plus a visible icon with hidden text). The sample is not a live region itself: a separate polite status region reads only the sentences that changed once the sample settles (about a second after the last move, sooner on commit), never on every tick, and also announces new notes, applied presets and copies. Changed words are marked with <mark> (tint plus underline) and deletions with <del> (strike-through), and the highlight can be switched off with a toggle button (aria-pressed). The Sample / Prompt / JSON views are a tablist with arrow-key navigation, and the sample shows aria-busy while an async preview is pending. Reduced motion (prefers-reduced-motion or data-motion="reduce"): sliders jump instead of gliding, no highlight flash, zone bump or indeterminate bar. Forced colors: segments, marks and notch use system colors, changed words use Mark/MarkText, the pressed preset uses Highlight.