익스클루시브
Drill <mv-drill>
앱의 단축키를 짧은 간격 반복 훈련으로 반사 동작이 되게 만드는 키보드 단축키 트레이너입니다.
문화적 레퍼런스
베스트 키드, 존 G. 아빌드센(1984년, 영화). 스승은 제자에게 왁스칠과 페인트칠 같은 평범한 허드렛일을 정확한 동작으로 반복시켜 반사 동작이 되게 하고, 나중에야 그것이 처음부터 막기 기술이었음을 밝힙니다. UI에서는 앱의 단축키를 간격 반복으로 한 번에 하나의 실제 작업씩 훈련해 각 응답이 시간 측정된 반사 동작이 될 때까지 연습하고, 훈련이 끝난 뒤에야 컴포넌트가 그 반복으로 쌓아 온 워크플로를 공개합니다.
작동 방식
앱의 단축키를 짧은 간격 반복 훈련으로 반사 동작이 되게 만드는 키보드 단축키 트레이너입니다. 동작은 moves 속성({ id, keys, label, prompt, context, uses, hint })에서 가져오거나 페이지에서 읽어 오므로(source 안의 aria-keyshortcuts, data-shortcut, data-combo), 이미 단축키를 선언한 컨트롤이 그대로 트레이너의 재료가 됩니다. 세션은 한 번에 하나의 실제 작업(“Archive this email”과 그 컨텍스트)을 제시하고, 포커스된 연습 패드는 시퀀스(“G then I”)를 포함한 실제 조합 키를 감지합니다. 키캡은 플랫폼에 맞게 표시되고(⌘⇧U와 Ctrl+Shift+U), 비라틴 자판 배열에서는 물리 키 기준으로 일치 여부를 판단하며, 앱 자체는 이 키 입력을 받지 않습니다. 피드백은 즉각적입니다: 누르고 있는 보조 키가 실시간으로 표시되고, 잘못된 조합은 취소선으로 보여 주며, 두 번 틀리면 힌트(보조 키나 첫 단계는 보이고 키는 가려짐)가, 세 번 틀리면 정답이 나타나고, 사용자는 그래도 한 번은 직접 눌러야 합니다. 반응 시간은 실시간 미터에서 반사 동작 목표치와 비교해 측정되고, 동작은 라이트너 상자로 일정이 잡힙니다: 첫 시도에 맞히면 한 상자 위로 올라가고, 틀리면 그대로 머물며, 정답을 보면 상자 1로 돌아가 같은 세션에서 나중에 다시 나옵니다. 동작은 마지막 상자에 도달하고 목표치보다 빠르게 응답했을 때만 반사 동작으로 인정됩니다. 숙련도 그리드는 모든 동작의 레벨, 반사 시간, 추세, 스파크라인을 보여 주며(타일을 클릭하면 시간 측정 반복 3회), 요약 화면에서는 그 허드렛일이 무엇을 위한 것이었는지 드러납니다: 훈련한 동작들을 연결하는 기법(“Inbox zero in two minutes: J → E → R → ⌘↩”)이 해당 동작을 익힐 때마다 잠금 해제됩니다. 패시브 팁 모드: 단축키가 있는 컨트롤을 마우스로 클릭하면 빈도가 제한되고 닫을 수 있는 “Press E to archive” 팁이 표시되며, 이후 그 단축키를 실제로 사용하면 숙련도에 반영됩니다. 진행 상황은 storage-key(옵트인)나 progress 속성을 통해 사용자별로 유지됩니다.
| 카테고리 | 유틸리티 |
|---|---|
| 유형 | Web Component (<mv-drill>) |
| 상태 | 안정 |
| 키트 | 파워 유저를 위한 내비게이션 |
| 함께 설치되는 항목 | button, kbd |
| Keywords | exclusive, culture, keyboard, shortcuts, hotkeys, training, trainer, onboarding, learning, spaced-repetition, leitner, reaction-time, reflex, mastery, tips, discoverability, power-user, aria-keyshortcuts, wcag-2.1.4, gamification |
When to use
- A power-user web app (mail, tracker, editor, spreadsheet, design tool) wants users to actually adopt its keyboard shortcuts
- Onboarding should turn a cheat sheet into practice: short timed sessions with spaced review instead of a list to memorize
- Mouse users should discover the shortcut of the button they just clicked, without a tour or a modal
- A team wants per-user data on which shortcuts are known, slow or never used before promoting or remapping them
Avoid when
- The need is to bind shortcuts to actions, show a live buffer and let users remap keys, not to train them → use Combo instead
- Users should act on what is on screen from the keyboard without memorizing anything → use Keen Sight instead
- The shortcuts are browser-owned chords (Ctrl/⌘+W, T, N, Q) or use Tab: a web page cannot capture them, so they cannot be drilled
설치
node scripts/add.mjs drill --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["drill"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, components/kbd/kbd.css, core/dom.js, core/element.js, core/motion.js, core/position.js, components/drill/drill.js, components/drill/drill.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<div id="mail"><button data-shortcut="e">Archive</button> <button data-shortcut="r">Reply</button></div>
<mv-drill label="Mail shortcuts" source="#mail"></mv-drill>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="dr-demo" style="width:min(100%,68rem);margin-inline:auto">
<style>
#dr-demo { display:grid; grid-template-columns:minmax(0,22rem) minmax(0,1fr); gap:1.25rem; align-items:start }
#dr-demo .dr-side { display:grid; gap:1rem; align-content:start }
#dr-demo .dr-mail { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden; outline:none }
#dr-demo .dr-mail:focus-visible { box-shadow:var(--mv-focus-ring) }
#dr-demo .dr-top { display:flex; align-items:center; gap:.5rem; padding:.625rem .75rem; border-bottom:1px solid var(--mv-border) }
#dr-demo .dr-inbox { display:inline-flex; align-items:center; gap:.375rem; padding:.25rem .5rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-muted); color:var(--mv-fg); font-size:.8125rem; font-weight:600; text-decoration:none }
#dr-demo .dr-inbox b { padding:0 .375rem; border-radius:var(--mv-radius-full); background:var(--mv-accent); color:var(--mv-fg-on-accent); font-size:.6875rem; line-height:1.125rem }
#dr-demo .dr-search { flex:1; min-width:0; height:2rem; padding:0 .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle); color:var(--mv-fg); font:inherit; font-size:.8125rem }
#dr-demo .dr-search:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
#dr-demo .dr-tools { display:flex; align-items:center; gap:.125rem; padding:.375rem .5rem; border-bottom:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#dr-demo .dr-tools .mv-button svg { width:1rem; height:1rem }
#dr-demo .dr-sep { width:1px; height:1.25rem; margin:0 .25rem; background:var(--mv-border) }
#dr-demo .dr-list { list-style:none; margin:0; padding:.25rem }
#dr-demo .dr-msg { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:.125rem .625rem; align-items:center; padding:.625rem .625rem; border-radius:var(--mv-radius-md); cursor:pointer }
#dr-demo .dr-msg + .dr-msg { margin-top:1px }
#dr-demo .dr-msg[aria-selected="true"] { background:color-mix(in oklab, var(--mv-accent) 9%, transparent); box-shadow:inset 2px 0 0 var(--mv-accent) }
#dr-demo .dr-av { grid-row:span 2; display:grid; place-items:center; width:2rem; height:2rem; border-radius:50%; background:var(--mv-bg-emphasis); font-size:.6875rem; font-weight:600 }
#dr-demo .dr-from { overflow:hidden; font-size:.8125rem; font-weight:500; text-overflow:ellipsis; white-space:nowrap }
#dr-demo .dr-msg[data-unread] .dr-from, #dr-demo .dr-msg[data-unread] .dr-subj { font-weight:650; color:var(--mv-fg) }
#dr-demo .dr-time { color:var(--mv-fg-subtle); font-size:.6875rem; font-variant-numeric:tabular-nums }
#dr-demo .dr-subj { grid-column:2 / 4; overflow:hidden; color:var(--mv-fg-muted); font-size:.75rem; text-overflow:ellipsis; white-space:nowrap }
#dr-demo .dr-star { color:var(--mv-warning); margin-inline-end:.25rem }
#dr-demo .dr-status { margin:0; padding:.5rem .875rem .625rem; border-top:1px solid var(--mv-border); color:var(--mv-fg-muted); font-size:.75rem; min-height:1rem }
#dr-demo .dr-panel { display:grid; gap:.75rem; padding:.875rem 1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
#dr-demo .dr-panel h4 { margin:0; color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.06em; text-transform:uppercase }
#dr-demo .dr-row { display:flex; align-items:center; justify-content:space-between; gap:.75rem; font-size:.8125rem }
#dr-demo .dr-log { margin:0; color:var(--mv-fg-subtle); font:.75rem/1.45 var(--mv-font-mono); min-height:2.2rem }
#dr-demo .dr-hint { margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.5 }
@media (max-width:56rem) { #dr-demo { grid-template-columns:minmax(0,1fr) } }
</style>
<div class="dr-side">
<!-- The product: a mail client whose controls declare their shortcuts (aria-keyshortcuts / data-shortcut). -->
<section class="dr-mail" id="dr-mail" tabindex="0" aria-label="Mail" data-drill-context="Inbox">
<div class="dr-top">
<a href="#inbox" class="dr-inbox" data-shortcut="g i" data-drill-id="inbox" data-drill-label="Go to Inbox" data-drill-prompt="Jump back to the Inbox" data-drill-context="Anywhere" data-drill-uses="From any folder or search result, you are back at the top of the Inbox in two keys.">Inbox <b>4</b></a>
<input class="dr-search" type="search" placeholder="Search mail" aria-label="Search mail" data-shortcut="/" data-drill-id="search" data-drill-label="Search" data-drill-prompt="Search your mail" data-drill-context="Anywhere" data-drill-uses="Find the thread you need without scrolling: / then type.">
</div>
<div class="dr-tools" role="toolbar" aria-label="Message actions">
<button type="button" class="mv-button" data-variant="ghost" data-size="icon" aria-label="Archive" aria-keyshortcuts="E" data-drill-id="archive" data-drill-prompt="Archive this email" data-drill-uses="Clear anything that needs no answer in one keystroke." data-act="archive"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="5" rx="1"/><path d="M5 9v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9"/><path d="M10 13h4"/></svg></button>
<button type="button" class="mv-button" data-variant="ghost" data-size="icon" aria-label="Reply" aria-keyshortcuts="R" data-drill-id="reply" data-drill-prompt="Reply to Priya" data-drill-uses="Answer straight away, then send without leaving the keyboard." data-act="reply"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/></svg></button>
<button type="button" class="mv-button" data-variant="ghost" data-size="icon" aria-label="Star" aria-keyshortcuts="S" data-drill-id="star" data-drill-prompt="Star this email for later" data-act="star"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2L12 17.3 6.4 20.2l1.1-6.2L3 9.6l6.2-.9z"/></svg></button>
<button type="button" class="mv-button" data-variant="ghost" data-size="icon" aria-label="Mark as unread" aria-keyshortcuts="Shift+U" data-drill-id="unread" data-drill-prompt="Mark it as unread" data-act="unread"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg></button>
<span class="dr-sep" aria-hidden="true"></span>
<button type="button" class="mv-button" data-variant="ghost" data-size="icon" aria-label="Previous email" aria-keyshortcuts="K" data-drill-id="prev" data-drill-prompt="Go back to the previous email" data-act="prev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m18 15-6-6-6 6"/></svg></button>
<button type="button" class="mv-button" data-variant="ghost" data-size="icon" aria-label="Next email" aria-keyshortcuts="J" data-drill-id="next" data-drill-prompt="Open the next email" data-drill-uses="Walk down the Inbox one email at a time, hands on the home row." data-act="next"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></button>
<span style="flex:1"></span>
<button type="button" class="mv-button" data-size="sm" aria-keyshortcuts="C" data-drill-id="compose" data-drill-label="Compose" data-drill-prompt="Start a new email" data-act="compose">Compose</button>
</div>
<ul class="dr-list" id="dr-list" role="listbox" aria-label="Inbox messages"></ul>
<p class="dr-status" id="dr-status" aria-live="polite">Click a toolbar button with the mouse to get a tip.</p>
</section>
<div class="dr-panel">
<h4>Settings</h4>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="dr-tips" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Tips when using the mouse</span></span>
</label>
<div class="dr-row"><span id="dr-plat-l">Show keys for</span>
<mv-segmented id="dr-plat" aria-labelledby="dr-plat-l" value="auto">
<button value="auto">Auto</button>
<button value="mac">Mac</button>
<button value="windows">Windows</button>
</mv-segmented>
</div>
<p class="dr-log" id="dr-log" aria-hidden="true">Progress is saved in this browser.</p>
<div class="dr-row">
<p class="dr-hint">Start the drill and answer with the real keys: the mail client never sees them.</p>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="dr-reset">Reset</button>
</div>
</div>
</div>
<mv-drill id="dr-drill" label="Mail shortcuts" source="#dr-mail" storage-key="relay-mail-demo" tips tip-interval="4s" length="6" reflex="1.5s"></mv-drill>
<script type="module">
const drill = document.getElementById("dr-drill");
const mail = document.getElementById("dr-mail");
const list = document.getElementById("dr-list");
const status = document.getElementById("dr-status");
const log = document.getElementById("dr-log");
/* ── A small, real mail client ─────────────────────────── */
const inbox = [
{ from: "Priya Raman", initials: "PR", subject: "Q3 invoice #4821 · $2,480.00 due Oct 15", time: "9:41 AM", unread: true },
{ from: "Lukas Becker", initials: "LB", subject: "Berlin offsite: final agenda and hotel details", time: "8:15 AM", unread: true },
{ from: "Amara Okafor", initials: "AO", subject: "Re: Design review moved to Thursday", time: "Yesterday" },
{ from: "Hiroshi Tanaka", initials: "HT", subject: "Contract redlines for the Osaka partnership", time: "Sep 22" },
{ from: "Sofia Alvarez", initials: "SA", subject: "Your flight to Lisbon is confirmed", time: "Sep 21" },
];
let selected = 0;
const paint = () => {
list.replaceChildren(...inbox.slice(0, 4).map((m, i) => {
const li = document.createElement("li");
li.className = "dr-msg";
li.setAttribute("role", "option");
li.setAttribute("aria-selected", String(i === selected));
if (m.unread) li.dataset.unread = "";
const av = Object.assign(document.createElement("span"), { className: "dr-av", textContent: m.initials });
av.setAttribute("aria-hidden", "true");
const from = Object.assign(document.createElement("span"), { className: "dr-from", textContent: m.from });
const time = Object.assign(document.createElement("span"), { className: "dr-time", textContent: m.time });
const subj = Object.assign(document.createElement("span"), { className: "dr-subj" });
if (m.starred) subj.append(Object.assign(document.createElement("span"), { className: "dr-star", textContent: "★" }));
subj.append(m.subject);
li.append(av, from, time, subj);
li.addEventListener("click", () => { selected = i; paint(); });
return li;
}));
};
const act = (name) => {
const m = inbox[selected];
if (name === "archive") { inbox.push(inbox.splice(selected, 1)[0]); status.textContent = `Archived “${m.subject}”.`; }
if (name === "next") selected = Math.min(3, selected + 1);
if (name === "prev") selected = Math.max(0, selected - 1);
if (name === "star") { m.starred = !m.starred; status.textContent = m.starred ? "Starred." : "Star removed."; }
if (name === "unread") { m.unread = true; status.textContent = "Marked as unread."; }
if (name === "reply") status.textContent = `Replying to ${m.from}…`;
if (name === "compose") status.textContent = "New message…";
if (name === "inbox") { selected = 0; status.textContent = "Inbox."; }
if (name === "search") document.querySelector("#dr-mail .dr-search").focus();
paint();
};
paint();
mail.addEventListener("click", (e) => {
const b = e.target.closest("[data-act]");
if (b) act(b.dataset.act);
if (e.target.closest(".dr-inbox")) { e.preventDefault(); act("inbox"); }
});
// The app's own shortcuts, active while focus is in the mail client (outside the search field).
const KEYS = { e: "archive", r: "reply", s: "star", j: "next", k: "prev", c: "compose", "/": "search" };
let g = 0;
mail.addEventListener("keydown", (e) => {
if (e.target.closest("input") || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key === "U") { act("unread"); return; }
if (e.key === "g") { g = Date.now(); return; }
if (e.key === "i" && Date.now() - g < 1500) { act("inbox"); return; }
const name = KEYS[e.key];
if (name) { e.preventDefault(); act(name); }
});
/* ── The drill ─────────────────────────────────────────── */
await customElements.whenDefined("mv-drill");
// One move lives in the compose window, which is not on screen: declared as a property.
drill.moves = [
{ id: "send", keys: "Mod+Enter", label: "Send", prompt: "Send your reply", context: "Compose", uses: "Reply and send in one motion: R, type, then Mod+Enter.", hint: "The key you’d press to submit, held with the command key." },
];
drill.techniques = [
{ title: "Inbox zero in two minutes", moves: ["next", "archive", "reply", "send"], text: "Open, decide, archive or answer, move on: about four seconds per email, and the mouse never moves." },
{ title: "Find it, flag it, get back", moves: ["search", "star", "unread", "inbox"], text: "Pull up a thread, keep it for later and land back at the top of the Inbox." },
];
// First visit: a few weeks of practice already recorded, so the grid has a story to tell.
let stored = null;
try { stored = localStorage.getItem("mv-drill:relay-mail-demo"); } catch {}
if (!stored) {
drill.progress = {
v: 1, session: 6,
moves: {
next: { box: 5, due: 22, seen: 6, right: 6, times: [1840, 1320, 1010, 820, 690, 610], best: 610 },
prev: { box: 4, due: 12, seen: 5, right: 4, misses: 1, times: [1990, 1610, 1180, 1050, 960], best: 960 },
archive: { box: 3, due: 7, seen: 4, right: 3, misses: 1, times: [2210, 1720, 1490, 1260], best: 1260 },
reply: { box: 3, due: 7, seen: 4, right: 3, misses: 1, times: [1650, 1540, 1580, 1470], best: 1470 },
star: { box: 2, due: 7, seen: 3, right: 2, misses: 1, times: [2480, 2050], best: 2050 },
search: { box: 2, due: 9, seen: 2, right: 2, times: [1380, 1160], best: 1160 },
unread: { box: 1, due: 7, seen: 2, right: 0, misses: 2, times: [3120], best: 3120 },
send: { box: 1, due: 7, seen: 1, right: 0, misses: 1, times: [], best: null },
},
};
}
const say = (text) => { log.textContent = text; };
drill.addEventListener("mv-drill-answer", (e) => {
const d = e.detail;
if (d.source === "use") say(`${d.label}: used for real after a tip · level ${d.previousBox} → ${d.box}`);
else if (d.correct) say(`${d.label}: ${d.firstTry ? "first try" : `after ${d.attempts} miss${d.attempts > 1 ? "es" : ""}`}${d.time ? ` · ${(d.time / 1000).toFixed(2)} s` : ""} · level ${d.previousBox} → ${d.box}`);
else say(`${d.label}: skipped · back to level ${d.box}`);
});
drill.addEventListener("mv-mastered", (e) => say(`${e.detail.label} is now a reflex.`));
drill.addEventListener("mv-tip", (e) => say(`Tip shown for ${e.detail.label}.`));
drill.addEventListener("mv-drill-end", (e) => say(`Session done · ${e.detail.firstTry}/${e.detail.total} first try.`));
document.getElementById("dr-tips").addEventListener("change", (e) => { drill.tips = e.target.checked; drill.tipsEnabled = e.target.checked; });
document.getElementById("dr-plat").addEventListener("mv-change", (e) => { drill.platform = e.detail.value; });
document.getElementById("dr-reset").addEventListener("click", () => { drill.reset(); say("Progress cleared."); });
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
label | string | Shortcut drill | Heading of the component (also its accessible name as a region). |
source | CSS selector | "document" | Region(s) scanned for controls that declare a shortcut: aria-keyshortcuts (ARIA syntax, “Control+Shift+E Meta+Shift+E” collapses to Mod), data-shortcut or data-combo (drill grammar). Rescanned when those attributes or the DOM change. Scanned controls are also the anchors of passive tips. | |
length | number | 8 | Maximum prompts per session before repeats (missed moves come back once, three prompts later). |
new-limit | number | 4 | Maximum never-seen moves introduced per session (reviews always come first, lowest box first). |
reflex | time ("1.5s", "1200ms", ms number) | 1.5s | Reflex target. The meter marks it; the last box is only reached by a first-try answer under it (“Correct, not a reflex yet” otherwise). |
storage-key | string | Opt-in persistence of progress and the tips choice in localStorage under mv-drill:<key>. Omit it and use the progress property and mv-drill-save to store progress server-side. | |
tips | boolean | Passive mode: a mouse or pen click on a control that has a shortcut shows a tip anchored to it (at most one per tip-interval, three per move ever, never for reflexes or keyboard/touch activations), and keystrokes are observed (never intercepted) to credit a tip that was acted on. | |
tip-interval | time | 20s | Minimum delay between two tips. |
platform | auto | mac | windows | linux | auto | How keys are displayed (⌘ ⌥ ⇧ ⌃ vs Ctrl Alt Shift). Display only: Mod always matches the real platform’s key. |
data-state | idle | prompt | correct | done | Set by the component (styleable). data-paused="blur | key | api" while a prompt waits for focus to come back. | |
data-shortcut / data-combo / aria-keyshortcuts | on page controls | Keys of a scanned move. data-shortcut and data-combo use the drill grammar: “e”, “Shift+u” (or “U”), “Mod+Enter”, sequences separated by spaces (“g i”), alternatives by commas. | |
data-drill-id / -label / -prompt / -context / -uses / -hint / -tip | on page controls | Move details for scanned controls: stable id (default: the element id, then a slug of the label), label (default aria-label, then text), the task shown in the drill (default: label), context chip (also inherited from the closest [data-drill-context]), payoff sentence shown after the drill, mnemonic hint, custom tip text with {keys} and {action}. | |
data-drill="off" | on page controls or containers | Excludes a control (or a whole area) from scanning. |
Properties
| Name | 유형 | Description |
|---|---|---|
moves | Array<{ id?, keys, label, prompt?, context?, uses?, hint?, tip?, target? }> | Moves declared from JS, merged with scanned ones (same id: the property wins, the scanned element becomes its tip anchor). target is a selector used to anchor tips for controls that are not scanned. Reading returns every move with { display, box, level, mastered, due, reflex, best, attempts, firstTry, realUses, element, source } (realUses counts real uses of the shortcut outside drills; uses stays the declared payoff sentence). |
techniques | Array<{ title, moves: string[], text? }> | Workflows that chain moves. After a session, those containing a drilled move are revealed with their keycap sequence; each shows “Ready to use” once all its moves have been answered on the first try at least once, or how many are left. |
progress | { v: 1, session, tipsOff, moves: Record<id, { box, due, seen, right, misses, times, best, uses, tips, tipPending, lastUse }> } | Serializable progress. Read it after mv-drill-save to store it; set it from your server (it replaces the current progress and is written to storage-key if present). |
tipsEnabled | boolean | The user-level tips switch (the “Turn off tips” link sets it to false); persisted with the progress. Effective only when the tips attribute is present. |
state | "idle" | "prompt" | "correct" | "done" | Current phase (read-only). |
currentSession | { number, index, total, practice, results } | null | The running or last session (read-only). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (title, summary, idleTitle, idleText, idleTextOne, idleNone, idleEmpty, more, start, practice, stageLabel, instructions, press, pressNext, correct, reflex, best, slow, wrong, hintLabel, hintChord, hintSingle, hintSequence, answer, nowYou, paused, pausedFocus, pausedKey, hint, reveal, skip, end, progress, repeatTag, doneTitle, statFirst, statReflex, statPromoted, statMastered, techniquesTitle, usesTitle, locked, lockedOne, unlocked, again, close, gridTitle, gridCount, level0…level5, due, tileSpoken, reflexSpoken, noReflex, faster, slower, steady, tip, tipUsed, tipDismiss, tipOff, announce*, seconds, secondsSpoken, then). English defaults. |
label / source / length / newLimit / reflex / storageKey / tips / tipInterval / platform | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
start({ ids?, length?, practice?, reps? }) | Starts a session: by default what is due (reviews by lowest box, then up to new-limit new moves); ids drills specific moves; practice: true records times without moving boxes; reps repeats each move. Focuses the practice pad. Returns false if nothing to drill, a session is running, or mv-drill-start was canceled. |
practiceMove(id, reps = 3) | Timed reps of one move that don’t change its box (also what clicking a mastery tile does). |
stop() | Ends the session; the summary shows if at least one prompt was answered. |
pause() / resume() | Holds the current prompt (paused time never counts in the reaction time). |
showTip(id, anchor?) | Shows the tip of a move now, anchored to anchor, its scanned element or its target. Returns false if vetoed or unknown. |
recordUse(id) | Reports a real use of a move from your own shortcut handler (same effect as an observed keystroke in tips mode). |
format(keys) | Display string on the current platform: “Mod+Shift+u” → “⌘⇧U” or “Ctrl+Shift+U”; sequences with “then”. |
refresh() | Rescans source now. |
reset() | Clears all progress (and the stored copy). |
Events
| Name | Description |
|---|---|
mv-drill-start | Cancelable, before a session starts. detail: { moves: string[], practice }. |
mv-drill-answer | A prompt was resolved, or a tip was acted on for real. detail: { id, label, keys, correct, firstTry, attempts, hinted, revealed, skipped, time (ms, null when revealed or from real use), pressed (wrong combos typed), box, previousBox, repeat, practice, source: "drill" | "use" }. |
mv-mastered | A move reached the last box: it is a reflex. detail: { id, label, keys, reflex (median of the last three times, ms), source }. |
mv-tip | Cancelable, before a passive tip is shown. detail: { id, label, keys, target, forced (true from showTip()) }. preventDefault() skips it (e.g. during a critical flow). |
mv-drill-end | The session summary. detail: { results, firstTry, total, medianReflex, promoted, mastered, practice, session }. |
mv-drill-save | Progress changed (answer, tip, real use, settings). detail: { progress }. |
CSS classes
| Name | Description |
|---|---|
mv-drill-head | Header: .mv-drill-title, .mv-drill-summary, .mv-drill-meta (.mv-drill-count and .mv-drill-pips > .mv-drill-pip with data-result="first | late | missed", data-current, data-repeat). |
mv-drill-idle | Ready panel: -idle-icon, -idle-title, -idle-text, .mv-drill-preview > .mv-drill-chip (data-level), .mv-drill-start. |
mv-drill-pad | The focused practice pad (role="application"): .mv-drill-card (-context, -repeat, -prompt, .mv-drill-well[data-mode="empty | held | partial | wrong | correct | hint | reveal"] with .mv-drill-cap keycaps, data-mask on hidden keys, .mv-drill-feedback[data-tone], .mv-drill-meter with -meter-fill, -meter-target, -meter-time) and .mv-drill-veil when paused. |
mv-drill-bar | Session controls under the pad: Hint, Show answer, Skip, End session. |
mv-drill-done | Summary: -done-title, .mv-drill-stats > .mv-drill-stat, .mv-drill-reveal with .mv-drill-technique (data-locked; -technique-head, .mv-drill-seq > .mv-drill-seq-move[data-known], -technique-text) or .mv-drill-uses. |
mv-drill-mastery | Mastery grid: .mv-drill-grid > .mv-drill-tile (data-level 0-5, data-mastered) > .mv-drill-tile-btn with -tile-label, .mv-drill-boxes, -level-name, .mv-drill-due, .mv-drill-reflex[data-trend], .mv-drill-spark. |
mv-drill-tip | Passive tip (Popover API top layer; data-state="tip | used"): -tip-icon, -tip-text, -tip-off, -tip-close. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-drill-level | var(--mv-accent) | Caret, level pips, meter, reveal and tip accents. |
--mv-drill-correct | var(--mv-success) | Correct answers, first-try pips, reflexes (mastered tiles), unlocked techniques. |
--mv-drill-wrong | var(--mv-danger) | Wrong combos and missed pips. |
--mv-drill-slow | var(--mv-warning) | Over the reflex target, late answers, repeated prompts. |
--mv-kbd-bg / --mv-kbd-fg / --mv-kbd-border / --mv-kbd-depth | (kbd) | The large keycaps of the pad follow the Kbd variables, so one keycap theme covers both. |
Accessibility
Keys are listened to only on the practice pad, a focusable role="application" element (so screen readers pass keys through) labelled “Shortcut practice pad” and described by its instructions; nothing is captured anywhere else, which keeps single-key moves compliant with WCAG 2.1.4 (active only while the component has focus). Tab and Shift+Tab are never taken, so the pad is never a keyboard trap; Escape pauses, Enter resumes, and moving focus away pauses the prompt behind a “Paused” veil without counting the time. Each prompt (with its context), each result (“Correct, 0.64 seconds.”, “Not quite. You pressed Shift U.”), hints (“these modifiers, plus one key”), answers and the summary are announced in a polite live region, since the visual keycaps of the pad are aria-hidden; keys are always spoken in words per platform (“Command Enter”, “G then I”). Hint, Show answer, Skip and End session are real buttons after the pad that hand focus back to it; at the end, focus moves to the summary heading. Mastery tiles are buttons whose accessible name reads the move, its keys, its level and its reflex trend; displayed keys are <kbd> keycaps with a visually hidden spoken version. Tips only follow mouse or pen clicks (never keyboard or touch activations), are rate-limited, announced politely, never take focus, stay while hovered or focused, close with Escape or their button, and can be turned off for good; real keystrokes are only observed, never prevented, and typing in fields is ignored except for modifier chords. Colors always come with text or shape (struck keys, check mark, level names, “Due”). Reduced motion (OS or data-motion="reduce"): no blinking caret, shake, pop or sliding meter (the time is shown when the answer lands). Forced colors: system colors for pips, levels, the meter and the focus ring.