metronome/showcase.html
Me Here 9e5c79b3b2 Split each form factor into a lean widget page + a separate info page
Previously every device .html bundled its full narrative (purpose, BOM,
dimensioned drawings, embedding docs) inside a #techinfo block that embed
mode (?embed=1) only CSS-hid — so every embedder and every landing-page
iframe downloaded all of it (showcase 77%, teacher 49% of the file) for
content no embedder ever sees.

Now:
  - <device>.html is the lean widget only: header + front view/controls +
    title + summary + program box. ?embed=1 still collapses to the bare
    widget; the heavy narrative is gone from the payload.
  - info-<device>.html (new, one per form factor) carries all the words —
    purpose, dimensions, priced BOM, embedding docs — and embeds the live
    widget at the top via the existing iframe + auto-resize protocol
    (new shared src/infoembed.html + src/infoembed.js).
  - Each device links out to its info page ("…dimensions & BOM →"); the
    landing panes and viewport bar now offer both Open ↗ and Specs & info ⓘ.
  - Dropped the now-dead "Show info" toggle (CSS + progbox.js).

Branding: adopt the official VARASYS "tagline on the bottom" logos from the
brand kit (light-background variant now matches; dark already did). The
tagline is baked into the PNGs, so remove the CSS .brand-tag / .dev-tag
spans and the showcase canvas-drawn tagline. Brand cyan #0AB3F7 / navy
#1C283F already match the official palette.

build.sh / deploy.sh: build + deploy the six new info-*.html pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:18:23 -05:00

263 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>VARASYS PM_D1 — Display (RGB pendulum metronome)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<script>
(function(){ if(!/[?&]embed=1/.test(location.search)) return;
document.documentElement.dataset.embed="1";
function ph(){ try{ parent.postMessage({type:"varasys-h",h:Math.ceil(document.documentElement.getBoundingClientRect().height)},"*"); }catch(e){} }
addEventListener("load",ph); addEventListener("resize",ph); setTimeout(ph,300); setTimeout(ph,1000);
})();
</script>
<!--
PM-S "Showcase" — a display-piece metronome shaped like a classic pyramid wind-up
unit. The pendulum is the whole show: a single RGB LED bar where every lane's
subdivisions / accents are combined ALONG its length (each lane is a moving point
of light), it carries a printed TEMPO scale on the vertical axis, and a sliding
WEIGHT sets the tempo (drag it — up = slower, like the real thing). The canvas is
transparent outside the body. There's no on-screen power switch: the real unit
starts when you lift it from its holder / set it swinging — here that's an external
button. Latency-compensated visuals. Shares src/engine.js.
-->
<script>
(function(){ try{ var p = localStorage.getItem("metronome.theme");
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
</script>
<style>
/*@BUILD:include:src/base.css@*/
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
--panel-bd:#2a313c; --panel-bg:#161b22; --field-bg:#0e1116; --field-bd:#2a313c; --cyan:#0AB3F7; }
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4; --panel-bd:#d2dae4; --panel-bg:#fff; --field-bg:#f1f4f8; --field-bd:#d2dae4 }
body{ margin:0; min-height:100vh; padding:24px 14px 44px;
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); color:var(--txt);
display:flex; flex-direction:column; align-items:center; gap:16px }
a{ color:var(--link) }
main{ display:flex; flex-direction:column; align-items:center; gap:16px; width:100% }
.device{ width:100%; max-width:300px; filter:drop-shadow(0 22px 34px rgba(0,0,0,.55)); }
#stage{ display:block; width:100%; height:auto; touch-action:none; cursor:ns-resize }
.ctrls{ display:flex; align-items:center; gap:14px; flex-wrap:wrap; justify-content:center }
.ctrls button{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:9px;
padding:7px 12px; font-size:14px; line-height:1; cursor:pointer }
.ctrls button:hover{ border-color:var(--cyan) }
#play{ min-width:64px; font-size:14px }
.trk{ display:flex; align-items:center; gap:8px; font-size:13px; color:var(--muted) }
.trk b{ color:var(--txt); min-width:34px; text-align:center; display:inline-block; font-variant-numeric:tabular-nums }
.hint{ max-width:340px; text-align:center; font-size:11px; color:var(--muted); line-height:1.5 }
[data-embed] .hint{ display:none !important }
</style>
</head>
<body>
/*@BUILD:include:src/header.html@*/
<main>
<h1 class="ff-title">PM_D1 Display</h1>
<p class="ff-sum">A displaypiece metronome — the pendulum is an RGB light bar that combines every lane's subdivisions &amp; accents; a printed tempo scale with a sliding weight sets the tempo.</p>
<div class="device"><canvas id="stage" width="300" height="470" aria-label="RGB pendulum metronome"></canvas></div>
<div class="ctrls">
<button id="play" title="Start / stop (Space) — the real unit starts when lifted from its holder">▶ Start</button>
<div class="trk"><button id="prev" title="Previous"></button><b id="trkLbl"></b><button id="next" title="Next"></button></div>
</div>
<div class="hint">The pendulum <b>is</b> the display: every lane's subdivisions &amp; accents ride along the bar as
moving RGB light. Drag the <b>weight</b> up/down (or scroll) to set tempo — the scale is printed on the bar,
just like a windup metronome. (No power switch: the real one starts when you lift it from its holder.)</div>
/*@BUILD:include:src/progbox.html@*/
<p class="ff-link pageonly"><a href="/info-showcase.html">Purpose, dimensions &amp; bill of materials →</a></p>
</main>
/*@BUILD:include:src/footer.html@*/
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
/* ========================= ENGINE (shared; synth voices only) ================= */
const SAMPLES = {};
/*@BUILD:include:src/engine.js@*/
/*@BUILD:include:src/setlists.js@*/
const state = { bpm:120, volume:0.85, running:false };
let meters = [], muteWindows = [];
function setBpm(v){ state.bpm = Math.max(30, Math.min(300, Math.round(v))); if(window.progRefresh) progRefresh(); }
function scheduler(){
const ahead = audioCtx.currentTime + SCHEDULE_AHEAD;
for(const m of meters){ while(m.nextTime < ahead){ scheduleMeterTick(m, m.nextTime); m.nextTime += laneStepDur(m, m.tick); m.tick++; } }
}
function buildMeters(lanes){
return (lanes||[]).map(c=>{ const p=parseGroups(c.groupsStr);
return {groupsStr:c.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts,
stepsPerBeat:c.stepsPerBeat||1,sound:c.sound,beatsOn:(c.beatsOn||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,gainDb:c.gainDb||0,
tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0}; });
}
function startAudio(){
ensureAudio(); audioCtx.resume(); state.running=true;
const t0=audioCtx.currentTime+0.08;
for(const m of meters){ m.tick=0; m.nextTime=t0; m.vq=[]; m.vqPtr=0; m.currentStep=-1; }
beatCount=-1; lastBeatTime=t0; muteWindows=[];
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); syncBtns();
}
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; for(const m of meters) m.currentStep=-1; syncBtns(); }
function toggle(){ state.running ? stopAudio() : startAudio(); }
/* ========================= TRACKS ============================================ */
let tracks = SEED_SETLISTS.flatMap(sl => sl.items.map(([n,p]) => ({ name:n, ...patchToSetup(p) })));
let trackIdx = 0;
function tracksFromHash(){
const m=(location.hash||"").match(/[#&](p|sl)=([^&]+)/); if(!m) return null;
let payload=m[2]; try{ payload=decodeURIComponent(payload); }catch(e){}
try{
if(m[1]==="sl"){ const sl=codeToSetlist(payload); return sl.items.length ? sl.items.map(it=>({...it})) : null; }
const s=patchToSetup(payload); return s.lanes.length ? [{name:"Patch",...s}] : null;
}catch(e){ return null; }
}
function loadTrack(i){
const n=tracks.length; if(!n) return; trackIdx=((i%n)+n)%n;
const t=tracks[trackIdx]; setBpm(t.bpm||120); meters=buildMeters(t.lanes);
const was=state.running; if(was){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
if(was) startAudio(); syncBtns();
}
function syncBtns(){ $("play").textContent = state.running ? "■ Stop" : "▶ Start";
$("trkLbl").textContent = (trackIdx+1)+"/"+tracks.length; }
/* ========================= RGB PENDULUM (canvas) ============================= */
const cv=$("stage"), g=cv.getContext("2d"), CW=300, CH=470;
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1)); cv.width=CW*dpr; cv.height=CH*dpr; g.scale(dpr,dpr); })();
const MAXANG=0.40;
const PIVX=150, PIVY=380, ROD=292; // pivot near the base; rod points up
const F_FAST=0.30, F_SLOW=0.94; // weight fraction along rod at 240 / 40 BPM (top=slow)
let beatCount=-1, lastBeatTime=0, pend=0, flash=0, flashAccent=false;
function audioLatency(){ return audioCtx ? (audioCtx.outputLatency || audioCtx.baseLatency || 0) : 0; }
const LEVELCOL = { 2:[255,155,46], 1:[51,208,255], 3:[155,123,255], 0:[70,80,95] }; // accent / normal / ghost / mute (rgb)
const LOGO = new Image(); let logoReady=false; LOGO.onload=function(){ logoReady=true; }; LOGO.src = "data:image/png;base64,@BUILD:logo-dark@";
function bpmToFrac(b){ return F_SLOW - (Math.max(40,Math.min(240,b))-40)/200*(F_SLOW-F_FAST); }
function fracToBpm(f){ return Math.round(240 - (Math.max(F_FAST,Math.min(F_SLOW,f))-F_FAST)/(F_SLOW-F_FAST)*200); }
function drawBody(){
g.clearRect(0,0,CW,CH); // transparent everywhere outside the body
const tlx=98,trx=202,topY=18, blx=20,brx=280,botY=440;
const grd=g.createLinearGradient(0,0,0,botY); grd.addColorStop(0,"#2c2e34"); grd.addColorStop(1,"#131419");
g.beginPath(); g.moveTo(tlx,topY); g.lineTo(trx,topY); g.lineTo(brx,botY); g.lineTo(blx,botY); g.closePath();
g.fillStyle=grd; g.fill(); g.lineWidth=1.5; g.strokeStyle="rgba(255,255,255,.06)"; g.stroke();
g.beginPath(); g.moveTo(tlx,topY); g.lineTo(blx,botY); g.lineWidth=2; g.strokeStyle="rgba(255,255,255,.05)"; g.stroke();
// official VARASYS logo (the "Simplifying Complexity" tagline is baked into the image) + model
const lw=92, lh=Math.round(lw*82/304), lx=CW/2-lw/2, ly=14;
if(logoReady) g.drawImage(LOGO, lx, ly, lw, lh);
g.textAlign="center";
g.fillStyle="#aab2bc"; g.font="600 7px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("PM_D1 DISPLAY", CW/2, ly+lh+11);
}
function drawPendulum(){
g.save(); g.translate(PIVX,PIVY); g.rotate(pend); // rod frame: up = -y, tilts with the swing
// rod
g.strokeStyle="rgba(150,160,176,.45)"; g.lineWidth=3.5; g.lineCap="round";
g.beginPath(); g.moveTo(0,0); g.lineTo(0,-ROD); g.stroke();
// printed tempo scale (numbers along the bar)
g.textAlign="right"; g.font="600 8px 'Segoe UI',Roboto,Arial,sans-serif";
[40,60,80,100,120,160,200,240].forEach(function(b){ const y=-bpmToFrac(b)*ROD;
g.strokeStyle="rgba(180,190,205,.5)"; g.lineWidth=1; g.beginPath(); g.moveTo(4,y); g.lineTo(10,y); g.stroke();
g.fillStyle="rgba(180,190,205,.6)"; g.fillText(String(b), 2, y+3); });
// fixed bob (drives the swing) near the bottom of the rod
g.fillStyle="#2a2f37"; roundRectP(-9,-58,18,30,4); g.fill();
g.fillStyle="rgba(255,255,255,.06)"; roundRectP(-9,-58,18,5,2); g.fill();
// sliding WEIGHT = tempo — STATIC (no flash) and drawn BEHIND the lights so it never hides a beat flash
const wy=-bpmToFrac(state.bpm)*ROD;
g.fillStyle="#3a4049"; roundRectP(-15,wy-11,30,22,4); g.fill();
g.fillStyle="rgba(150,160,176,.5)"; roundRectP(-13,wy-3.5,26,7,2); g.fill(); // index mark (static)
g.fillStyle="rgba(255,255,255,.10)"; roundRectP(-15,wy-11,30,5,2); g.fill(); // top sheen
// combined-meter LEDs: each lane is a moving point of light along the bar (its current step position).
// Drawn LAST so the flashes sit on top of the weight.
for(const m of meters){ if(m.currentStep<0 || !state.running) continue;
const steps=m.beatsPerBar*m.stepsPerBeat, fr=steps?((m.currentStep%steps)/steps):0;
const y=-(0.16 + fr*(0.96-0.16))*ROD, lvl=m.beatsOn[m.currentStep]|0; if(lvl===0) continue;
const c=LEVELCOL[lvl]||LEVELCOL[1], rgb="rgb("+c[0]+","+c[1]+","+c[2]+")";
g.shadowColor=rgb; g.shadowBlur=14; g.fillStyle=rgb;
g.beginPath(); g.arc(0,y, lvl>=2?6:4.5, 0,7); g.fill(); g.shadowBlur=0;
}
// pivot hub
g.restore();
g.beginPath(); g.arc(PIVX,PIVY,6,0,7); g.fillStyle="#2a2f37"; g.fill();
g.beginPath(); g.arc(PIVX,PIVY,2.5,0,7); g.fillStyle="#aab2bc"; g.fill();
}
function roundRectP(x,y,w,h,r){ g.beginPath(); g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r); g.arcTo(x+w,y+h,x,y+h,r); g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); }
function drawReadout(){
g.textAlign="center";
g.fillStyle="#c7d0db"; g.font="700 20px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText(String(state.bpm), CW/2-2, 420);
g.fillStyle="#7f8b9a"; g.font="600 8px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("BPM", CW/2+38, 420);
const nm=(tracks[trackIdx]&&tracks[trackIdx].name)||"—";
g.fillStyle="#8f9aa6"; g.font="600 8.5px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText(nm.length>32?nm.slice(0,31)+"…":nm, CW/2, 432);
}
function draw(){
const now = audioCtx ? audioCtx.currentTime - audioLatency() : 0;
if(audioCtx && state.running){
for(const m of meters){
while(m.vqPtr<m.vq.length && m.vq[m.vqPtr].time<=now){
const e=m.vq[m.vqPtr]; m.currentStep=e.step; m.currentBar=e.bar;
if(m===meters[0] && e.step % m.stepsPerBeat === 0){
beatCount++; lastBeatTime=e.time;
const lvl=m.beatsOn[e.step]|0; flashAccent = lvl>=2 || m.groupStarts.has(e.step/m.stepsPerBeat);
if(lvl!==0) flash=1;
}
m.vqPtr++;
}
if(m.vqPtr>512){ m.vq=m.vq.slice(m.vqPtr); m.vqPtr=0; }
}
}
let tgt=0;
if(state.running && beatCount>=0){ let fr=(now-lastBeatTime)/(60/state.bpm); if(fr<0)fr=0; if(fr>1.2)fr=1.2;
tgt = MAXANG*Math.cos(Math.PI*(beatCount+fr)); }
pend += (tgt-pend)*(state.running?1:0.12);
flash = Math.max(0, flash-0.08);
drawBody(); drawPendulum(); drawReadout();
requestAnimationFrame(draw);
}
/* ========================= CONTROLS ========================================== */
// drag anywhere on the piece to set tempo via the weight (vertical axis); scroll too.
let dragging=false;
function tempoFromY(clientY){ const r=cv.getBoundingClientRect(); const yy=(clientY-r.top)/r.height*CH;
const f=(PIVY-yy)/ROD; setBpm(fracToBpm(f)); syncBtns(); }
cv.addEventListener("pointerdown",(e)=>{ dragging=true; try{cv.setPointerCapture(e.pointerId);}catch(_){ } tempoFromY(e.clientY); });
cv.addEventListener("pointermove",(e)=>{ if(dragging) tempoFromY(e.clientY); });
cv.addEventListener("pointerup",()=>{ dragging=false; });
cv.addEventListener("pointercancel",()=>{ dragging=false; });
cv.addEventListener("wheel",(e)=>{ e.preventDefault(); setBpm(state.bpm+(e.deltaY<0?(e.shiftKey?5:1):(e.shiftKey?-5:-1))); syncBtns(); }, {passive:false});
$("play").onclick = ()=>toggle();
$("prev").onclick = ()=>loadTrack(trackIdx-1);
$("next").onclick = ()=>loadTrack(trackIdx+1);
addEventListener("keydown",(e)=>{
const tag=e.target?e.target.tagName:""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
if(e.key===" "){ e.preventDefault(); toggle(); }
else if(e.key==="ArrowUp"){ e.preventDefault(); setBpm(state.bpm+(e.shiftKey?10:1)); syncBtns(); }
else if(e.key==="ArrowDown"){ e.preventDefault(); setBpm(state.bpm-(e.shiftKey?10:1)); syncBtns(); }
else if(e.key==="ArrowRight"){ loadTrack(trackIdx+1); }
else if(e.key==="ArrowLeft"){ loadTrack(trackIdx-1); }
});
/* ========================= INIT ============================================== */
{ const ht=tracksFromHash(); if(ht) tracks=ht; }
loadTrack(0); syncBtns();
requestAnimationFrame(draw);
window.currentProgramString = function(){ var t=tracks[trackIdx]||{}; return setupToPatch({bpm:state.bpm, volume:state.volume, lanes:t.lanes||[]}); };
window.loadProgramString = function(plain){ var s=patchToSetup(plain); tracks=[{name:"Program", ...s}]; trackIdx=0; loadTrack(0); syncBtns(); };
/*@BUILD:include:src/progbox.js@*/
/*@BUILD:include:src/chrome.js@*/
</script>
</body>
</html>