익스클루시브

Tone Dial <mv-tone-dial>

AI 어시스턴트, 챗봇, 생성된 문구, 알림이나 이메일 어조, 브랜드 보이스, 고객 지원 매크로를 위한 성격 및 어조 설정으로, 즉각적이고 구체적인 미리 보기를 제공합니다.

문화적 레퍼런스

인터스텔라, 크리스토퍼 놀란(2014, 영화). 탐사대의 로봇에게는 정직도와 유머를 비롯해 백분율로 표현되는 성격 설정이 있고, 사람들은 대화 중에 이를 조정하며 바로 다음 대사에서 그 효과를 듣습니다. UI에서는 어시스턴트나 메시지의 어조 특성을 이름 붙은 구간이 있는 백분율로 조절하고, 실시간 샘플이 움직일 때마다 바뀐 단어를 강조하며 스스로 다시 쓰이므로 각 설정의 효과를 곧바로 확인할 수 있습니다.

작동 방식

AI 어시스턴트, 챗봇, 생성 문구, 알림이나 이메일 어조, 브랜드 보이스, 지원 매크로를 위한, 즉각적이고 구체적인 미리 보기가 있는 성격 및 어조 설정입니다. 앱은 특성(기본값: Formality, Warmth, Humor, Detail, Directness, Emoji use)을 선언하며, 각 특성은 0에서 100%까지 설정됩니다. 네이티브 range는 구간 세그먼트(“Terse · Balanced · Thorough”)로 그려지고 활성 구간이 강조되며, 숫자 입력란(75 입력), 포커스된 슬라이더에서 바로 입력하는 숫자, Shift+화살표로 10씩 이동, Page Up/Down으로 다음 표시까지 이동, 드래그 중 표시에 자석처럼 맞춰지는 스냅, 각 기본값을 보여 주는 홈, 특성별 초기화를 지원합니다. 핵심은 실시간 샘플입니다: 앱의 preview(settings) 함수(동기, 또는 디바운스, abort 신호, 로딩 및 오류 상태를 갖춘 비동기)가 움직일 때마다 샘플을 다시 쓰고, 마지막 설정 이후 바뀐 단어가 제자리에서 강조되며(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
Keywordsexclusive, 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/marvelous

Marvelous 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유형DefaultDescription
labelstringToneHeading of the component; also its accessible name (role="group").
descriptionstringMuted line under the heading, wired as the group's aria-describedby.
valueJSONInitial settings, e.g. {"humor":75,"formality":40}. Missing traits use their default; the value property set by the app wins.
debouncenumber (ms)300Delay before calling an async preview() again while the user keeps moving. Sync previews run on every move.
snapnumber (%)2Magnetic distance to the trait's marks while dragging with a pointer (0 disables). Keyboard and typed values are never snapped.
diff"words" | "inline" | "off"wordsHow 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"changesWhat 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"samplePreview tab shown first; changing the attribute switches tabs.
form-format"json" | "prompt"jsonWhat the form submits: the settings JSON ({"formality":45,…}) or the prompt text.
persiststringlocalStorage key suffix (mv-tone-dial:{persist}) where presets saved by the user are kept.
name / disabledformForm association. disabled (or a disabled fieldset) locks the traits and presets; tabs and Copy keep working.

Properties

Name유형Description
traitsArray<{ 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 } | PromiseBuilds 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.
presetsArray<{ 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 }).
hintsArray<{ 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 }) => stringReplaces 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 / presetread-onlyTrait defaults, current zone label per trait, notes shown, text of the sample, id of the preset matching exactly (or null).
stringsobjectOverrides for every visible text and announcement (partial object, English defaults).

Methods

NameDescription
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

NameDescription
mv-inputOn every move. detail: { settings, trait, value, zone, source: "slider" | "input" | "keyboard" | "reset" | "api" }.
mv-changeWhen a change is committed (pointer released, number entered, key step, preset, reset). detail: { settings, zones, changed (ids), preset, source, hints }.
mv-previewThe sample was rendered with new text. detail: { text, settings, added, removed } (word counts against the previous setting).
mv-preview-errorpreview() threw or rejected. detail: { error, settings }. The previous sample stays, with a Retry button.
mv-hintThe set of notes changed. detail: { hints: [{ message, traits }], settings }.
mv-presetCancelable. detail: { action: "save" | "delete", preset }. preventDefault() refuses the save or deletion (e.g. to store presets on the server instead).
mv-exportCancelable, before copying. detail: { format: "sample" | "prompt" | "json", text }.

Content structure

NameDescription
contextContent shown above the sample (the incoming message being answered, the event that triggers the notification…). Styled by the app.

CSS classes

NameDescription
mv-tone-dial-traitOne trait row. data-trait, data-zone (index), data-modified, data-hint.
mv-tone-dial-range / -num / -segs / -zonesThe 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-presetPreset chip: aria-pressed when it matches, data-edited when it was applied then tweaked.
mv-tone-dial-sample / -ins / -delThe sample, inserted words (<mark>) and deleted words (<del>, diff="inline").
mv-tone-dial-rootInner layout; data-loading while an async preview is pending, data-error after a failure.

CSS variables

NameDefaultDescription
--mv-tone-dial-fillvar(--mv-accent)Slider fill, thumb ring and loading bar.
--mv-tone-dial-highlightvar(--mv-accent)Tint of changed words in the sample.
--mv-tone-dial-hintvar(--mv-warning)Tint of incompatibility notes.
--mv-tone-dial-thumb-size / --mv-tone-dial-track-size1.125rem / 0.375remSlider 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).

이 페이지는 AI로 번역되었습니다. 번역 문제 신고