Refine mockup: set lists, per-beat patterns, theming, shortcuts

- Tempo ramp gains a start BPM and signed (up/down) steps.
- Presets and set-list items now capture practice settings (trainer + ramp).
- Set-list items: per-item ▶/⏹ play, 💾 save-back, per-item history view.
- Log & backup moved into a ⋯ menu; "+ Add meter" moved below the lanes.
- Light/dark theming (OS-aware + toggle, persisted) and mobile/desktop responsive layout.
- Keyboard shortcuts (Space/T/↑↓/A/R/N/1-9/?) + help overlay; fixed dead Tap button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-24 16:35:17 -05:00
parent 49c8584c8c
commit 38d860b4dd

View file

@ -4,6 +4,17 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stackable Metronome — Mockup</title>
<script>
// Set theme before first paint (avoids a flash). Stored choice wins; else
// follow the OS preference.
(function () {
try {
var t = localStorage.getItem("metronome.theme");
if (t !== "light" && t !== "dark") t = matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
document.documentElement.dataset.theme = t;
} catch (e) { document.documentElement.dataset.theme = "dark"; }
})();
</script>
<!--
Browser mockup / simulator for the Pi Pico metronome.
@ -18,26 +29,30 @@
-->
<style>
:root {
--bg:#14171c; --panel:#1d222b; --panel-2:#242b36; --edge:#333d4b;
--bg:#14171c; --bg2:#1b212b; --panel:#1d222b; --panel-2:#242b36; --edge:#333d4b;
--txt:#c7d0db; --muted:#7f8b9a; --hot:#ffd166; --led-off:#2b323d;
}
:root[data-theme="light"] {
--bg:#e9edf2; --bg2:#f6f8fb; --panel:#ffffff; --panel-2:#eef2f7; --edge:#d2dae4;
--txt:#1e2630; --muted:#5c6776; --hot:#a9760a; --led-off:#dbe2ea;
}
* { box-sizing: border-box; }
body {
margin:0; padding:24px;
background: radial-gradient(circle at 50% -10%, #1b212b, var(--bg));
margin:0; padding:24px; min-height:100vh;
background: radial-gradient(circle at 50% -10%, var(--bg2), var(--bg));
color: var(--txt); font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
h1 { font-size:18px; font-weight:600; letter-spacing:.5px; margin:0 0 2px; }
.sub { color:var(--muted); font-size:12px; margin-bottom:18px; }
.kbd-legend { color:var(--muted); font-size:11px; font-family:"Courier New",monospace; text-align:right; }
.device { max-width:1000px; margin:0 auto; background:linear-gradient(180deg,var(--panel),#171c24);
.device { max-width:1000px; margin:0 auto; background:linear-gradient(180deg, var(--panel), var(--bg));
border:1px solid var(--edge); border-radius:16px; padding:18px; box-shadow:0 18px 50px rgba(0,0,0,.5); }
.row { display:flex; gap:18px; flex-wrap:wrap; }
.card { background:var(--panel-2); border:1px solid var(--edge); border-radius:12px; padding:13px; flex:1; min-width:240px; }
.card h2 { font-size:11px; text-transform:uppercase; letter-spacing:1.4px; color:var(--muted); margin:0 0 14px; }
.display { background:#0a0d11; border:1px solid #000; border-radius:8px; padding:8px 14px; text-align:center; box-shadow:inset 0 2px 10px rgba(0,0,0,.7); }
.display .big { font-family:"Courier New",monospace; font-weight:700; font-size:40px; color:var(--hot); letter-spacing:3px; text-shadow:0 0 12px rgba(255,209,102,.5); }
.display .big { font-family:"Courier New",monospace; font-weight:700; font-size:40px; color:#ffd166; letter-spacing:3px; text-shadow:0 0 12px rgba(255,209,102,.5); }
.display .ctx { font-family:"Courier New",monospace; font-size:12px; color:#4dd0e1; height:15px; }
.knob { margin-bottom:10px; }
.knob label { display:flex; justify-content:space-between; font-size:12px; margin-bottom:5px; }
@ -89,9 +104,24 @@
.log-seg { font-size:12px; color:var(--muted); margin:2px 0 0 12px; font-family:"Courier New",monospace; }
.practice { border-top:1px solid var(--edge); margin-top:16px; padding-top:4px; }
#routineToggle { position:fixed; top:16px; right:16px; z-index:40; background:#2c3a4d; border-color:#3b5168; color:#cfe3ff; font-weight:600; }
#routineTray { position:fixed; top:0; right:0; height:100%; width:380px; max-width:92vw; background:linear-gradient(180deg,var(--panel),#171c24); border-left:1px solid var(--edge); box-shadow:-12px 0 40px rgba(0,0,0,.55); transform:translateX(102%); transition:transform .25s ease; z-index:60; padding:18px; overflow:auto; }
#routineTray { position:fixed; top:0; right:0; height:100%; width:380px; max-width:92vw; background:linear-gradient(180deg, var(--panel), var(--bg)); border-left:1px solid var(--edge); box-shadow:-12px 0 40px rgba(0,0,0,.45); transform:translateX(102%); transition:transform .25s ease; z-index:60; padding:18px; overflow:auto; }
#routineTray.open { transform:translateX(0); }
.tray-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
.practice-col { border-left:1px solid var(--edge); padding-left:18px; }
#themeBtn, #helpBtn { padding:4px 11px; }
/* --- responsive --- */
@media (max-width: 760px) {
body { padding: 12px; }
.device { padding: 13px; border-radius:12px; }
.row { gap: 14px; }
/* when the practice column wraps under the others, swap its side rule for a top one */
.practice-col { border-left:none; padding-left:0; border-top:1px solid var(--edge); padding-top:12px; margin-top:4px; }
}
@media (max-width: 620px) {
.kbd-legend { display:none; } /* the ? overlay covers discovery on small screens */
#routineTray { width:100%; }
.meter-card .led { width:24px; height:24px; }
}
.num { width:54px; background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:6px 6px; font-size:13px; text-align:center; }
#helpBtn { padding:4px 11px; }
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; z-index:80; }
@ -101,9 +131,13 @@
.kbd-table td { padding:6px 4px; border-bottom:1px solid var(--edge); }
.kbd-table tr:last-child td { border-bottom:none; }
.kbd-table td:first-child { width:100px; white-space:nowrap; }
kbd { background:var(--panel); border:1px solid var(--edge); border-bottom-width:2px; border-radius:5px; padding:2px 7px; font-family:"Courier New",monospace; font-size:12px; color:#fff; }
kbd { background:var(--panel); border:1px solid var(--edge); border-bottom-width:2px; border-radius:5px; padding:2px 7px; font-family:"Courier New",monospace; font-size:12px; color:var(--txt); }
.setlist-fields textarea { width:100%; background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:8px; font-size:12px; resize:vertical; min-height:40px; }
.play { background:#2e7d32; border-color:#2e7d32; color:#fff; padding:3px 10px; }
.stop { background:#c0392b; border-color:#c0392b; color:#fff; padding:3px 10px; }
.menu { position:absolute; top:36px; right:0; background:var(--panel-2); border:1px solid var(--edge); border-radius:10px; padding:6px; display:flex; flex-direction:column; gap:4px; box-shadow:0 12px 30px rgba(0,0,0,.5); z-index:70; min-width:150px; }
.menu[hidden] { display:none; }
.menu button { text-align:left; }
</style>
</head>
<body>
@ -112,6 +146,7 @@
<h1 style="margin:0">Stackable Metronome <span class="lane-meta">mockup</span></h1>
<div style="display:flex; align-items:center; gap:10px">
<span class="kbd-legend">Space play · T tap · ↑↓ tempo (⇧×10) · A add · R set&nbsp;lists · N next · ? help</span>
<button id="themeBtn" title="toggle light / dark theme"></button>
<button id="helpBtn" title="keyboard shortcuts (?)">?</button>
</div>
</div>
@ -140,15 +175,16 @@
<div class="knob" style="margin-bottom:0"><label>Master Volume <b id="volVal">70%</b></label><input type="range" id="vol" min="0" max="100" value="70"></div>
</div>
<div style="flex:1; min-width:215px; border-left:1px solid var(--edge); padding-left:18px">
<div class="practice-col" style="flex:1; min-width:215px">
<div class="checkrow" style="margin-bottom:8px"><input type="checkbox" id="trainerOn"><label for="trainerOn">Gap / mute trainer</label></div>
<div class="row" style="gap:14px; align-items:center">
<label style="font-size:12px">Play <input type="number" class="num" id="playBars" min="1" max="16" value="2"></label>
<label style="font-size:12px">Mute <input type="number" class="num" id="muteBars" min="0" max="16" value="2"> bars</label>
</div>
<div class="checkrow" style="margin:10px 0 8px"><input type="checkbox" id="rampOn"><label for="rampOn">Tempo ramp</label></div>
<div class="row" style="gap:14px; align-items:center">
<label style="font-size:12px"><input type="number" class="num" id="rampAmt" min="-10" max="10" value="2"> BPM</label>
<div class="row" style="gap:12px 14px; align-items:center; flex-wrap:wrap">
<label style="font-size:12px">from <input type="number" class="num" id="rampStart" min="30" max="300" value="80"> BPM</label>
<label style="font-size:12px" title="negative ramps down, positive ramps up"><input type="number" class="num" id="rampAmt" min="-30" max="30" value="5"> BPM</label>
<label style="font-size:12px">every <input type="number" class="num" id="rampEvery" min="1" max="16" value="4"> bars</label>
</div>
</div>
@ -159,9 +195,9 @@
<div class="row" style="align-items:center; gap:12px; margin:14px 0 8px">
<h2 style="font-size:11px; text-transform:uppercase; letter-spacing:1.4px; color:var(--muted); margin:0">Meter lanes</h2>
<span class="hint" style="margin:0; flex:1">Click a beat pad to toggle it (rest) — e.g. snare on 2 &amp; 4</span>
<button class="add" id="addMeterBtn">+ Add meter</button>
</div>
<div id="meters"></div>
<div style="margin-top:10px"><button class="add" id="addMeterBtn">+ Add meter</button></div>
<!-- (presets moved into the Transport card; set lists live in the slide-out tray) -->
@ -172,7 +208,19 @@
<!-- Routine slide-out tray (from the right) -->
<button id="routineToggle">☰ Routine &amp; Log</button>
<aside id="routineTray">
<div class="tray-head"><h2 style="margin:0">Set Lists</h2><button class="x" id="routineClose" style="margin-left:0"></button></div>
<div class="tray-head">
<h2 style="margin:0">Set Lists</h2>
<div style="display:flex; gap:6px; position:relative">
<button class="x" id="trayMenuBtn" title="log &amp; backup" style="margin-left:0"></button>
<button class="x" id="routineClose" title="close" style="margin-left:0"></button>
<div id="trayMenu" class="menu" hidden>
<button id="exportBtn">⭳ Export all</button>
<button id="importBtn">⭱ Import…</button>
<input type="file" id="importFile" accept="application/json" style="display:none">
<button id="clearLogBtn">🗑 Clear log</button>
</div>
</div>
</div>
<div class="lane-row" style="margin-bottom:8px">
<select class="cmp" id="setlistSelect" style="flex:1"></select>
@ -189,17 +237,9 @@
<button id="addItemBtn">+ Add current settings</button>
</div>
<div id="itemList"></div>
<div class="hint" style="margin-top:6px">▶ loads &amp; starts an item (one click) · press <kbd>N</kbd> to advance to the next.</div>
<div class="hint" style="margin-top:6px">▶ loads &amp; starts an item · <kbd>N</kbd> advances · 💾 saves current settings back to an item.</div>
<div class="tray-head" style="margin-top:22px"><h2 style="margin:0">Log &amp; Backup</h2></div>
<div class="lane-row" style="margin-bottom:8px; flex-wrap:wrap">
<button id="exportBtn">⭳ Export all</button>
<button id="importBtn">⭱ Import…</button>
<input type="file" id="importFile" accept="application/json" style="display:none">
<button id="clearLogBtn">Clear log</button>
</div>
<div class="hint" style="margin:0 0 8px">Export/Import covers presets, set lists &amp; logs.</div>
<div id="logView"></div>
<div id="logView" style="margin-top:18px"></div>
</aside>
<div id="shortcutsOverlay" class="overlay" hidden>
@ -228,7 +268,7 @@
========================================================================= */
const state = { bpm: 120, volume: 0.7, running: false };
const trainer = { on: false, playBars: 2, muteBars: 2 };
const ramp = { on: false, amount: 2, everyBars: 4 };
const ramp = { on: false, startBpm: 80, amount: 5, everyBars: 4 };
let meters = []; // array of meter-lane objects
let meterSeq = 0; // id counter
@ -392,6 +432,7 @@ function scheduler() {
function start() {
ensureAudio(); audioCtx.resume();
state.running = true;
if (ramp.on) setBpm(ramp.startBpm); // ramp begins from its start BPM
const t0 = audioCtx.currentTime + 0.08;
for (const m of meters) { m.tick = 0; m.nextTime = t0; m.vq = []; m.vqPtr = 0; m.currentBeat = -1; m.currentBar = 0; }
masterBeat = 0; masterBeatTime = t0; muteWindows = [];
@ -546,13 +587,13 @@ function applyLanes(lanes) {
}
function savePreset(name) {
const all = lsGet(LS.presets, {});
all[name] = { bpm: state.bpm, lanes: snapshotLanes() };
all[name] = currentSetup();
lsSet(LS.presets, all); refreshPresetList(name);
}
function loadPreset(name) {
const all = lsGet(LS.presets, {}); const p = all[name]; if (!p) return;
const wasRunning = state.running; if (wasRunning) stop();
setBpm(p.bpm); applyLanes(p.lanes); updateCtx();
applySetup(p);
if (wasRunning) start();
}
function deletePreset(name) { const all = lsGet(LS.presets, {}); delete all[name]; lsSet(LS.presets, all); refreshPresetList(); }
@ -573,9 +614,19 @@ let setlists = lsGet(LS.setlists, []);
let activeSL = 0; // index of the selected set list
let playingItem = -1; // index of the item currently playing in the active set list
let nowPlaying = null; // { at, name } for duration logging
let historyName = null; // name of the item whose past-session history is shown
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes() }; }
function applySetup(s) { setBpm(s.bpm); applyLanes(s.lanes); updateCtx(); }
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp } }; }
function applySetup(s) {
setBpm(s.bpm); applyLanes(s.lanes);
if (s.trainer) Object.assign(trainer, s.trainer);
if (s.ramp) Object.assign(ramp, s.ramp);
syncPracticeUI(); updateCtx();
}
function syncPracticeUI() {
$("trainerOn").checked = trainer.on; $("playBars").value = trainer.playBars; $("muteBars").value = trainer.muteBars;
$("rampOn").checked = ramp.on; $("rampStart").value = ramp.startBpm; $("rampAmt").value = ramp.amount; $("rampEvery").value = ramp.everyBars;
}
function fmtDur(sec) { sec = Math.round(sec); const m = Math.floor(sec / 60); return m + ":" + String(sec % 60).padStart(2, "0"); }
function getSL() { return setlists[activeSL]; }
function saveSetlists() { lsSet(LS.setlists, setlists); }
@ -602,12 +653,20 @@ function playItem(i) {
const sl = getSL(); if (!sl || !sl.items[i]) return;
logFinalize(); // close out the previously-playing item
applySetup(sl.items[i]);
if (!state.running) start();
if (state.running) stop(); // clean restart so ramp start-BPM + bar counter reset
start();
playingItem = i;
historyName = sl.items[i].name;
nowPlaying = { at: Date.now(), name: sl.items[i].name };
renderItems();
renderItems(); renderLog();
}
function nextItem() { const sl = getSL(); if (sl && playingItem + 1 < sl.items.length) playItem(playingItem + 1); }
function updateItem(i) { // load → adjust → save back to the item
const sl = getSL(); if (!sl || !sl.items[i]) return;
const nm = sl.items[i].name;
sl.items[i] = { name: nm, ...currentSetup() };
saveSetlists(); renderItems();
}
// Start/stop button + Space route through here so internal restarts don't log.
function toggleTransport() {
@ -633,14 +692,17 @@ function renderItems() {
if (!sl.items.length) { box.innerHTML = '<div class="hint">No items yet — set up the metronome and “Add current settings”.</div>'; return; }
sl.items.forEach((it, i) => {
const row = document.createElement("div"); row.className = "ex-item";
const playing = (i === playingItem && state.running);
if (i === playingItem) row.style.borderColor = "#2e7d32";
row.innerHTML = `<button class="play iconbtn" data-act="play" title="load &amp; start"></button>
row.innerHTML = `<button class="${playing ? "stop" : "play"} iconbtn" data-act="play" title="${playing ? "stop" : "load & start"}">${playing ? "⏹" : "▶"}</button>
<span class="nm">${i + 1}. ${it.name}</span>
<span class="meta">${it.bpm}bpm · ${it.lanes.map((l) => l.groupsStr).join("/")}</span>
<button class="iconbtn" data-act="save" title="save current settings to this item">💾</button>
<button class="iconbtn" data-act="up" title="up"></button>
<button class="iconbtn" data-act="down" title="down"></button>
<button class="x iconbtn" data-act="del" title="remove"></button>`;
row.querySelector('[data-act=play]').onclick = () => playItem(i);
row.querySelector('[data-act=play]').onclick = () => (i === playingItem && state.running) ? toggleTransport() : playItem(i);
row.querySelector('[data-act=save]').onclick = () => updateItem(i);
row.querySelector('[data-act=up]').onclick = () => moveItem(i, -1);
row.querySelector('[data-act=down]').onclick = () => moveItem(i, 1);
row.querySelector('[data-act=del]').onclick = () => removeItem(i);
@ -655,15 +717,16 @@ function logFinalize() {
logs.unshift({ at: nowPlaying.at, name: nowPlaying.name, durationSec: (Date.now() - nowPlaying.at) / 1000, bpm: state.bpm, lanes: snapshotLanes() });
lsSet(LS.logs, logs); nowPlaying = null; renderLog();
}
// Show history for the item being (or last) played, so the user can compare
// today's BPM/duration against previous days for that specific task.
function renderLog() {
const box = $("logView"); const logs = lsGet(LS.logs, []); box.innerHTML = "";
if (!logs.length) { box.innerHTML = '<div class="hint">No practice logged yet — play set-list items to record what you do.</div>'; return; }
logs.forEach((e) => {
const div = document.createElement("div"); div.className = "log-item";
div.innerHTML = `<div class="log-head">${new Date(e.at).toLocaleString()} — ${e.name}</div>
<div class="log-seg">${fmtDur(e.durationSec)} @ ${e.bpm}bpm · ${(e.lanes || []).map((l) => l.groupsStr + "/" + l.sound).join(", ")}</div>`;
box.appendChild(div);
});
const box = $("logView"); box.innerHTML = "";
if (!historyName) { box.innerHTML = '<div class="hint">Play a set-list item to see its history — compare BPM &amp; duration across days.</div>'; return; }
const logs = lsGet(LS.logs, []).filter((e) => e.name === historyName);
let html = `<div class="log-head" style="margin-bottom:5px">History — ${historyName}</div>`;
if (!logs.length) html += '<div class="hint">No past sessions for this item yet.</div>';
else html += logs.map((e) => `<div class="log-seg">${new Date(e.at).toLocaleString()} · ${fmtDur(e.durationSec)} @ ${e.bpm}bpm</div>`).join("");
box.innerHTML = html;
}
function clearLog() { if (confirm("Clear the practice log? (set lists & presets are kept)")) { lsSet(LS.logs, []); renderLog(); } }
@ -732,6 +795,13 @@ function syncStartBtn() {
else { startBtn.textContent = "▶ Start"; startBtn.classList.remove("on"); }
}
function toggleShortcuts(show) { const o = $("shortcutsOverlay"); o.hidden = (show === undefined) ? !o.hidden : !show; }
function applyTheme(t) {
document.documentElement.dataset.theme = t;
try { localStorage.setItem("metronome.theme", t); } catch (e) {}
$("themeBtn").textContent = t === "light" ? "🌙" : "☀"; // icon = theme you'd switch TO
}
$("themeBtn").addEventListener("click", () => applyTheme(document.documentElement.dataset.theme === "light" ? "dark" : "light"));
applyTheme(document.documentElement.dataset.theme === "light" ? "light" : "dark");
$("startBtn").addEventListener("click", () => toggleTransport());
let _taps = [];
function tapTempo() {
@ -753,6 +823,7 @@ $("trainerOn").addEventListener("change", (e) => trainer.on = e.target.checked);
$("playBars").addEventListener("input", (e) => trainer.playBars = +e.target.value);
$("muteBars").addEventListener("input", (e) => trainer.muteBars = +e.target.value);
$("rampOn").addEventListener("change", (e) => ramp.on = e.target.checked);
$("rampStart").addEventListener("input", (e) => ramp.startBpm = +e.target.value);
$("rampAmt").addEventListener("input", (e) => ramp.amount = +e.target.value);
$("rampEvery").addEventListener("input", (e) => ramp.everyBars = +e.target.value);
$("addMeterBtn").addEventListener("click", () => addMeter("4", 1, "claves"));
@ -764,6 +835,8 @@ $("presetSelect").addEventListener("change", (e) => {
$("delPresetBtn").addEventListener("click", () => { const n = $("presetSelect").value; if (n && n !== "__save__" && confirm('Delete preset "' + n + '"?')) deletePreset(n); });
$("routineToggle").addEventListener("click", () => $("routineTray").classList.toggle("open"));
$("routineClose").addEventListener("click", () => $("routineTray").classList.remove("open"));
$("trayMenuBtn").addEventListener("click", (e) => { e.stopPropagation(); $("trayMenu").hidden = !$("trayMenu").hidden; });
document.addEventListener("click", (e) => { const m = $("trayMenu"); if (m && !m.hidden && !m.contains(e.target) && e.target.id !== "trayMenuBtn") m.hidden = true; });
$("newSetlistBtn").addEventListener("click", newSetlist);
$("delSetlistBtn").addEventListener("click", deleteSetlist);
$("setlistSelect").addEventListener("change", (e) => { activeSL = +e.target.value; playingItem = -1; renderSetlists(); });
@ -773,10 +846,10 @@ $("addItemBtn").addEventListener("click", () => { addItem($("itemName").value.tr
$("helpBtn").addEventListener("click", () => toggleShortcuts());
$("shortcutsClose").addEventListener("click", () => toggleShortcuts(false));
$("shortcutsOverlay").addEventListener("click", (e) => { if (e.target.id === "shortcutsOverlay") toggleShortcuts(false); });
$("exportBtn").addEventListener("click", exportAll);
$("importBtn").addEventListener("click", () => $("importFile").click());
$("exportBtn").addEventListener("click", () => { $("trayMenu").hidden = true; exportAll(); });
$("importBtn").addEventListener("click", () => { $("trayMenu").hidden = true; $("importFile").click(); });
$("importFile").addEventListener("change", (e) => { if (e.target.files[0]) importAll(e.target.files[0]); e.target.value = ""; });
$("clearLogBtn").addEventListener("click", clearLog);
$("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); });
window.addEventListener("keydown", (e) => {
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;