익스클루시브
Leitmotif <mv-leitmotif>
다른 일을 하면서 지켜보는 화면을 위한, 보지 않고도 알아들을 수 있는 선택형 오디오 신호입니다. 운영 콘솔, 고객 지원 대기열, 트레이딩 데스크, 채팅, 주방 디스플레이, CI 대시보드에 적합합니다.
문화적 레퍼런스
피터와 늑대, 세르게이 프로코피예프 (1936, 음악). 이 교향 동화의 각 등장인물에게는 처음에 소개되는 고유한 악기와 주제가 있어서, 청중은 아무도 보지 않고도 누가 등장했는지 압니다. 여기서는 이벤트 범주마다 짧은 합성 모티프가 주어지며, “Play all” 소개로 익히고 그 모양을 나타내는 시각적 글리프와 짝을 이루므로 화면을 보지 않고도 무슨 일이 일어났는지 알 수 있습니다.
작동 방식
다른 일을 하면서 지켜보는 화면을 위한, 보지 않고도 알아들을 수 있는 옵트인 오디오 신호입니다: 운영 콘솔, 지원 대기열, 트레이딩 데스크, 채팅, 주방 디스플레이, CI 대시보드. 앱이 이벤트 범주(data-motif가 있는 자식 요소 또는 motifs 속성: id, label, pattern)를 선언하면, 각 범주는 WebAudio로 실시간 합성되는(오디오 파일 없음) 고유한 짧은 모티프를 얻습니다. 2~4개의 음으로 이루어지며 윤곽, 리듬, 음색이 모두 다르므로(chime, pluck, low pulse, knock, alert 등 8가지 프리셋, 또는 "C5 E5 G5:3" 같은 직접 작성한 음표 문자열), 범주는 음높이만이 아니라 모양으로 구별됩니다. 신호는 play(id)로, 또는 DOM 이벤트로부터 선언적으로(listen="order-created:new-order") 발생합니다. 사용자가 네이티브 컨트롤로 된 설정 패널(마스터 스위치, 볼륨, 미리 듣기가 있는 신호별 스위치, 모든 곡조를 차례로 익히는 Play all, 긴급 예외가 있는 방해 금지 시간)에서 옵트인하기 전까지 소리는 꺼져 있으며, 오디오는 사용자 동작 이후에만 시작됩니다. 연달아 발생한 신호는 합쳐지고(1초에 주문 10건 = 모티프 하나와 ×10 카운트), 분당 속도 제한이 집중을 보호하며, 긴급 모티프는 대기열에 끼어들어 나머지를 뒤로 밀고, channel 옵션을 쓰면 특정 신호가 하나의 탭에서만 울립니다. 모든 신호는 곡조의 미니 피아노 롤 글리프가 담긴 작은 배지도 함께 깜박이므로 음소거된 탭이나 청각 장애인 또는 난청 사용자도 같은 정보를 얻으며, 취소 가능한 mv-motif 이벤트를 발생시킵니다.
| 카테고리 | 피드백 |
|---|---|
| 유형 | Web Component (<mv-leitmotif>) |
| 상태 | 안정 |
| 키트 | 알림 피로 없는 모니터링 |
| 함께 설치되는 항목 | button, checkbox, input, slider, switch |
| Keywords | exclusive, 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
- An ops, support or trading console stays open all day and people need to know what just happened without looking at it
- A kitchen display, warehouse or front-desk screen must signal different event types to staff who are busy with their hands
- A CI or monitoring dashboard on a second screen should make a failure sound different from routine successes
- A chat or queue app already plays one generic ping and users cannot tell a new message from an escalation
Avoid when
- The real problem is too many alerts: decide which ones deserve to interrupt before giving them sounds → use Cry Wolf instead
- The event needs a message, actions or an undo, not just recognition → use Toast instead
- The page is a marketing site or content people read once: sound there is noise, whatever the opt-in
설치
node scripts/add.mjs leitmotif --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["leitmotif"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, components/checkbox/checkbox.css, components/input/input.css, core/dom.js, core/element.js, components/slider/slider.js, components/slider/slider.css, components/switch/switch.css, core/dismiss.js, core/motion.js, core/position.js, components/leitmotif/leitmotif.js, components/leitmotif/leitmotif.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-leitmotif listen="order-created:new-order payment-failed:payment-failed">
<span data-motif="new-order" data-pattern="chime">New order</span>
<span data-motif="payment-failed" data-pattern="alert" data-priority="urgent">Payment failed</span>
</mv-leitmotif>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<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 checking ···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>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
listen | string | Space- 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-on | window | document | self | CSS selector | window | Where listen events are caught (window also receives bubbling events from anywhere in the page). |
storage-key | string | Persists 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). | |
channel | string | Tabs 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. | |
coalesce | time ("1.5s", "800ms") | 1.5s | Events 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-rate | number | 15 | Maximum non-urgent sounds per minute across all motifs; beyond it cues only flash (reason "rate-limit"). Urgent motifs are never rate limited. |
display | chip | inline | chip | chip: 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. |
flash | start | end | bottom | none | start | Where 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-duration | time | 2.8s | How 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. |
announce | boolean | Also 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. | |
label | string | Sound cues | Label of the chip and title of the panel. |
placement | bottom-end | bottom-start | bottom | top-end | top-start | top | bottom-end | Preferred side of the settings popover (flips and shifts to stay in the viewport). |
data-motif / data-pattern / data-priority / data-tone / data-timbre | on children | Declarative 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-sound | set by the component | data-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
| Name | 유형 | Description |
|---|---|---|
motifs | Array<{ 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 / volume | boolean / number | Shortcuts into settings. |
unlocked | boolean | True when the audio context is running (read-only). |
quiet | boolean | True while the current local time is inside enabled quiet hours (read-only). |
strings | Partial<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
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-motif | Cancelable, 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-merge | An event was folded into a recent cue of the same motif. detail: { id, label, count (total in the window), added, text }. |
mv-settings | The 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
| Name | Description |
|---|---|
(children with data-motif) | Declarative motif definitions, hidden. Anything else inside the element is left as is. |
CSS classes
| Name | Description |
|---|---|
mv-leitmotif-chip | The settings button (-chip-icon, -chip-label, -chip-state). |
mv-leitmotif-rail / mv-leitmotif-flash | aria-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-note | Mini 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-panel | Settings: -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
| Name | Default | Description |
|---|---|---|
--mv-leitmotif-urgent | var(--mv-danger) | Color of urgent motifs and of the Urgent label. |
--mv-leitmotif-accent / -info / -success / -warning / -danger | the matching token | Tone palette of motifs (a motif's color property overrides it). |
--mv-leitmotif-glyph-width / --mv-leitmotif-glyph-height / --mv-leitmotif-note-size | 1.75rem / 1rem / 3px | Size 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.