pm-mobile: inline track panel above lanes + fixed landscape layout

- Repeat (bar count) + End, Ramp, Gap and the track share-string now show
  directly on the main screen in a compact panel above the lanes (with live
  Copy/Apply of the string) — replaces the ⚙ Track dialog, which is removed.
- Landscape was overlapping (top row + pulse + lanes collided at short
  heights). Reworked: the work area is a 2-column grid — pulse + transport on
  the left, track panel + lanes (scrollable) on the right — with a single-row
  top bar (dropdowns + volume + icons). Portrait keeps the centered block with
  the transport below. Verified clean in both orientations down to ~300px tall.

Engine untouched; conformance unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-07 11:03:18 -05:00
parent 582118abf9
commit ca2a695f4f

View file

@ -67,10 +67,9 @@
font-size:18px; line-height:1; cursor:pointer; color:var(--txt); background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); }
.icon:active{ background:rgba(127,139,154,.30); }
/* ---- middle: pulse + lanes centered as one block, transport pinned below ---- */
#mid{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; gap:10px; }
#center{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:clamp(14px,3.5vmin,34px); }
#stage{ flex:0 0 auto; display:flex; flex-direction:column; align-items:center; gap:clamp(8px,2vmin,18px); }
/* ---- middle: pulse + track panel + lanes + transport, centered as one block ---- */
#mid{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:safe center; gap:clamp(10px,2.4vmin,22px); }
#stage{ flex:0 0 auto; display:flex; flex-direction:column; align-items:center; gap:clamp(6px,1.6vmin,14px); }
#pulse{ position:relative; width:clamp(186px,46vmin,360px); height:clamp(186px,46vmin,360px); border-radius:50%;
display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center;
border:2px solid var(--ring); background:radial-gradient(circle at 50% 40%, rgba(127,139,154,.07), transparent 70%);
@ -84,12 +83,24 @@
#bpmIn::-webkit-outer-spin-button, #bpmIn::-webkit-inner-spin-button{ -webkit-appearance:none; margin:0; }
#meterline{ font-size:clamp(12px,2.1vmin,16px); color:var(--muted); text-align:center; min-height:1.2em; letter-spacing:.02em; }
/* ---- editable lanes + track-settings button ---- */
#detail{ flex:0 1 auto; width:100%; max-width:560px; max-height:34vh; overflow-y:auto; display:flex; flex-direction:column; gap:8px; padding:2px 0; }
/* ---- track panel (repeat/end/ramp/gap/string) + editable lanes ---- */
#detail{ flex:0 1 auto; width:100%; max-width:560px; max-height:32vh; overflow-y:auto; display:flex; flex-direction:column; gap:8px; padding:2px 0; }
#lanes{ display:flex; flex-direction:column; gap:6px; }
#trackBtn{ align-self:center; background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--muted);
border-radius:9px; padding:7px 14px; font-size:clamp(11px,1.9vmin,13px); cursor:pointer; max-width:100%; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
#trackBtn:active{ background:rgba(127,139,154,.22); }
#trackpanel{ width:100%; background:var(--chip-bg); border:1px solid var(--chip-bd); border-radius:10px; padding:8px 10px; display:flex; flex-direction:column; gap:7px; font-size:12px; color:var(--muted); }
#trackpanel .tp-grid{ display:flex; flex-wrap:wrap; align-items:center; gap:8px 14px; }
#trackpanel label{ display:flex; align-items:center; gap:6px; }
#trackpanel .tp-chk{ color:var(--txt); }
#trackpanel .tp-chk input{ width:18px; height:18px; accent-color:var(--cyan); }
#trackpanel input[type=number]{ width:48px; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:7px; padding:5px 6px; font-size:13px; -moz-appearance:textfield; }
#trackpanel input[type=number]::-webkit-outer-spin-button, #trackpanel input[type=number]::-webkit-inner-spin-button{ -webkit-appearance:none; margin:0; }
#trackpanel select{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:7px; padding:5px 6px; font-size:13px; }
#trackpanel .tp-sub{ display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
#trackpanel .tp-sub.off{ display:none; }
#trackpanel .tp-sub b{ color:var(--txt); font-weight:600; }
#trackpanel .tp-str{ display:flex; gap:6px; }
#trackpanel .tp-str input{ flex:1 1 auto; min-width:0; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:7px; padding:6px 8px; font-family:"Courier New",monospace; font-size:11px; }
#trackpanel .tp-btn{ flex:0 0 auto; background:linear-gradient(180deg,var(--btn1),var(--btn2)); color:var(--txt); border:1px solid var(--btn-bd); border-radius:7px; padding:0 12px; font-size:12px; cursor:pointer; }
#trackpanel .tp-msg{ color:#5fd08a; margin-left:auto; }
.lane{ display:flex; align-items:center; gap:8px; }
.lane.off{ opacity:.5; }
.lmeta{ flex:0 0 auto; width:30%; max-width:130px; min-width:64px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left;
@ -124,15 +135,20 @@
.tbtn.prac{ background:linear-gradient(180deg,#1c87b8,#136488); border-color:#1f9bd0; color:#eaf8ff; }
.tbtn.prac.on{ background:linear-gradient(180deg,#c8922a,#9c6f12); border-color:#e0a93a; color:#fff; }
/* landscape phones: pulse left, lanes right (centered), transport full-width below */
/* landscape phones: 2-column grid — pulse + transport on the left, panel + lanes on the right */
@media (orientation:landscape) and (max-height:600px){
#center{ flex-direction:row; align-items:center; justify-content:center; gap:4vw; }
#stage{ flex:0 1 44%; gap:clamp(6px,1.6vmin,12px); }
#detail{ flex:0 1 52%; max-width:560px; max-height:64vh; }
#pulse{ width:clamp(132px,40vmin,300px); height:clamp(132px,40vmin,300px); }
#top{ flex-direction:row; align-items:flex-end; gap:12px; }
#top .sels{ flex:3 1 0; min-width:0; }
#top .trow{ flex:2 1 0; min-width:0; }
#mid{ display:grid; align-items:center; gap:8px 4vw;
grid-template-columns:40% 60%; grid-template-rows:1fr auto;
grid-template-areas:"stage detail" "transport detail"; }
#stage{ grid-area:stage; align-self:center; }
#detail{ grid-area:detail; align-self:stretch; width:auto; max-width:none; max-height:none; min-height:0; overflow-y:auto; }
#transport{ grid-area:transport; align-self:end; }
#pulse{ width:clamp(100px,30vmin,240px); height:clamp(100px,30vmin,240px); }
#bpmlab{ display:none; }
.tbtn{ height:clamp(40px,13vmin,58px); }
#transport .tgrid{ max-width:640px; }
.tbtn{ height:clamp(34px,11vmin,54px); }
}
/* ---- session bar ---- */
@ -203,19 +219,28 @@
</div>
<div id="mid">
<div id="center">
<div id="stage">
<div id="pulse">
<div id="bpm">120</div>
<input id="bpmIn" type="number" inputmode="numeric" min="30" max="300" />
<div id="bpmlab">BPM</div>
<div id="stage">
<div id="pulse">
<div id="bpm">120</div>
<input id="bpmIn" type="number" inputmode="numeric" min="30" max="300" />
<div id="bpmlab">BPM</div>
</div>
<div id="meterline"></div>
</div>
<div id="detail">
<div id="trackpanel">
<div class="tp-grid">
<label>Repeat <input id="ipBars" type="number" inputmode="numeric" min="0" max="999" /> bars</label>
<label>End <select id="ipEnd"><option value="loop">Loop</option><option value="stop">Stop</option><option value="next">Next</option></select></label>
<label class="tp-chk"><input type="checkbox" id="ipRamp" /> Ramp</label>
<label class="tp-chk"><input type="checkbox" id="ipGap" /> Gap</label>
<span id="ipMsg" class="tp-msg"></span>
</div>
<div id="meterline"></div>
</div>
<div id="detail">
<div id="lanes"></div>
<button id="trackBtn" title="Track settings — bars, loop/end, ramp, gaps, share string"></button>
<div class="tp-sub off" id="ipRampRow"><b>Ramp</b> start <input id="ipRampStart" type="number" min="30" max="300" /> → +<input id="ipRampAmt" type="number" min="1" max="50" /> every <input id="ipRampEvery" type="number" min="1" max="64" /> bars</div>
<div class="tp-sub off" id="ipGapRow"><b>Gap</b> <input id="ipGapPlay" type="number" min="1" max="32" /> play / <input id="ipGapMute" type="number" min="1" max="32" /> mute bars</div>
<div class="tp-str"><input id="ipStr" type="text" spellcheck="false" autocomplete="off" /><button id="ipCopy" class="tp-btn">Copy</button><button id="ipPaste" class="tp-btn">Apply</button></div>
</div>
<div id="lanes"></div>
</div>
<div id="transport">
<div class="tgrid">
@ -254,35 +279,6 @@
<div class="lfoot"><button id="lsDel" class="lbtn danger">Delete lane</button><button id="lsDone" class="lbtn">Done</button></div>
</div>
<!-- track settings sheet -->
<div id="trackSheet">
<div class="grab"></div>
<h2>Track settings</h2>
<div class="lrow">
<label class="half">Bars per repeat (0 = unlimited)<input id="tsBars" type="number" inputmode="numeric" min="0" max="999" /></label>
<label class="half">At end<select id="tsEnd"><option value="loop">Loop</option><option value="stop">Stop</option><option value="next">Next track</option></select></label>
</div>
<label class="chk"><input type="checkbox" id="tsRamp" /> Tempo ramp (speed up while you play)</label>
<div class="lrow" id="tsRampRow">
<label class="half">Start BPM<input id="tsRampStart" type="number" inputmode="numeric" min="30" max="300" /></label>
<label class="half">+BPM<input id="tsRampAmt" type="number" inputmode="numeric" min="1" max="50" /></label>
<label class="half">every N bars<input id="tsRampEvery" type="number" inputmode="numeric" min="1" max="64" /></label>
</div>
<label class="chk"><input type="checkbox" id="tsGap" /> Practice gaps (mute bars to test your inner clock)</label>
<div class="lrow" id="tsGapRow">
<label class="half">Play bars<input id="tsGapPlay" type="number" inputmode="numeric" min="1" max="32" /></label>
<label class="half">Mute bars<input id="tsGapMute" type="number" inputmode="numeric" min="1" max="32" /></label>
</div>
<label for="tsStr">Track string (share / copy / paste)</label>
<textarea id="tsStr" spellcheck="false" style="width:100%;background:var(--field-bg);color:var(--txt);border:1px solid var(--field-bd);border-radius:10px;padding:10px;font-family:'Courier New',monospace;font-size:12px;resize:vertical;min-height:54px"></textarea>
<div class="lrow">
<button id="tsCopy" class="lbtn">Copy</button>
<button id="tsApply" class="lbtn">Apply pasted string</button>
<span id="tsMsg" style="font-size:12px;color:var(--muted);align-self:center"></span>
</div>
<div class="lfoot"><span></span><button id="tsDone" class="lbtn">Done</button></div>
</div>
<!-- save & library sheet -->
<div id="saveSheet">
<div class="grab"></div>
@ -484,8 +480,8 @@ function gainLabel(db){ return (db>0?"+":"")+db+" dB"; }
function openLaneSheet(i){ editLaneIdx=i; const m=meters[i]; if(!m) return;
$("lsSound").value=m.sound; $("lsGroup").value=m.groupsStr; $("lsSub").value=String(m.stepsPerBeat); $("lsSwing").checked=!!m.swing; $("lsPoly").checked=!!m.poly; $("lsMute").checked=!m.enabled;
$("lsGain").value=m.gainDb||0; $("lsGainVal").textContent=gainLabel(m.gainDb||0);
$("trackSheet").classList.remove("open"); $("saveSheet").classList.remove("open"); $("scrim").classList.add("open"); $("laneSheet").classList.add("open"); }
function closeSheets(){ $("scrim").classList.remove("open"); $("laneSheet").classList.remove("open"); $("trackSheet").classList.remove("open"); $("saveSheet").classList.remove("open"); }
$("saveSheet").classList.remove("open"); $("scrim").classList.add("open"); $("laneSheet").classList.add("open"); }
function closeSheets(){ $("scrim").classList.remove("open"); $("laneSheet").classList.remove("open"); $("saveSheet").classList.remove("open"); }
const closeLaneSheet=closeSheets;
function applyLane(){ const m=meters[editLaneIdx]; if(!m) return;
let grp=($("lsGroup").value||"").trim()||"4";
@ -499,34 +495,33 @@ $("lsDone").onclick=closeLaneSheet;
$("lsDel").onclick=()=>{ if(meters.length<=1){ closeLaneSheet(); return; } meters.splice(editLaneIdx,1); laneSig=null; renderAll(); saveState(); closeLaneSheet(); };
$("scrim").onclick=closeSheets;
/* ---- track settings sheet: bars, end/loop, ramp, gaps, copy/paste string ---- */
/* ---- inline track panel: repeat/end, ramp, gap, copy/paste string (above lanes) ---- */
function clampInt(v,lo,hi,def){ v=parseInt(v,10); if(isNaN(v)) return def; return Math.max(lo,Math.min(hi,v)); }
function openTrackSheet(){
$("tsBars").value=segBars||0;
$("tsEnd").value = curEnd==null?"loop":(curEnd==="stop"?"stop":"next");
$("tsRamp").checked=ramp.on; $("tsRampStart").value=ramp.startBpm||80; $("tsRampAmt").value=ramp.amount||5; $("tsRampEvery").value=ramp.everyBars||4;
$("tsGap").checked=trainer.on; $("tsGapPlay").value=trainer.playBars||2; $("tsGapMute").value=trainer.muteBars||2;
$("tsRampRow").classList.toggle("off",!ramp.on); $("tsGapRow").classList.toggle("off",!trainer.on);
$("tsStr").value=currentPatch(); $("tsMsg").textContent="";
$("laneSheet").classList.remove("open"); $("saveSheet").classList.remove("open"); $("scrim").classList.add("open"); $("trackSheet").classList.add("open");
function flashIp(msg){ $("ipMsg").textContent=msg; setTimeout(()=>{ if($("ipMsg").textContent===msg) $("ipMsg").textContent=""; },1600); }
function buildTrackPanel(){
if(document.activeElement&&document.activeElement.closest&&document.activeElement.closest("#trackpanel")) return; // don't fight the user mid-edit
$("ipBars").value=segBars||0;
$("ipEnd").value = curEnd==null?"loop":(curEnd==="stop"?"stop":"next");
$("ipRamp").checked=ramp.on; $("ipGap").checked=trainer.on;
$("ipRampStart").value=ramp.startBpm||80; $("ipRampAmt").value=ramp.amount||5; $("ipRampEvery").value=ramp.everyBars||4;
$("ipGapPlay").value=trainer.playBars||2; $("ipGapMute").value=trainer.muteBars||2;
$("ipRampRow").classList.toggle("off",!ramp.on); $("ipGapRow").classList.toggle("off",!trainer.on);
$("ipStr").value=currentPatch();
}
function applyTrackSettings(){
segBars=Math.max(0,parseInt($("tsBars").value,10)||0);
const e=$("tsEnd").value; curEnd = e==="loop"?null:(e==="stop"?"stop":1);
ramp.on=$("tsRamp").checked; ramp.startBpm=clampInt($("tsRampStart").value,30,300,80); ramp.amount=clampInt($("tsRampAmt").value,1,50,5); ramp.everyBars=clampInt($("tsRampEvery").value,1,64,4);
trainer.on=$("tsGap").checked; trainer.playBars=clampInt($("tsGapPlay").value,1,32,2); trainer.muteBars=clampInt($("tsGapMute").value,1,32,2);
$("tsRampRow").classList.toggle("off",!ramp.on); $("tsGapRow").classList.toggle("off",!trainer.on);
$("tsStr").value=currentPatch(); renderAll(); saveState();
function applyTrackPanel(){
segBars=Math.max(0,parseInt($("ipBars").value,10)||0);
const e=$("ipEnd").value; curEnd = e==="loop"?null:(e==="stop"?"stop":1);
ramp.on=$("ipRamp").checked; ramp.startBpm=clampInt($("ipRampStart").value,30,300,80); ramp.amount=clampInt($("ipRampAmt").value,1,50,5); ramp.everyBars=clampInt($("ipRampEvery").value,1,64,4);
trainer.on=$("ipGap").checked; trainer.playBars=clampInt($("ipGapPlay").value,1,32,2); trainer.muteBars=clampInt($("ipGapMute").value,1,32,2);
$("ipRampRow").classList.toggle("off",!ramp.on); $("ipGapRow").classList.toggle("off",!trainer.on);
$("ipStr").value=currentPatch(); saveState();
}
["tsBars","tsEnd","tsRamp","tsRampStart","tsRampAmt","tsRampEvery","tsGap","tsGapPlay","tsGapMute"].forEach(id=>$(id).addEventListener("change",applyTrackSettings));
$("tsCopy").onclick=()=>{ const s=currentPatch(); const ok=()=>{ $("tsMsg").textContent="Copied ✓"; setTimeout(()=>$("tsMsg").textContent="",1600); };
if(navigator.clipboard&&navigator.clipboard.writeText){ navigator.clipboard.writeText(s).then(ok,()=>{ $("tsStr").select(); }); } else { $("tsStr").select(); try{ document.execCommand("copy"); ok(); }catch(e){} } };
$("tsApply").onclick=()=>{ const txt=($("tsStr").value||"").trim().replace(/^[#?&]*p=/,"");
try{ const setup=patchToSetup(txt); if(!setup.lanes.length) throw 0; loadSetup(setup); laneSig=null; renderAll(); saveState();
$("tsStr").value=currentPatch(); $("tsMsg").textContent="Applied ✓"; setTimeout(()=>$("tsMsg").textContent="",1600); }
catch(e){ $("tsMsg").textContent="✗ not a valid track string"; } };
$("tsDone").onclick=closeSheets;
$("trackBtn").onclick=openTrackSheet;
["ipBars","ipEnd","ipRamp","ipGap","ipRampStart","ipRampAmt","ipRampEvery","ipGapPlay","ipGapMute"].forEach(id=>$(id).addEventListener("change",applyTrackPanel));
$("ipCopy").onclick=()=>{ const s=currentPatch();
if(navigator.clipboard&&navigator.clipboard.writeText){ navigator.clipboard.writeText(s).then(()=>flashIp("Copied ✓"),()=>{ $("ipStr").select(); }); } else { $("ipStr").select(); try{ document.execCommand("copy"); flashIp("Copied ✓"); }catch(e){} } };
$("ipPaste").onclick=()=>{ const txt=($("ipStr").value||"").trim().replace(/^[#?&]*p=/,"");
try{ const setup=patchToSetup(txt); if(!setup.lanes.length) throw 0; loadSetup(setup); laneSig=null; renderAll(); saveState(); flashIp("Applied ✓"); }
catch(e){ flashIp("✗ invalid string"); } };
/* ---- save & library: user set lists/tracks (same store + format as the editor) ---- */
function userSetlists(){ return lsGet(LS_SETLISTS,[]); }
@ -601,7 +596,7 @@ function openSaveSheet(){
$("saveName").value=currentName()||"My track"; buildSaveTo();
const upd=$("saveUpd"); if(slKey[0]==="s"){ upd.style.display=""; upd.textContent='Update “'+(currentName()||"track")+'”'; } else upd.style.display="none";
$("saveMsg").textContent=""; renderLibrary();
$("laneSheet").classList.remove("open"); $("trackSheet").classList.remove("open"); $("scrim").classList.add("open"); $("saveSheet").classList.add("open"); }
$("laneSheet").classList.remove("open"); $("scrim").classList.add("open"); $("saveSheet").classList.add("open"); }
$("saveNew").onclick=doSaveAsNew; $("saveUpd").onclick=doUpdate; $("saveDone").onclick=closeSheets; $("saveBtn").onclick=openSaveSheet;
/* ========================= SET-LIST / TRACK DROPDOWNS ======================== */
@ -629,14 +624,10 @@ function renderInfo(){
const ts=$("trkSel"); if(ts && ts.value!==String(idx)) ts.value=String(idx);
const nm=currentName(); if(nm!==lastCur){ lastCur=nm; lsSet(LS_CURTRACK,nm); }
const sig=laneSignature(); if(sig!==laneSig){ laneSig=sig; buildLanes(); } else renderPadLevels();
buildTrackBtn(); updateStatus();
buildTrackPanel(); updateStatus();
}
function endLabel(){ if(curEnd==null) return "loop"; if(curEnd==="stop") return "stop"; if(curEnd===1) return "next";
if(typeof curEnd==="number"&&curEnd>0) return "+"+curEnd; return String(curEnd); }
function buildTrackBtn(){ const parts=[];
if(segBars>0) parts.push(segBars+" bars"); parts.push("→ "+endLabel());
if(ramp.on) parts.push("ramp"); if(trainer.on) parts.push("gaps");
$("trackBtn").textContent="⚙ "+parts.join(" · "); }
function updateStatus(){ const m=meters[0]; let s="";
if(m){ s=m.beatsPerBar+" beats"+(m.groups.length>1?" · "+m.groups.join("+"):"")+(m.swing?" · swing":""); }
if(state.running&&m){ const bar=segBars>0?((m.currentBar|0)%segBars+1):((m.currentBar|0)+1);
@ -722,7 +713,7 @@ const TOUR=[
{sel:"#lanes", title:"Edit the beat", text:"Each lane is a row of pads that blink on the beat — tap a pad to cycle rest → beat → accent → ghost. Tap a lane's label to change its sound, grouping, subdivision, mute or polymeter. “+ Add lane” for more."},
{sel:"#bPlay", title:"Play", text:"Just runs the metronome — nothing is added to your practice log."},
{sel:"#bPrac", title:"Practice = a timed session", text:"Practice times your playing and logs it to your practice log (it does NOT record audio). It starts a session clock; Play turns into Stop. Use Practice to start/pause each track, then tap Stop when you're done to save the session."},
{sel:"#trackBtn", title:"Track settings", text:"Set this track's bar count, what happens at the end (loop / stop / next), tempo ramp and practice gaps — and copy or paste the track's share string."},
{sel:"#trackpanel", title:"Track settings", text:"Right here: bar count, what happens at the end (loop / stop / next), a tempo ramp and practice gaps — plus copy or paste this track's share string."},
{sel:"#sessbar", title:"Your practice log", text:"Review past sessions, add notes, and compare a track across days."},
];
let tstep=0;