Reclaim Space for play/stop; narrow the keyboard focus guard

The real conflict wasn't the key: shortcuts bailed whenever ANY form
control (slider/checkbox/menu/button) had focus, and those keep focus
after use — so P/T/A/N seemed dead most of the time.

- Guard now stands down only for text-entry fields (text/number/textarea/
  contenteditable), so shortcuts work right after you touch a slider or
  checkbox.
- Space always = play/stop (preventDefault so it won't scroll, toggle a
  focused checkbox, or re-fire a focused button) — the DAW standard,
  which also fixes the original Space/checkbox conflict.
- Arrow keys still defer to a focused range slider / select.
- Legend, help overlay, README updated back to Space.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-25 08:08:37 -05:00
parent 9cb7c2c193
commit c2a88e5014
2 changed files with 20 additions and 17 deletions

View file

@ -105,7 +105,7 @@ In the setlist panel's **⋯** menu:
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| `P` | play / stop | | `Space` | play / stop (except while typing in a text field) |
| `T` | tap tempo | | `T` | tap tempo |
| `↑` / `↓` | tempo ±1 (`Shift` = ±10) | | `↑` / `↓` | tempo ±1 (`Shift` = ±10) |
| `A` | add meter lane | | `A` | add meter lane |

View file

@ -192,7 +192,7 @@
<div class="row" style="align-items:baseline; justify-content:space-between; gap:14px; margin-bottom:12px"> <div class="row" style="align-items:baseline; justify-content:space-between; gap:14px; margin-bottom:12px">
<h1 style="margin:0">Stackable Metronome <span class="lane-meta" id="appVersion" title="build version">v0.0.1-dev</span></h1> <h1 style="margin:0">Stackable Metronome <span class="lane-meta" id="appVersion" title="build version">v0.0.1-dev</span></h1>
<div style="display:flex; align-items:center; gap:10px"> <div style="display:flex; align-items:center; gap:10px">
<span class="kbd-legend">P play · T tap · ↑↓ tempo (⇧×10) · A add · N next · ? help</span> <span class="kbd-legend">Space play · T tap · ↑↓ tempo (⇧×10) · A add · N next · ? help</span>
<button id="themeBtn" title="toggle light / dark theme"></button> <button id="themeBtn" title="toggle light / dark theme"></button>
<button id="helpBtn" title="keyboard shortcuts (?)">?</button> <button id="helpBtn" title="keyboard shortcuts (?)">?</button>
</div> </div>
@ -314,7 +314,7 @@
<div class="overlay-box"> <div class="overlay-box">
<div class="tray-head"><h2 style="margin:0">Keyboard shortcuts</h2><button class="x" id="shortcutsClose" style="margin-left:0"></button></div> <div class="tray-head"><h2 style="margin:0">Keyboard shortcuts</h2><button class="x" id="shortcutsClose" style="margin-left:0"></button></div>
<table class="kbd-table"> <table class="kbd-table">
<tr><td><kbd>P</kbd></td><td>Play / stop</td></tr> <tr><td><kbd>Space</kbd></td><td>Play / stop (works everywhere except while typing in a text field)</td></tr>
<tr><td><kbd>T</kbd></td><td>Tap tempo</td></tr> <tr><td><kbd>T</kbd></td><td>Tap tempo</td></tr>
<tr><td><kbd></kbd> <kbd></kbd></td><td>Tempo ±1 BPM</td></tr> <tr><td><kbd></kbd> <kbd></kbd></td><td>Tempo ±1 BPM</td></tr>
<tr><td><kbd>⇧↑</kbd> <kbd>⇧↓</kbd></td><td>Tempo ±10 BPM</td></tr> <tr><td><kbd>⇧↑</kbd> <kbd>⇧↓</kbd></td><td>Tempo ±10 BPM</td></tr>
@ -1191,23 +1191,26 @@ $("shareQrExt").addEventListener("click", () => {
window.open("https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=" + encodeURIComponent($("shareUrl").value), "_blank", "noopener"); window.open("https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=" + encodeURIComponent($("shareUrl").value), "_blank", "noopener");
}); });
window.addEventListener("keydown", (e) => { window.addEventListener("keydown", (e) => {
const t = e.target; const t = e.target, tag = t ? t.tagName : "", type = (t && t.type ? String(t.type) : "").toLowerCase();
if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; // Text entry is sacred — never hijack typing in a text field.
if (t && (t.isContentEditable || tag === "TEXTAREA" ||
(tag === "INPUT" && /^(text|number|search|email|url|tel|password)$/.test(type)))) return;
const k = e.key; const k = e.key;
if (e.altKey && (k === "ArrowUp" || k === "ArrowDown")) { e.preventDefault(); moveActiveItem(k === "ArrowUp" ? -1 : 1); return; } // reorder selected item if (e.altKey && (k === "ArrowUp" || k === "ArrowDown")) { e.preventDefault(); moveActiveItem(k === "ArrowUp" ? -1 : 1); return; } // reorder selected item
if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.metaKey || e.ctrlKey || e.altKey) return;
if (k === "p" || k === "P") { e.preventDefault(); toggleTransport(); } // Transport: Space always = play/stop. preventDefault so it never scrolls the
else if (k === "t" || k === "T") { tapTempo(); } // page, toggles a focused checkbox, or re-fires a focused button.
else if (k === "ArrowUp") { e.preventDefault(); setBpm(state.bpm + (e.shiftKey ? 10 : 1)); } if (k === " " || e.code === "Space") { e.preventDefault(); toggleTransport(); return; }
else if (k === "ArrowDown") { e.preventDefault(); setBpm(state.bpm - (e.shiftKey ? 10 : 1)); } // Leave arrow keys to a focused slider / menu so they still adjust it.
else if (k === "a" || k === "A") { addMeter("4", 1, "claves"); } const arrowCtrl = tag === "SELECT" || (tag === "INPUT" && type === "range");
else if (k === "n" || k === "N") { nextItem(); } if (k === "ArrowUp") { if (arrowCtrl) return; e.preventDefault(); setBpm(state.bpm + (e.shiftKey ? 10 : 1)); return; }
else if (k === "?") { toggleShortcuts(true); } if (k === "ArrowDown") { if (arrowCtrl) return; e.preventDefault(); setBpm(state.bpm - (e.shiftKey ? 10 : 1)); return; }
else if (k === "Escape") { if (!$("shareOverlay").hidden) $("shareOverlay").hidden = true; else if (!$("shortcutsOverlay").hidden) toggleShortcuts(false); } if (k === "t" || k === "T") { tapTempo(); return; }
else if (k >= "1" && k <= "9") { if (k === "a" || k === "A") { addMeter("4", 1, "claves"); return; }
const m = meters[+k - 1]; if (k === "n" || k === "N") { nextItem(); return; }
if (m) setLaneEnabled(m, !m.enabled); if (k === "?") { toggleShortcuts(true); return; }
} if (k === "Escape") { if (!$("shareOverlay").hidden) $("shareOverlay").hidden = true; else if (!$("shortcutsOverlay").hidden) toggleShortcuts(false); return; }
if (k >= "1" && k <= "9") { const m = meters[+k - 1]; if (m) setLaneEnabled(m, !m.enabled); }
}); });
/* init */ /* init */