エクスクルーシブ
Tone Dial <mv-tone-dial>
AI アシスタント、チャットボット、生成されたコピー、通知やメールのトーン、ブランドボイス、サポートの定型文のための、性格とトーンの設定です。すぐに具体的なプレビューを表示します。
文化的リファレンス
インターステラー、クリストファー・ノーラン(2014年、映画)。乗組員のロボットには、正直さやユーモアなどの性格設定がパーセンテージで用意されており、人々は会話の中でそれを調整し、すぐ次のセリフでその効果を耳にします。UI では、アシスタントやメッセージのトーンの特性を、名前付きのゾーンを持つパーセンテージで調整します。動かすたびにライブのサンプルが書き換わり、変化した単語がハイライトされるので、各設定の効果をすぐに確かめられます。
仕組み
即座に具体的なプレビューが見られる、性格とトーンの設定です。AI アシスタント、チャットボット、生成コピー、通知やメールのトーン、ブランドボイス、サポート用の定型文に使えます。アプリは特性(デフォルトは Formality、Warmth、Humor、Detail、Directness、Emoji use)を宣言し、各特性はネイティブの range で 0 から 100% の間で設定します。range はゾーンの区切り(「Terse · Balanced · Thorough」)として描かれ、アクティブなゾーンが強調されます。数値フィールド(75 と入力)、フォーカスしたスライダーへの直接の数字入力、Shift+矢印キーで 10 刻み、Page Up/Down で次の目盛りへ、ドラッグ中の目盛りへの磁気スナップ、各デフォルト値を示す刻み、特性ごとのリセットを備えています。肝心なのはライブのサンプルです:アプリの preview(settings) 関数(同期、またはデバウンス、中断シグナル、読み込み中とエラーの状態を伴う非同期)が動かすたびにサンプルを書き換え、直前の設定から変化した単語がその場でハイライトされ(diff="inline" では挿入と削除として表示)、「Humor 20% → 72% · +18 −4 words」のようなステータス行が付きます。相性の悪い組み合わせ(高いユーモアと高いフォーマルさ)には控えめな注記が表示され、関係する特性を示します。プリセットはトグルチップで(設定に一致するものが押下状態になり、調整したものには「edited」と表示)、ユーザープリセットは保存、削除、永続化でき、切り替えるとすべてのスライダーが新しい値へ滑らかに移動します。Sample、Prompt(ゾーンごとのガイダンスから組み立てたシステムプロンプト風のスニペット)、JSON の各ビューは、キャンセル可能な mv-export を通じてクリップボードにコピーできます。フォーム関連付けに対応(JSON またはプロンプトテキスト)し、mv-input、mv-change、mv-preview、mv-hint、キャンセル可能な mv-preset を備えています。
| カテゴリー | フォーム |
|---|---|
| タイプ | Web Component(<mv-tone-dial>) |
| ステータス | 安定版 |
| キット | 検証できる AI |
| 同時にインストールされるもの | 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
インストール
node scripts/add.mjs tone-dial --out ./src/marvelousMarvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["tone-dial"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
コピーされるファイル(依存関係を含む):tokens/tokens.css, core/base.css, components/button/button.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。
使い方
クイックスタート、動作する最小限のマークアップ:
<mv-tone-dial name="voice" label="Assistant voice"></mv-tone-dial>基本となるマークアップです。ここから属性、data-*、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 Brightloom 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 Brightloom Pro payment", "Payment failed for Brightloom Pro", "Action needed: your Brightloom Pro payment failed"]);
const body = [
W === 2 ? "Sorry for the hassle!" : "",
pick(L, [
"Update your card to keep access.",
"We couldn't charge your card ending 4417 for $29.00. Update your card to keep access.",
"We couldn't charge your card 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>API
Attributes
| Name | タイプ | 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 | タイプ | 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. Known limits: the component writes no text itself, the sample is only as good as the app's preview(settings) function; the change highlight compares words, so a rephrased sentence reads as fully changed; with persist, user presets are kept in this browser's localStorage only (sync them yourself through the events to share them).