editor: fill the screen + align header to editor width

Editor #app and .device were capped (1400/1000px) and the shared header at 980px, so on
wide screens the logo sat inset from the editor's left edge and content floated narrow in a
wide page. Drop the caps (fill to the 24px body padding) and widen .site-head/.site-foot to
match — logo now lines up with the editor's left edge and the editor uses the full width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-02 07:52:23 -05:00
parent 1eca3ee0fe
commit 336d1b43bb
2 changed files with 126 additions and 48 deletions

View file

@ -72,18 +72,28 @@
.kbd-legend { color:var(--muted); font-size:13px; font-family:"Courier New",monospace; text-align:left; line-height:1.75; }
.kbd-legend span { white-space:nowrap; } /* wrap only between shortcut groups, never mid-token */
.appheader-ctrls { margin-left:auto; } /* push controls right on wide; narrow media query resets to left */
#app { display:flex; gap:18px; max-width:1400px; margin:0 auto; align-items:flex-start; justify-content:center; }
.device { flex:1 1 auto; min-width:0; max-width:1000px; background:linear-gradient(180deg, var(--panel), var(--bg));
/* Editor fills the screen (to the 24px body padding); the header/footer match so the logo lines
up with the editor's left edge. */
#app { display:flex; gap:18px; max-width:none; margin:0 auto; align-items:flex-start; justify-content:center; }
.site-head, .site-foot { max-width:none; }
.device { flex:1 1 auto; min-width:0; max-width:none; 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:80px; color:#ffd166; letter-spacing:2px; line-height:1.0; text-shadow:0 0 12px rgba(255,209,102,.5); }
.display .dtimers { font-family:"Courier New",monospace; font-size:26px; color:#4dd0e1; margin:6px 0; display:flex; gap:18px; justify-content:center; flex-wrap:wrap; }
/* Top display panel: compact, evenly spaced. A flex column with a small,
uniform gap replaces the old per-element margins (which stacked into big gaps). */
.display { background:#0a0d11; border:1px solid #000; border-radius:8px; padding:10px 14px; text-align:center; box-shadow:inset 0 2px 10px rgba(0,0,0,.7);
display:flex; flex-direction:column; align-items:center; gap:6px; }
.display .big { font-family:"Courier New",monospace; font-weight:700; font-size:72px; color:#ffd166; letter-spacing:2px; line-height:1.0; text-shadow:0 0 12px rgba(255,209,102,.5); }
.display .dtimers { font-family:"Courier New",monospace; font-size:24px; color:#4dd0e1; margin:0; display:flex; gap:8px 16px; justify-content:center; flex-wrap:wrap; line-height:1.1; }
.display .dtimers[hidden] { display:none; }
.display .ctx { font-family:"Courier New",monospace; font-size:19px; color:#4dd0e1; min-height:22px; line-height:1.25; }
.display .ctx { font-family:"Courier New",monospace; font-size:18px; color:#4dd0e1; min-height:20px; line-height:1.2; }
.display .ctx.muted-cue { color:#ffb454; }
/* Gap-trainer indicator in the display: persistent green "GAP p/m" while armed;
turns amber while the current bars are muted (count-along cue). */
.display .gap-ind { color:#5fd08a; font-size:20px; }
.display .gap-ind.muting { color:#ffb454; }
.knob { margin-bottom:10px; }
.knob label { display:flex; justify-content:space-between; font-size:12px; margin-bottom:5px; }
.knob label b { color:#fff; font-variant-numeric:tabular-nums; }
@ -266,7 +276,8 @@
<div class="display">
<div class="big" id="bpmDisplay">120</div>
<div class="dtimers" id="dtimers">
<span title="elapsed (stopwatch)"><span id="elapsedVal">0:00</span></span>
<span id="elapsedWrap" title="elapsed (stopwatch)"><span id="elapsedVal">0:00</span></span>
<span id="gapWrap" class="tval gap-ind" title="gap / mute trainer — plays N bars, mutes M bars" hidden></span>
<span id="countWrap" title="time countdown" hidden><span id="countVal" class="tval">0:00</span></span>
<span id="barWrap" title="bars remaining in this segment" hidden><span id="barVal" class="tval">0</span></span>
</div>
@ -386,7 +397,18 @@
</div>
<div class="hint" style="margin-top:6px"><kbd>Alt+↑/↓</kbd> reorder · 💾 save settings to an item.</div>
<div id="logView" style="margin-top:18px"></div>
<!-- Practice-log header + "log sessions" toggle. When OFF, played sessions are NOT
appended to the practice log (for noodling / adjusting); existing entries are kept.
Persisted in localStorage (LS.logging); default ON. -->
<div class="practice" style="margin-top:18px">
<div class="tray-head" style="margin-bottom:8px">
<h2 style="margin:0">Practice log</h2>
<label class="mini-check" id="loggingToggle" title="When off, sessions you play are NOT recorded to the practice log — handy while adjusting settings or just playing around. Existing history is kept.">
<input type="checkbox" id="logSessions" checked> log sessions
</label>
</div>
<div id="logView"></div>
</div>
</aside>
</div><!-- /#app -->
@ -747,7 +769,7 @@ function renderLaneStrip(m) {
/* =========================================================================
PRESETS (localStorage)
========================================================================= */
const LS = { presets: "metronome.presets", setlists: "metronome.setlists", logs: "metronome.logs", seeded: "metronome.seeded", continue: "metronome.continue", timers: "metronome.timers" };
const LS = { presets: "metronome.presets", setlists: "metronome.setlists", logs: "metronome.logs", seeded: "metronome.seeded", continue: "metronome.continue", timers: "metronome.timers", logging: "metronome.logging" };
function lsGet(k, fb) { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : fb; } catch (e) { return fb; } }
function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch (e) { console.warn("localStorage unavailable", e); return false; } }
@ -780,6 +802,7 @@ let nowPlaying = null; // { at, name } for duration logging
let historyName = null; // item whose past-session history is shown
let continueMode = lsGet(LS.continue, false); // auto-advance to next item when countdown ends
let timersOn = lsGet(LS.timers, true); // master switch for the elapsed/countdown timers
let loggingOn = lsGet(LS.logging, true); // master switch for recording practice-log sessions (default ON)
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp }, countMs: timers.totalMs, bars: segBars, rep: state.rep, end: state.end }; }
function applySetup(s) {
@ -828,7 +851,7 @@ function refreshFeatureBoxes() {
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]; } // the VIEWED list
function loadedItem() { const sl = setlists[loadedSL]; return (sl && activeItem >= 0) ? sl.items[activeItem] : null; }
function saveSetlists() { lsSet(LS.setlists, setlists); }
function saveSetlists() { lsSet(LS.setlists, setlists); if (typeof syncSetlistsSoon === "function") syncSetlistsSoon(); } // mirror content to a connected device (live-sync §8)
// --- set list CRUD ---
function newSetlist() {
@ -1007,9 +1030,12 @@ function renderItems() {
// --- practice log (flat entries, one per played item) ---
function logFinalize() {
if (!nowPlaying) return;
if (!loggingOn) { nowPlaying = null; return; } // logging off → discard this session, keep existing history
const logs = lsGet(LS.logs, []);
logs.unshift({ at: nowPlaying.at, name: nowPlaying.name, durationSec: (Date.now() - nowPlaying.at) / 1000, bpm: state.bpm, lanes: snapshotLanes() });
const entry = { at: nowPlaying.at, name: nowPlaying.name, durationSec: (Date.now() - nowPlaying.at) / 1000, bpm: state.bpm, lanes: snapshotLanes() };
logs.unshift(entry);
lsSet(LS.logs, logs); nowPlaying = null; renderLog();
if (typeof syncLog === "function") syncLog(entry); // mirror this session to a connected device (live-sync §9)
}
// 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.
@ -1432,8 +1458,13 @@ function tickTimers() {
renderTimers();
}
function renderTimers() {
$("dtimers").hidden = !timersOn;
if (!timersOn) return;
// Gap-trainer indicator is shown whenever gap mode is armed, even if the timers
// master switch is off — so the dtimers row stays visible for it alone.
const gapArmed = trainer.on && trainer.muteBars > 0;
$("dtimers").hidden = !timersOn && !gapArmed;
// timer-specific spans only when the timers master switch is on
$("elapsedWrap").hidden = !timersOn;
if (timersOn) {
$("elapsedVal").textContent = fmtClock(timers.elapsedMs);
const off = timers.totalMs <= 0;
$("countWrap").hidden = off; // hide time countdown when off
@ -1453,6 +1484,16 @@ function renderTimers() {
bv.textContent = remaining;
bv.classList.toggle("low", remaining <= 1);
}
} else { $("countWrap").hidden = true; $("barWrap").hidden = true; }
// Gap-trainer indicator: persistent "GAP <play>/<mute>" whenever gap mode is armed
// (not just while bars are muted). Goes amber while a muted window is active.
const gw = $("gapWrap");
if (gapArmed) {
gw.hidden = false;
gw.textContent = "GAP " + trainer.playBars + "/" + trainer.muteBars;
const muting = state.running && typeof isMutedAt === "function" && audioCtx && isMutedAt(audioCtx.currentTime);
gw.classList.toggle("muting", !!muting);
} else gw.hidden = true;
}
// Status shows in the display, under the BPM. Stopped → meter count; running →
@ -1555,9 +1596,9 @@ $("vol").addEventListener("input", (e) => {
if (masterGain) masterGain.gain.setTargetAtTime(state.volume, audioCtx.currentTime, 0.01);
if (typeof syncVol === "function") syncVol();
});
$("trainerOn").addEventListener("change", (e) => { trainer.on = e.target.checked; refreshFeatureBoxes(); syncPatchSoon(); });
$("playBars").addEventListener("input", (e) => { trainer.playBars = +e.target.value; syncPatchSoon(); });
$("muteBars").addEventListener("input", (e) => { trainer.muteBars = +e.target.value; syncPatchSoon(); });
$("trainerOn").addEventListener("change", (e) => { trainer.on = e.target.checked; refreshFeatureBoxes(); renderTimers(); syncPatchSoon(); });
$("playBars").addEventListener("input", (e) => { trainer.playBars = +e.target.value; renderTimers(); syncPatchSoon(); });
$("muteBars").addEventListener("input", (e) => { trainer.muteBars = +e.target.value; renderTimers(); syncPatchSoon(); });
$("rampOn").addEventListener("change", (e) => { ramp.on = e.target.checked; refreshFeatureBoxes(); syncPatchSoon(); });
$("rampStart").addEventListener("input", (e) => { ramp.startBpm = +e.target.value; syncPatchSoon(); });
$("rampAmt").addEventListener("input", (e) => { ramp.amount = +e.target.value; syncPatchSoon(); });
@ -1572,6 +1613,7 @@ $("endGoto").addEventListener("input", readEndActionUI);
$("endRep").addEventListener("input", readEndActionUI);
$("continueMode").addEventListener("change", (e) => { continueMode = e.target.checked; lsSet(LS.continue, continueMode); });
$("timersOn").addEventListener("change", (e) => { timersOn = e.target.checked; lsSet(LS.timers, timersOn); refreshFeatureBoxes(); renderTimers(); });
$("logSessions").addEventListener("change", (e) => { loggingOn = e.target.checked; lsSet(LS.logging, loggingOn); }); // practice-log on/off (persisted)
$("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; });
$("delSetlistBtn").addEventListener("click", deleteSetlist);
@ -1656,7 +1698,7 @@ window.addEventListener("keydown", (e) => {
// Seed the demo set lists. Versioned + additive: a newer SEED_VERSION adds any
// seed list whose title isn't already present, without clobbering the user's lists
// (and won't re-add one they've deleted at the same version).
const SEED_VERSION = 3;
const SEED_VERSION = 4;
if ((lsGet(LS.seeded, 0) | 0) < SEED_VERSION) {
for (const s of SEED_SETLISTS) {
if (!setlists.some((x) => x.title === s.title)) {
@ -1678,6 +1720,7 @@ updateCtx();
refreshFeatureBoxes();
$("continueMode").checked = continueMode;
$("timersOn").checked = timersOn;
$("logSessions").checked = loggingOn;
requestAnimationFrame(drawLoop);
/*@BUILD:include:src/chrome.js@*/
</script>

View file

@ -72,18 +72,28 @@
.kbd-legend { color:var(--muted); font-size:13px; font-family:"Courier New",monospace; text-align:left; line-height:1.75; }
.kbd-legend span { white-space:nowrap; } /* wrap only between shortcut groups, never mid-token */
.appheader-ctrls { margin-left:auto; } /* push controls right on wide; narrow media query resets to left */
#app { display:flex; gap:18px; max-width:1400px; margin:0 auto; align-items:flex-start; justify-content:center; }
.device { flex:1 1 auto; min-width:0; max-width:1000px; background:linear-gradient(180deg, var(--panel), var(--bg));
/* Editor fills the screen (to the 24px body padding); the header/footer match so the logo lines
up with the editor's left edge. */
#app { display:flex; gap:18px; max-width:none; margin:0 auto; align-items:flex-start; justify-content:center; }
.site-head, .site-foot { max-width:none; }
.device { flex:1 1 auto; min-width:0; max-width:none; 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:80px; color:#ffd166; letter-spacing:2px; line-height:1.0; text-shadow:0 0 12px rgba(255,209,102,.5); }
.display .dtimers { font-family:"Courier New",monospace; font-size:26px; color:#4dd0e1; margin:6px 0; display:flex; gap:18px; justify-content:center; flex-wrap:wrap; }
/* Top display panel: compact, evenly spaced. A flex column with a small,
uniform gap replaces the old per-element margins (which stacked into big gaps). */
.display { background:#0a0d11; border:1px solid #000; border-radius:8px; padding:10px 14px; text-align:center; box-shadow:inset 0 2px 10px rgba(0,0,0,.7);
display:flex; flex-direction:column; align-items:center; gap:6px; }
.display .big { font-family:"Courier New",monospace; font-weight:700; font-size:72px; color:#ffd166; letter-spacing:2px; line-height:1.0; text-shadow:0 0 12px rgba(255,209,102,.5); }
.display .dtimers { font-family:"Courier New",monospace; font-size:24px; color:#4dd0e1; margin:0; display:flex; gap:8px 16px; justify-content:center; flex-wrap:wrap; line-height:1.1; }
.display .dtimers[hidden] { display:none; }
.display .ctx { font-family:"Courier New",monospace; font-size:19px; color:#4dd0e1; min-height:22px; line-height:1.25; }
.display .ctx { font-family:"Courier New",monospace; font-size:18px; color:#4dd0e1; min-height:20px; line-height:1.2; }
.display .ctx.muted-cue { color:#ffb454; }
/* Gap-trainer indicator in the display: persistent green "GAP p/m" while armed;
turns amber while the current bars are muted (count-along cue). */
.display .gap-ind { color:#5fd08a; font-size:20px; }
.display .gap-ind.muting { color:#ffb454; }
.knob { margin-bottom:10px; }
.knob label { display:flex; justify-content:space-between; font-size:12px; margin-bottom:5px; }
.knob label b { color:#fff; font-variant-numeric:tabular-nums; }
@ -269,6 +279,7 @@
<div class="dtimers" id="dtimers">
<span title="elapsed (stopwatch)"><span id="elapsedVal">0:00</span></span>
<span id="rampWrap" class="tval" title="tempo ramp" hidden></span>
<span id="gapWrap" class="tval gap-ind" title="gap / mute trainer — plays N bars, mutes M bars" hidden></span>
<span id="countWrap" title="time countdown" hidden><span id="countVal" class="tval">0:00</span></span>
<span id="barWrap" title="bars remaining in this segment" hidden><span id="barVal" class="tval">0</span></span>
</div>
@ -388,7 +399,18 @@
</div>
<div class="hint" style="margin-top:6px"><kbd>Alt+↑/↓</kbd> reorder · 💾 save settings to an item.</div>
<div id="logView" style="margin-top:18px"></div>
<!-- Practice-log header + "log sessions" toggle. When OFF, played sessions are NOT
appended to the practice log (for noodling / adjusting); existing entries are kept.
Persisted in localStorage (LS.logging); default ON. -->
<div class="practice" style="margin-top:18px">
<div class="tray-head" style="margin-bottom:8px">
<h2 style="margin:0">Practice log</h2>
<label class="mini-check" id="loggingToggle" title="When off, sessions you play are NOT recorded to the practice log — handy while adjusting settings or just playing around. Existing history is kept.">
<input type="checkbox" id="logSessions" checked> log sessions
</label>
</div>
<div id="logView"></div>
</div>
</aside>
</div><!-- /#app -->
@ -747,7 +769,7 @@ function renderLaneStrip(m) {
/* =========================================================================
PRESETS (localStorage)
========================================================================= */
const LS = { presets: "metronome.presets", setlists: "metronome.setlists", logs: "metronome.logs", seeded: "metronome.seeded", continue: "metronome.continue", timers: "metronome.timers" };
const LS = { presets: "metronome.presets", setlists: "metronome.setlists", logs: "metronome.logs", seeded: "metronome.seeded", continue: "metronome.continue", timers: "metronome.timers", logging: "metronome.logging" };
function lsGet(k, fb) { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : fb; } catch (e) { return fb; } }
function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch (e) { console.warn("localStorage unavailable", e); return false; } }
@ -780,6 +802,7 @@ let nowPlaying = null; // { at, name } for duration logging
let historyName = null; // item whose past-session history is shown
let continueMode = lsGet(LS.continue, false); // auto-advance to next item when countdown ends
let timersOn = lsGet(LS.timers, true); // master switch for the elapsed/countdown timers
let loggingOn = lsGet(LS.logging, true); // master switch for recording practice-log sessions (default ON)
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp }, countMs: timers.totalMs, bars: segBars, rep: state.rep, end: state.end }; }
function applySetup(s) {
@ -1005,6 +1028,7 @@ function renderItems() {
// --- practice log (flat entries, one per played item) ---
function logFinalize() {
if (!nowPlaying) return;
if (!loggingOn) { nowPlaying = null; return; } // logging off → discard this session, keep existing history
const logs = lsGet(LS.logs, []);
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();
@ -1434,6 +1458,15 @@ function renderTimers() {
const rw = $("rampWrap"); // tempo-ramp indicator (matches the device's ramp arrow)
if (ramp.on) { rw.hidden = false; rw.textContent = (ramp.amount < 0 ? " " : " ") + (ramp.amount >= 0 ? "+" : "") + ramp.amount + "/" + ramp.everyBars + "b"; }
else rw.hidden = true;
// Gap-trainer indicator: persistent "GAP <play>/<mute>" whenever gap mode is armed
// (not just while bars are muted). Goes amber while a muted window is active.
const gw = $("gapWrap");
if (trainer.on && trainer.muteBars > 0) {
gw.hidden = false;
gw.textContent = "GAP " + trainer.playBars + "/" + trainer.muteBars;
const muting = state.running && typeof isMutedAt === "function" && audioCtx && isMutedAt(audioCtx.currentTime);
gw.classList.toggle("muting", !!muting);
} else gw.hidden = true;
const off = !timersOn || timers.totalMs <= 0;
$("countWrap").hidden = off; // hide time countdown when off
if (!off) {
@ -1552,9 +1585,9 @@ $("vol").addEventListener("input", (e) => {
state.volume = +e.target.value / 100; volVal.textContent = e.target.value + "%";
if (masterGain) masterGain.gain.setTargetAtTime(state.volume, audioCtx.currentTime, 0.01);
});
$("trainerOn").addEventListener("change", (e) => { trainer.on = e.target.checked; refreshFeatureBoxes(); });
$("playBars").addEventListener("input", (e) => trainer.playBars = +e.target.value);
$("muteBars").addEventListener("input", (e) => trainer.muteBars = +e.target.value);
$("trainerOn").addEventListener("change", (e) => { trainer.on = e.target.checked; refreshFeatureBoxes(); renderTimers(); });
$("playBars").addEventListener("input", (e) => { trainer.playBars = +e.target.value; renderTimers(); });
$("muteBars").addEventListener("input", (e) => { trainer.muteBars = +e.target.value; renderTimers(); });
$("rampOn").addEventListener("change", (e) => { ramp.on = e.target.checked; refreshFeatureBoxes(); });
$("rampStart").addEventListener("input", (e) => ramp.startBpm = +e.target.value);
$("rampAmt").addEventListener("input", (e) => ramp.amount = +e.target.value);
@ -1569,6 +1602,7 @@ $("endGoto").addEventListener("input", readEndActionUI);
$("endRep").addEventListener("input", readEndActionUI);
$("continueMode").addEventListener("change", (e) => { continueMode = e.target.checked; lsSet(LS.continue, continueMode); });
$("timersOn").addEventListener("change", (e) => { timersOn = e.target.checked; lsSet(LS.timers, timersOn); refreshFeatureBoxes(); renderTimers(); });
$("logSessions").addEventListener("change", (e) => { loggingOn = e.target.checked; lsSet(LS.logging, loggingOn); }); // practice-log on/off (persisted)
$("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; });
$("delSetlistBtn").addEventListener("click", deleteSetlist);
@ -1653,7 +1687,7 @@ window.addEventListener("keydown", (e) => {
// Seed the demo set lists. Versioned + additive: a newer SEED_VERSION adds any
// seed list whose title isn't already present, without clobbering the user's lists
// (and won't re-add one they've deleted at the same version).
const SEED_VERSION = 3;
const SEED_VERSION = 4;
if ((lsGet(LS.seeded, 0) | 0) < SEED_VERSION) {
for (const s of SEED_SETLISTS) {
if (!setlists.some((x) => x.title === s.title)) {
@ -1675,6 +1709,7 @@ updateCtx();
refreshFeatureBoxes();
$("continueMode").checked = continueMode;
$("timersOn").checked = timersOn;
$("logSessions").checked = loggingOn;
// Connect to a PM_K-1 / PM_X-1 over USB-MIDI on load so the header badge reflects the link
// (Chrome remembers the grant; first visit prompts once). updateDevBadge runs via _wireMidi.
if (navigator.requestMIDIAccess) _ensureMidi().then(updateDevBadge).catch(() => updateDevBadge());