Exclusivebeta

Leitmotif — <mv-leitmotif>

Opt-in audio cues you can recognize without looking, for screens people monitor while doing something else: ops consoles, support queues, trading desks, chat, kitchen displays, CI dashboards. The app declares its event categories (children with data-motif or a motifs property: id, label, pattern) and each one gets its own short motif, synthesized live with WebAudio (no audio files): 2 to 4 notes whose contour, rhythm and timbre all differ (8 presets such as chime, pluck, low pulse, knock and alert, or your own note string like "C5 E5 G5:3"), so categories are told apart by shape, not only by pitch. Cues fire from play(id) or declaratively from DOM events (listen="order-created:new-order"). Sound is OFF until the user opts in from a settings panel of native controls (master switch, volume, per-cue switches with preview, Play all to learn every tune in turn, quiet hours with an urgent exception), and audio only starts after a user gesture. Bursts are coalesced (10 orders in a second = one motif and a ×10 count), a per-minute rate limit protects focus, urgent motifs cut through and push the rest back in the queue, and a channel option makes only one tab sound a given cue. Every cue also flashes a small badge with a mini piano-roll glyph of the tune, so muted tabs and deaf or hard-of-hearing users get the same information, and emits a cancelable mv-motif event.

CategoryFeedback
TypeWeb Component (<mv-leitmotif>)
Statusbeta
Also installsbutton, checkbox, input, slider, switch
Keywordsexclusive, culture, sound, audio, webaudio, earcon, notification-sound, audio-cue, alert, monitoring, ops, dashboard, kitchen-display, support, chat, trading, ci, quiet-hours, do-not-disturb, coalescing, rate-limit, accessibility, sonification

When to use

Avoid when

Install

node scripts/add.mjs leitmotif --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["leitmotif"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/position.js, components/leitmotif/leitmotif.js, components/leitmotif/leitmotif.css, components/button/button.css, components/checkbox/checkbox.css, components/input/input.css, components/slider/slider.js, components/slider/slider.css, components/switch/switch.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<div id="lm-demo" style="width:min(100%,60rem);margin-inline:auto">
  <style>
    #lm-demo { display:grid; gap:1.25rem; align-content:start; font-size:.875rem }
    #lm-demo .lm-app { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
    #lm-demo .lm-bar { display:flex; align-items:center; gap:1rem; height:3.5rem; padding:0 1rem 0 1.125rem; border-bottom:1px solid var(--mv-border) }
    #lm-demo .lm-brand { display:flex; align-items:center; gap:.5rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em; white-space:nowrap }
    #lm-demo .lm-logo { display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-md); background:var(--mv-fg); color:var(--mv-bg) }
    #lm-demo .lm-logo svg { width:1rem; height:1rem }
    #lm-demo .lm-end { display:flex; align-items:center; gap:.75rem; margin-inline-start:auto }
    #lm-demo .lm-me { display:grid; place-items:center; width:2rem; height:2rem; border-radius:50%; background:var(--mv-bg-emphasis); font-size:.75rem; font-weight:600 }
    #lm-demo .lm-body { display:grid; grid-template-columns:minmax(0,1fr) 17rem; gap:1rem; padding:1.125rem }
    #lm-demo .lm-card { min-width:0; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
    #lm-demo .lm-card h4 { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin:0 0 .25rem; font-size:.875rem }
    #lm-demo .lm-card > p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
    #lm-demo .lm-feed { display:grid; gap:.375rem; margin:.875rem 0 0; padding:0; list-style:none }
    #lm-demo .lm-row { display:grid; grid-template-columns:2.75rem auto minmax(0,1fr) auto; align-items:center; gap:.75rem; padding:.5rem .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-surface); font-size:.8125rem }
    #lm-demo .lm-row .mv-leitmotif-glyph { --mv-leitmotif-glyph-width:2.25rem; --mv-leitmotif-glyph-height:1.125rem; --mv-leitmotif-note-size:3.5px }
    #lm-demo .lm-row time { color:var(--mv-fg-subtle); font-size:.75rem; font-variant-numeric:tabular-nums }
    #lm-demo .lm-row b { display:block; font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #lm-demo .lm-row small { display:block; color:var(--mv-fg-muted); font-size:.75rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #lm-demo .lm-row span:last-child { color:var(--mv-fg-muted); font-variant-numeric:tabular-nums; white-space:nowrap }
    #lm-demo .lm-row[data-new] { animation:lm-in .5s var(--mv-ease-out) }
    @keyframes lm-in { from { background:color-mix(in oklab, var(--mv-accent) 10%, var(--mv-surface)) } }
    #lm-demo .lm-sim { display:grid; gap:.875rem; align-content:start }
    #lm-demo .lm-btns { display:grid; gap:.5rem }
    #lm-demo .lm-btns .mv-button { justify-content:flex-start; width:100% }
    #lm-demo .lm-btns .mv-button kbd { margin-inline-start:auto; color:var(--mv-fg-subtle); font:inherit; font-size:.6875rem }
    #lm-demo .lm-log { margin:0; padding:.625rem .75rem; border-radius:var(--mv-radius-md); background:var(--mv-surface); border:1px dashed var(--mv-border); color:var(--mv-fg-muted); font:.75rem/1.45 var(--mv-font-mono); min-height:2.5rem }
    #lm-demo .lm-foot { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; padding:.75rem 1.125rem; border-top:1px solid var(--mv-border); color:var(--mv-fg-muted); font-size:.75rem }
    #lm-demo .lm-ci { display:grid; grid-template-columns:minmax(0,24rem) minmax(0,1fr); gap:1.25rem; align-items:start }
    #lm-demo .lm-ci-side { display:grid; gap:.875rem; padding:1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
    #lm-demo .lm-ci-side h4 { margin:0; font-size:.9375rem; letter-spacing:-.01em }
    #lm-demo .lm-ci-side p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
    #lm-demo .lm-pipes { display:grid; gap:.375rem; margin:0; padding:0; list-style:none }
    #lm-demo .lm-pipe { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:.5rem .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); font-size:.8125rem }
    #lm-demo .lm-pipe code { font:.75rem var(--mv-font-mono); color:var(--mv-fg-muted) }
    #lm-demo .lm-row-btns { display:flex; flex-wrap:wrap; gap:.5rem }
    @media (max-width:52rem) {
      #lm-demo .lm-body, #lm-demo .lm-ci { grid-template-columns:minmax(0,1fr) }
    }
    @media (prefers-reduced-motion: reduce) { #lm-demo .lm-row[data-new] { animation:none } }
  </style>

  <!-- Main: a store operations console; categories are declared as children, events arrive as DOM events -->
  <section class="lm-app" aria-label="Store operations console">
    <header class="lm-bar">
      <span class="lm-brand">
        <span class="lm-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9h16l-1.5 10h-13z"/><path d="M8.5 9V7a3.5 3.5 0 0 1 7 0v2"/></svg></span>
        Fernhill Supply · Ops
      </span>
      <span class="lm-end">
        <mv-leitmotif id="lm-main" storage-key="lm-demo-ops" channel="lm-demo-ops" announce
          listen="order-created:new-order message-received:chat refund-requested:refund stock-low:low-stock payment-failed:payment-failed payout-sent:payout">
          <span data-motif="new-order" data-pattern="chime">New order</span>
          <span data-motif="chat" data-pattern="pluck">Customer message</span>
          <span data-motif="refund" data-pattern="knock">Refund request</span>
          <span data-motif="low-stock" data-pattern="low-pulse" data-priority="low" data-tone="neutral">Low stock</span>
          <span data-motif="payment-failed" data-pattern="alert">Payment failed</span>
          <span data-motif="payout" data-pattern="resolve">Payout sent</span>
        </mv-leitmotif>
        <span class="lm-me" aria-hidden="true">JO</span>
      </span>
    </header>
    <div class="lm-body">
      <div class="lm-card">
        <h4>Live activity</h4>
        <p>Each row’s glyph is the shape of its tune: learn it here, recognize it later without looking.</p>
        <ol class="lm-feed" id="lm-feed" aria-label="Recent store events"></ol>
      </div>
      <div class="lm-card lm-sim">
        <h4>Simulate events</h4>
        <div class="lm-btns">
          <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-fire="order">New order</button>
          <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-fire="rush">Lunch rush <kbd>10 orders in 1 s</kbd></button>
          <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-fire="chat">Customer message</button>
          <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-fire="stock">Low stock <kbd>low priority</kbd></button>
          <button type="button" class="mv-button" data-variant="destructive" data-size="sm" data-fire="failed">Payment failed <kbd style="color:inherit;opacity:.75">urgent</kbd></button>
        </div>
        <label class="mv-choice" data-control="end"><input type="checkbox" role="switch" class="mv-switch" id="lm-traffic" checked><span class="mv-choice-text"><span class="mv-choice-title">Live traffic</span><span class="mv-choice-description">A random event every few seconds</span></span></label>
        <p class="lm-log" id="lm-log" aria-live="off">Waiting for the first event…</p>
      </div>
    </div>
    <div class="lm-foot">
      <span>Sound stays off until each user turns it on in <b>Sound cues</b>. Every cue also flashes next to it.</span>
      <span class="mv-badge" data-variant="secondary" data-shape="pill">No audio files: every tune is synthesized</span>
    </div>
  </section>

  <!-- Inline display: the same settings embedded in a preferences page, motifs set as a property -->
  <section class="lm-ci" aria-label="Pipeline notification preferences">
    <mv-leitmotif id="lm-ci" display="inline" label="Pipeline sounds" coalesce="3s"></mv-leitmotif>
    <div class="lm-ci-side">
      <h4>Deploy pipeline · web-storefront</h4>
      <p>A CI dashboard left open on a second screen: a failed build cuts through anything else that is playing, and quiet hours keep the night silent unless it is urgent.</p>
      <ul class="lm-pipes">
        <li class="lm-pipe"><span>main · #4821</span><code>passed in 4m 12s</code></li>
        <li class="lm-pipe"><span>feat/checkout-v2 · #4822</span><code>running · 2m</code></li>
        <li class="lm-pipe"><span>fix/tax-rounding · #4823</span><code>review requested</code></li>
      </ul>
      <div class="lm-row-btns">
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-ci="build-passed">Build passed</button>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-ci="review">Review requested</button>
        <button type="button" class="mv-button" data-variant="destructive" data-size="sm" data-ci="build-failed">Build failed</button>
      </div>
    </div>
  </section>

  <script type="module">
    await customElements.whenDefined("mv-leitmotif");
    const lm = document.getElementById("lm-main");
    const ci = document.getElementById("lm-ci");
    const feed = document.getElementById("lm-feed");
    const log = document.getElementById("lm-log");
    const traffic = document.getElementById("lm-traffic");

    const time = (d = new Date()) => d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
    const pick = (a) => a[Math.floor(Math.random() * a.length)];
    const money = (n) => n.toLocaleString("en-US", { style: "currency", currency: "USD" });
    const names = ["Aiko Tanaka", "Lucas Moreau", "Priya Nair", "Mateo García", "Amara Okafor", "Sven Lindqvist", "Hana Kim", "Omar Haddad"];
    const items = ["Linen apron", "Cast-iron skillet", "Ceramic pour-over", "Oak cutting board", "Chef’s knife 8\"", "Enamel kettle"];
    let orderNo = 10482;

    const EVENTS = {
      order: () => { const v = money(24 + Math.round(Math.random() * 180) + .5); return { type: "order-created", motif: "new-order", title: `Order #${++orderNo}`, detail: `${pick(names)} · ${pick(items)}`, value: v, flash: `#${orderNo} · ${v}` }; },
      chat: () => { const who = pick(names); return { type: "message-received", motif: "chat", title: who, detail: `“Is the ${pick(items).toLowerCase()} back in stock?”`, value: "", flash: who }; },
      refund: () => ({ type: "refund-requested", motif: "refund", title: `Refund · #${orderNo - 7}`, detail: `${pick(names)} · damaged in transit`, value: money(42.9), flash: `#${orderNo - 7} · $42.90` }),
      stock: () => { const it = pick(items); return { type: "stock-low", motif: "low-stock", title: `Low stock · ${it}`, detail: "3 left · reorder suggested", value: "", flash: it }; },
      failed: () => ({ type: "payment-failed", motif: "payment-failed", title: `Payment failed · #${++orderNo}`, detail: `${pick(names)} · card declined`, value: money(129), flash: `#${orderNo} · card declined` }),
      payout: () => ({ type: "payout-sent", motif: "payout", title: "Payout sent", flash: "$8,412.35", detail: "Weekly transfer to Chase ···4821", value: money(8412.35) }),
    };

    function addRow(ev, { at = new Date(), fresh = true } = {}) {
      const li = document.createElement("li");
      li.className = "lm-row";
      if (fresh) li.dataset.new = "";
      const t = document.createElement("time");
      t.textContent = time(at);
      const g = lm.glyph(ev.motif);
      const txt = document.createElement("span");
      const b = document.createElement("b");
      b.textContent = ev.title;
      const s = document.createElement("small");
      s.textContent = ev.detail;
      txt.append(b, s);
      const v = document.createElement("span");
      v.textContent = ev.value;
      li.append(t, g, txt, v);
      feed.prepend(li);
      while (feed.children.length > 5) feed.lastElementChild.remove();
    }

    function fire(kind, { count = 1 } = {}) {
      const ev = EVENTS[kind]();
      addRow(ev);
      window.dispatchEvent(new CustomEvent(ev.type, { detail: { count, text: ev.flash ?? ev.title } }));
    }

    // Seed the feed with recent history.
    const now = Date.now();
    ["payout", "refund", "chat", "order"].forEach((k, i) => addRow(EVENTS[k](), { at: new Date(now - (4 - i) * 7 * 60000), fresh: false }));

    const REASONS = { off: "sound is off", muted: "cue muted", volume: "volume at 0", "quiet-hours": "quiet hours", "rate-limit": "rate limited", locked: "waiting for a click", unsupported: "no WebAudio" };
    lm.addEventListener("mv-motif", (e) => {
      const d = e.detail;
      log.textContent = `mv-motif · ${d.label}${d.count > 1 ? ` ×${d.count}` : ""} · ${d.audible ? "plays" : `silent (${REASONS[d.reason] ?? d.reason})`}`;
    });
    lm.addEventListener("mv-motif-merge", (e) => {
      log.textContent = `mv-motif-merge · ${e.detail.label} ×${e.detail.count} · folded into one cue`;
    });

    document.querySelectorAll("#lm-demo [data-fire]").forEach((btn) => btn.addEventListener("click", () => {
      const kind = btn.dataset.fire;
      if (kind === "rush") {
        for (let i = 0; i < 10; i++) setTimeout(() => fire("order"), i * 90);
      } else fire(kind);
    }));

    // Live traffic: a random event every 4–8 s, mostly orders.
    let timer = 0;
    const loop = () => {
      clearTimeout(timer);
      if (!lm.isConnected) return;
      timer = setTimeout(() => {
        if (!lm.isConnected) return;
        if (traffic.checked) fire(pick(["order", "order", "order", "chat", "chat", "refund", "stock", "payout"]));
        loop();
      }, 4000 + Math.random() * 4000);
    };
    traffic.addEventListener("change", loop);
    loop();

    // Show the mechanic right away: a burst of three orders and a message.
    setTimeout(() => { fire("order", { count: 3 }); }, 250);
    setTimeout(() => { fire("chat"); }, 700);

    // Inline instance: motifs as a property, quiet hours pre-configured.
    ci.motifs = [
      { id: "build-passed", label: "Build passed", pattern: "resolve" },
      { id: "deploy", label: "Deploy started", pattern: "bloom", tone: "info" },
      { id: "review", label: "Review requested", pattern: "leap" },
      { id: "build-failed", label: "Build failed", pattern: "alert" },
    ];
    ci.settings = { quiet: { on: true, from: "22:00", to: "07:00", urgent: true } };
    document.querySelectorAll("#lm-demo [data-ci]").forEach((btn) => btn.addEventListener("click", () => {
      ci.play(btn.dataset.ci, { text: btn.dataset.ci === "build-failed" ? "#4822" : btn.dataset.ci === "review" ? "#4823" : "#4821" });
    }));
    setTimeout(() => ci.play("deploy", { text: "web-storefront" }), 450);
  </script>
</div>

Cultural reference

Peter and the Wolf — Sergei Prokofiev (1936, music). Each character of the symphonic fairy tale has its own instrument and theme, introduced at the start, so listeners know who has arrived without seeing anyone. Here each event category gets its own short synthesized motif, learned from a “Play all” introduction and paired with a visual glyph of its shape, so people know what happened without looking at the screen.

API

Attributes

NameTypeDefaultDescription
listenstringSpace- or comma-separated DOM events that trigger cues: "event:motif" plays that motif ("order-created:new-order"); a bare "event" plays event.detail.motif, else the motif whose id is the event name. event.detail.count counts as that many events (coalesced), event.detail.text is shown in the flash. Event names may contain colons: the part after the last colon is only a motif when it matches a declared id.
listen-onwindow | document | self | CSS selectorwindowWhere listen events are caught (window also receives bubbling events from anywhere in the page).
storage-keystringPersists the user's settings in localStorage under mv-leitmotif:<key> and keeps open tabs in sync. Without it, settings live only as long as the page (set them from your user profile with the settings property).
channelstringTabs sharing this name coordinate through Web Locks: only one of them sounds a given motif per coalesce window, the others just flash (reason "other-tab"). Avoids doubled cues when the same app is open twice. Ignored where Web Locks are unsupported.
coalescetime ("1.5s", "800ms")1.5sEvents of the same motif arriving within this window of the last cue are folded into it: no new sound, the flash count goes up (×10) and mv-motif-merge fires. The window slides with each event but never beyond 4× its length, so a steady stream still sounds again from time to time.
max-ratenumber15Maximum non-urgent sounds per minute across all motifs; beyond it cues only flash (reason "rate-limit"). Urgent motifs are never rate limited.
displaychip | inlinechipchip: a compact “Sound cues” button that opens the settings in a non-modal popover, flashes appear next to it. inline: the settings panel is rendered in the page (a preferences screen), flashes appear in its header.
flashstart | end | bottom | nonestartWhere the visual flashes appear relative to the chip (inline start side, inline end side, stacked below). none disables them: only do that if your app shows its own visual for every mv-motif, since sound must never be the only channel.
flash-durationtime2.8sHow long a flash stays after its last event. Flashes raised while the tab is hidden wait until the tab is visible again before their timer starts.
announcebooleanAlso announces each cue in a polite live region (“New order: Order #10483”), and the total of a burst when its window closes (“New order, 10 total”). Leave it off if your app already announces these events.
labelstringSound cuesLabel of the chip and title of the panel.
placementbottom-end | bottom-start | bottom | top-end | top-start | topbottom-endPreferred side of the settings popover (flips and shifts to stay in the viewport).
data-motif / data-pattern / data-priority / data-tone / data-timbreon childrenDeclarative motifs: any child with data-motif="id" (its text is the label), data-pattern (a preset name: chime, pluck, low-pulse, bloom, knock, alert, resolve, leap, or a note string such as "C5 E5 G5:3", "-" for a rest, ":n" for n beats), data-priority (low | normal | urgent), data-tone (accent | info | success | warning | danger | neutral) and data-timbre (sine, bell, glass, pluck, wood, pulse, pad, bright). Hidden, and watched for changes.
data-state / data-cue / data-soundset by the componentdata-state: off | locked (sound on, waiting for a user gesture) | quiet (inside quiet hours) | on. data-cue: the priority of the cue that just fired (styles the chip glow). data-sound: present while the user has sound enabled.

Properties

NameTypeDescription
motifsArray<{ id, label, pattern?, timbre?, priority?, tone?, color?, beat? }>Event categories (overrides the data-motif children). pattern: preset name or note string; priority: low (only sounds when nothing else plays), normal (queued, 2 waiting at most) or urgent (cuts through, never rate limited, may pass quiet hours); color: any CSS color instead of a tone; beat: seconds per beat (default 0.12). Unknown or empty patterns fall back to a distinct preset.
settings{ enabled, volume (0–1), muted: string[], quiet: { on, from: "HH:MM", to: "HH:MM", urgent } }The user's preferences (read / write, partial objects are merged). Defaults: enabled false, volume 0.7, quiet hours 22:00–07:00 off, urgent allowed. Set it from a stored user profile: it counts as that user's earlier opt-in, but audio still waits for a gesture in this page. Set before the first render, it takes precedence over storage-key.
enabled / volumeboolean / numberShortcuts into settings.
unlockedbooleanTrue when the audio context is running (read-only).
quietbooleanTrue while the current local time is inside enabled quiet hours (read-only).
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (label, stateOn, stateOff, stateLocked, stateQuiet, title, text, master, masterHint, masterHintOn, volume, cues, playAll, stopAll, preview ({label}), urgent, low, quiet, quietHint ({from}, {to}), from, to, allowUrgent, statusOff, statusLocked, statusQuiet ({to}, {urgent}), statusQuietUrgent, statusQuietAll, statusVolume, statusUnsupported, announce, announceText, announceTotal ({label}, {text}, {count})). English defaults.

Methods

NameDescription
play(id, { count?, text?, source? })Signals an event of that category: coalescing, the cancelable mv-motif, the flash and, if the user's settings allow it, the sound. Returns Promise<{ played, merged, audible, reason, count }>; reason explains a silent cue: off, muted, volume, quiet-hours, rate-limit, locked, busy, other-tab, unsupported, merged, cancelled.
preview(id)Plays one motif now whatever the settings (call it from a user gesture). Returns false if the id is unknown.
introduce()Plays every motif in turn while highlighting its row, so users learn which tune means what (the “Play all” button). Call from a user gesture.
enable() / disable()Turns sound on or off. Call enable() from a user gesture so audio can start immediately.
stop()Silences what this element is playing or has queued.
glyph(id)Returns a new aria-hidden contour glyph of a motif (the mini piano roll) to use in your own lists and legends, so the tune's shape is visible where the event is shown.
open() / close() / toggle()Opens or closes the settings popover (chip display).

Events

NameDescription
mv-motifCancelable, before a cue flashes and sounds. detail: { id, label, priority, count, text, source: "api" | "event", audible (the settings allow sound), reason (why not: off, muted, volume, quiet-hours, rate-limit, locked, unsupported) }. preventDefault() drops the cue entirely, e.g. when the user is already looking at that queue.
mv-motif-mergeAn event was folded into a recent cue of the same motif. detail: { id, label, count (total in the window), added, text }.
mv-settingsThe settings changed. detail: { settings, reason: "user" | "api" | "storage" } (storage: changed in another tab). Save them to your user profile here if you don't use storage-key.

Content structure

NameDescription
(children with data-motif)Declarative motif definitions, hidden. Anything else inside the element is left as is.

CSS classes

NameDescription
mv-leitmotif-chipThe settings button (-chip-icon, -chip-label, -chip-state).
mv-leitmotif-rail / mv-leitmotif-flasharia-hidden rail of visual flashes (3 at most): each .mv-leitmotif-flash holds a glyph, -flash-label, -flash-count (×n) and -flash-text; data-priority on each.
mv-leitmotif-glyph / mv-leitmotif-noteMini piano roll: one pill per note, x = time (--_x), width = duration (--_w), height = pitch on a scale shared by all motifs (--_y). data-playing animates each note at its own time (--_t).
mv-leitmotif-panelSettings: -head, -master (switch), -status, -volume, -list-head (Play all), -list > .mv-leitmotif-row (switch, -row-label, -row-meta, -priority, glyph, -preview), -quiet (switch, -quiet-times, -quiet-urgent). role=dialog popover in chip display, role=group inline.

CSS variables

NameDefaultDescription
--mv-leitmotif-urgentvar(--mv-danger)Color of urgent motifs and of the Urgent label.
--mv-leitmotif-accent / -info / -success / -warning / -dangerthe matching tokenTone palette of motifs (a motif's color property overrides it).
--mv-leitmotif-glyph-width / --mv-leitmotif-glyph-height / --mv-leitmotif-note-size1.75rem / 1rem / 3pxSize of the contour glyphs and thickness of their note pills (larger in the settings rows).

Accessibility

Sound is never the only channel and never starts on its own. It is off by default; the user turns it on with a native switch (role="switch"), and audio only starts inside a user gesture (the switch, a preview, or any click or key press after opting in, per browser autoplay policies). Until then the chip says “Paused” and the panel explains why. Every cue, audible or not, also shows a visual flash (label, burst count, optional text and a glyph of the tune) and emits mv-motif, so deaf and hard-of-hearing users and muted tabs get the same information; the announce attribute adds a polite live region for screen-reader users (bursts announced once with their total). The flash rail is aria-hidden and never takes focus; flashes raised while the tab was hidden wait until it is visible again. The chip is a real <button> with aria-expanded and aria-controls; the popover is a labelled, described non-modal dialog placed right after it in the DOM, focus moves to the master switch when it opens, Escape or an outside click closes it and Escape returns focus to the chip. All settings are native controls with visible labels: switches, a range input with an <output> value, per-cue switches described by their preset and priority, preview buttons named “Preview New order”, time inputs grouped under “Quiet hours” and a checkbox for urgent cues. Motifs differ in contour, rhythm and timbre (not only pitch), which helps people with limited pitch perception, and colors always come with words (Urgent, Low, Off, Quiet). Reduced motion (OS setting or data-motion="reduce") only removes the visual flash animations (note pulses, chip ripple, entrance motion); the sound itself is unaffected. Forced colors: glyphs and borders use system colors.