Exclusifbêta
Tone Dial <mv-tone-dial>
Réglages de personnalité et de ton avec un aperçu immédiat et concret, pour les assistants IA, les chatbots, les textes générés, le ton des notifications ou des e-mails, la voix de marque et les macros de support. L’application déclare des traits (par défaut Formalité, Chaleur, Humour, Détail, Franchise, Usage des emojis), chacun réglé de 0 à 100 % sur un range natif dessiné en segments de zones (« Concis · Équilibré · Détaillé ») avec la zone active mise en valeur, un champ numérique (tapez 75), des chiffres saisis directement sur un curseur qui a le focus, Maj+flèches par pas de 10, Page préc./suiv. jusqu’au repère suivant, un aimantage aux repères pendant le glissement, une encoche indiquant chaque valeur par défaut et une réinitialisation par trait. L’élément clé est l’échantillon en direct : la fonction preview(settings) de l’application (synchrone, ou asynchrone avec debounce, signal d’abandon, états de chargement et d’erreur) le réécrit à chaque mouvement, et les mots qui ont changé depuis le dernier réglage sont surlignés sur place (ou affichés sous forme d’insertions et de suppressions avec diff="inline"), avec une ligne d’état comme « Humour 20 % → 72 % · +18 −4 mots ». Les combinaisons incompatibles déclenchent des notes bienveillantes (beaucoup d’humour avec beaucoup de formalité) qui signalent les traits concernés. Les préréglages sont des pastilles bascules (celle qui correspond aux réglages est enfoncée, une version retouchée affiche « modifié »), les préréglages utilisateur peuvent être enregistrés, supprimés et conservés, et changer de préréglage fait glisser chaque curseur vers sa nouvelle valeur. Les vues Échantillon, Prompt (un extrait façon prompt système construit à partir des consignes par zone) et JSON se copient chacune dans le presse-papiers via un mv-export annulable. Associé aux formulaires (JSON ou texte de prompt), avec mv-input, mv-change, mv-preview, mv-hint et un mv-preset annulable.
| Catégorie | Formulaires |
|---|---|
| Type | Web Component (<mv-tone-dial>) |
| Statut | bêta |
| Kit | Une IA vérifiable |
| Installe aussi | 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
Installation
node scripts/add.mjs tone-dial --out ./src/marvelousAgent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["tone-dial"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : 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.
Utilisation
Démarrage rapide, le balisage minimal qui fonctionne :
<mv-tone-dial name="voice" label="Assistant voice"></mv-tone-dial>Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables 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>Référence culturelle
Interstellar, Christopher Nolan (2014, film). Le robot de l’équipage a des réglages de personnalité exprimés en pourcentages, dont l’honnêteté et l’humour, que les personnages ajustent au fil de la conversation et dont ils entendent l’effet dès sa réplique suivante. Dans l’interface, les traits de ton d’un assistant ou d’un message se règlent en pourcentages avec des zones nommées, et un échantillon en direct se réécrit à chaque mouvement en surlignant les mots modifiés, pour que l’effet de chaque réglage s’entende immédiatement.
API
Attributes
| Name | Type | 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 | Type | 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.