Restructure (1/2): Concepts = landing with live embeds; shared chrome partials; Showcase redesign

- Concepts is now the landing (/): index.html is the form-factor gallery with the
  LIVE widget embedded in every box (editor/teacher/stage/micro/showcase/initial),
  on the shared header/footer. concepts.html retired; every "Concepts" link → /.
- New shared chrome partials src/header.html, src/footer.html, src/chrome.js
  (assembled by build.sh) + .site-foot / details.spec styles in base.css. Applied
  to the landing + showcase this pass.
- Showcase redesign per spec: the pendulum bar IS the display — each lane's
  subdivisions/accents ride along the rod as moving RGB light (all meters combined);
  transparent outside the body (no black window); a printed tempo scale on the
  vertical axis with a draggable weight to set tempo; start is an external button
  (the real unit starts when lifted from its holder).

Next pass: roll the shared header/footer onto the remaining pages (incl. the editor
header-above-toolbar), merge Open=Info into one page per form factor with the
expandable Info & BOM, and add teacher-style dimensioned views to Stage/Micro/Showcase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-28 09:09:51 -05:00
parent fb55a84aa2
commit be00ebf097
22 changed files with 253 additions and 401 deletions

View file

@ -20,9 +20,8 @@ State (set lists, the practice log, theme and UI preferences) lives in `localSto
| URL | What |
|-----|------|
| [`/`](https://metronome.varasys.io/) `index.html` | **Landing** — the PolyMeter front door (hero + formfactor cards) |
| [`/`](https://metronome.varasys.io/) `index.html` | **Concepts** — the landing / formfactor gallery; each box embeds the live widget |
| `/editor.html` | **PE1 — PolyMeter Editor** (the main app) |
| `/concepts.html` | **PolyMeter Concepts** — the formfactor gallery (cards → live page + info) |
| `/player.html` | **PM1 Initial** — idealized concept device (full display + setlist nav, theme, fullscreen "stage" view) |
| `/teacher.html` | **PM1 Teacher** — studio / lesson console (colour TFT, arcade buttons, 1/4″ instrument passthrough with analog click injection) |
| `/stage.html` | **PM1 Stage** — footpedal stompbox (two footswitches, expressionpedal in, RGB beat light, instrument passthrough) |
@ -232,9 +231,9 @@ Push the tag, then deploy.
| File | Purpose |
|------|---------|
| `index.html` | the **landing page** (site front door) |
| `index.html` | the **Concepts** landing / gallery (embeds each widget live) |
| `editor.html` | the **PE1 editor** app (source, with `@BUILD:*` markers) |
| `concepts.html` | the formfactor gallery |
| `src/header.html` · `src/footer.html` · `src/chrome.js` | shared header / footer / theme chrome, inlined into every page |
| `player.html` · `teacher.html` · `stage.html` · `micro.html` · `showcase.html` | the device mockups (PM1 Initial / Teacher / Stage, PMµ Micro, PMS Showcase) |
| `info-*.html` | performfactor info pages (purpose + priced BOM for buildable hardware) |
| `embed.html` · `embed.js` | embed docs and the dropin widget loader |

View file

@ -32,7 +32,7 @@ def build(name):
return out.stat().st_size
for name in ("index.html","editor.html","player.html","teacher.html","stage.html","micro.html","showcase.html",
"concepts.html","embed.html",
"embed.html",
"info-editor.html","info-initial.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html"):
print("built %s (%dKB)" % (name, build(name) // 1024))
pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) # loader, served as-is

View file

@ -1,143 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PolyMeter Concepts — VARASYS</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<!-- The PolyMeter concept library: an ever-expanding gallery of form factors
(the PE-1 editor + the PM-1/PM-µ hardware mockups + web widgets). Static page. -->
<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-bg:#161b22; --panel-bd:#2a313c; }
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
--panel-bg:#ffffff; --panel-bd:#d2dae4; }
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt);
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); }
a{ color:var(--link); }
main{ width:100%; max-width:980px; margin:26px auto 0; }
h1{ font-size:24px; margin:0 0 4px; }
.lead{ margin:0 0 22px; color:var(--muted); font-size:14px; line-height:1.55; max-width:62ch; }
.grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(250px, 1fr)); gap:16px; }
.card{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:16px;
display:flex; flex-direction:column; gap:9px; }
.card h3{ margin:0; font-size:16px; }
.chip{ align-self:flex-start; font-size:10px; text-transform:uppercase; letter-spacing:.08em;
padding:2px 9px; border-radius:999px; border:1px solid var(--panel-bd); color:var(--muted); }
.chip.hw{ color:var(--cyan); border-color:rgba(10,179,247,.45); }
.chip.app{ color:#2fe07a; border-color:rgba(47,224,160,.45); }
.card p{ margin:0; font-size:13px; color:var(--muted); line-height:1.5; flex:1; }
.card .links{ display:flex; gap:16px; margin-top:4px; }
.card .links a{ color:var(--link); text-decoration:none; font-size:13px; font-weight:600; }
.card.soon{ opacity:.65; border-style:dashed; }
.site-foot{ max-width:980px; margin:40px auto 0; font-size:12px; color:var(--muted); }
</style>
</head>
<body>
<header class="site-head">
<div class="head-left">
<a class="brand" href="/" title="VARASYS — Simplifying Complexity">
<img class="brand-logo brand-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" />
<img class="brand-logo brand-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS — Simplifying Complexity" />
</a>
<span class="page-name"><b>PolyMeter</b> · Concepts</span>
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<span class="here">Concepts</span>
<a href="/embed.html">Embed</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>
</header>
<main>
<h1>PolyMeter Concepts</h1>
<p class="lead">One polymeter engine, many form factors. The same firmware/share-language drives the
web editor and every device idea below — an ever-expanding library of concepts, from a full editor to
pocket practice hardware. Open one to try it live.</p>
<div class="grid">
<div class="card">
<span class="chip app">Web app</span>
<h3>PE1 — PolyMeter Editor</h3>
<p>The full editor: stack meter lanes, perstep accents / ghosts / mutes, swing &amp; ratio polyrhythm,
set lists, and shareable links. This is where you design grooves.</p>
<div class="links"><a href="/editor.html">Open ↗</a><a href="/info-editor.html">Info ⓘ</a></div>
</div>
<div class="card">
<span class="chip">Concept</span>
<h3>PM1 — Initial</h3>
<p>The original idealized device mock — full multilane display and setlist navigation. A northstar
concept (more than a single small unit can really show); the buildable take is Teacher.</p>
<div class="links"><a href="/player.html">Open ↗</a><a href="/info-initial.html">Info ⓘ</a></div>
</div>
<div class="card">
<span class="chip hw">Hardware</span>
<h3>PM1 — Teacher</h3>
<p>Fullfeature desktop console for studio &amp; lessons: 2.0″ colour TFT showing every lane, arcade buttons,
thumbroller, 1/4″ instrument passthrough with analog click injection + balancedTRS out, USBC powered.</p>
<div class="links"><a href="/teacher.html">Open ↗</a><a href="/info-teacher.html">Info &amp; BOM ⓘ</a></div>
</div>
<div class="card">
<span class="chip hw">Hardware</span>
<h3>PM1 — Stage</h3>
<p>Footpedal stompbox for live use: two heavy footswitches (tap / next), an expressionpedal input, a
big floorreadable RGB beat light, instrument passthrough with analog click, dualUSBC daisychain.</p>
<div class="links"><a href="/stage.html">Open ↗</a><a href="/info-stage.html">Info &amp; BOM ⓘ</a></div>
</div>
<div class="card">
<span class="chip hw">Hardware</span>
<h3>PMµ — Micro</h3>
<p>Long, narrow inline practice bar: instrument in one end, amp/headphones out the other, click mixed in.
Clickable thumbroller, amber 14segment display, USBC powered.</p>
<div class="links"><a href="/micro.html">Open ↗</a><a href="/info-micro.html">Info &amp; BOM ⓘ</a></div>
</div>
<div class="card">
<span class="chip hw">Hardware</span>
<h3>PMS — Showcase</h3>
<p>A display piece shaped like a classic pyramid windup metronome — the pendulum is RGB light easing to
the beat, with light rows showing every lane's subdivisions, accents &amp; mutes. USBC powered.</p>
<div class="links"><a href="/showcase.html">Open ↗</a><a href="/info-showcase.html">Info &amp; BOM ⓘ</a></div>
</div>
<div class="card soon">
<span class="chip">Coming</span>
<h3>More form factors</h3>
<p>The library keeps growing — desktop, Eurorack, wearable, headless module… each a widget you can
embed. Ideas welcome.</p>
</div>
</div>
</main>
<div class="site-foot">VARASYS · Simplifying Complexity — <span id="appVersion">v0.0.1-dev</span></div>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id)=>document.getElementById(id);
try{ $("appVersion").textContent = "v"+APP_VERSION.replace(/^v/,""); }catch(e){}
const THEMES=["system","light","dark"];
function effectiveTheme(p){ return p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches?"light":"dark") : p; }
function themePref(){ try{ const p=localStorage.getItem("metronome.theme"); return (p==="light"||p==="dark"||p==="system")?p:"system"; }catch(e){ return "system"; } }
function applyTheme(p){ try{ localStorage.setItem("metronome.theme",p); }catch(e){}
document.documentElement.dataset.theme = effectiveTheme(p);
$("themeBtn").textContent = p==="system" ? "◐" : p==="light" ? "☀" : "☾"; $("themeBtn").title="Theme: "+p+" (system → light → dark)"; }
$("themeBtn").onclick = ()=> applyTheme(THEMES[(THEMES.indexOf(themePref())+1)%THEMES.length]);
matchMedia("(prefers-color-scheme: light)").addEventListener("change", ()=>{ if(themePref()==="system") applyTheme("system"); });
applyTheme(themePref());
</script>
</body>
</html>

View file

@ -41,13 +41,14 @@ fi
# stamp the version into the built copy only (source stays clean)
echo "deployed v$BUILD -> $DEST_DIR"
for f in index.html editor.html player.html teacher.html stage.html micro.html showcase.html \
concepts.html embed.html \
embed.html \
info-editor.html info-initial.html info-teacher.html info-stage.html info-micro.html info-showcase.html; do
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
done
cp "$DIST_DIR/embed.js" "$DEST_DIR/embed.js"; echo " embed.js ($(stat -c '%s' "$DEST_DIR/embed.js") bytes)"
rm -f "$DEST_DIR/player-asbuilt.html" # renamed to teacher.html
rm -f "$DEST_DIR/concepts.html" # Concepts is now the landing (/)
# (stage.html / info-stage.html are deployed again — now the foot-pedal Stage stompbox)
# If real audio samples are added later (see the plan's GM-sample note),

View file

@ -242,7 +242,7 @@
<div class="row appheader" style="align-items:center; flex-wrap:wrap; gap:6px 14px; margin-bottom:8px">
<h1 style="margin:0">PE1 <span style="font-weight:400; opacity:.75">PolyMeter Editor</span> <span class="lane-meta" id="appVersion" title="build version">v0.0.1-dev</span></h1>
<div class="appheader-ctrls" style="display:flex; align-items:center; gap:10px">
<a href="/concepts.html" style="font-size:13px; color:var(--muted); text-decoration:none" title="Concept gallery — hardware & widget form factors">Concepts</a>
<a href="/" style="font-size:13px; color:var(--muted); text-decoration:none" title="Concept gallery — hardware & widget form factors">Concepts</a>
<a href="/info-editor.html" style="font-size:13px; color:var(--muted); text-decoration:none" title="About the PolyMeter Editor">Info</a>
<a href="/embed.html" style="font-size:13px; color:var(--muted); text-decoration:none" title="Embed a PolyMeter widget">Embed</a>
<button id="themeBtn" title="toggle light / dark theme"></button>

View file

@ -48,7 +48,7 @@
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/">Concepts</a>
<span class="here">Embed</span>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>

View file

@ -3,13 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VARASYS PolyMeter — polymetric groove trainer &amp; metronome</title>
<meta name="description" content="PolyMeter — a polymetric groove trainer and metronome. One engine, many form factors: a free web editor, hardware concepts, and an embeddable widget." />
<title>VARASYS PolyMeter — Concepts (polymetric groove trainer &amp; metronome)</title>
<meta name="description" content="PolyMeter — a polymetric groove trainer and metronome. One engine, many form factors: a free web editor, hardware concepts, and an embeddable widget. Try each one live." />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<!-- Landing page: the front door to the PolyMeter family. The app itself is /editor.html. Static page. -->
<script>
(function(){ try{
var p = localStorage.getItem("metronome.theme");
(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"; } })();
@ -23,65 +21,45 @@
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt);
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); }
a{ color:var(--link); }
main{ width:100%; max-width:980px; margin:0 auto; }
main{ width:100%; max-width:1040px; margin:0 auto; }
/* hero */
.hero{ text-align:center; padding:54px 12px 38px; }
.hero h1{ font-size:clamp(40px, 9vw, 76px); margin:0; letter-spacing:-.02em; line-height:1;
.hero{ text-align:center; padding:40px 12px 26px; }
.hero h1{ font-size:clamp(34px, 7vw, 58px); margin:0; letter-spacing:-.02em; line-height:1;
background:linear-gradient(90deg, var(--cyan), #6cb6ff); -webkit-background-clip:text; background-clip:text; color:transparent; }
.hero .tagline{ margin:16px auto 0; font-size:clamp(16px, 2.6vw, 21px); color:var(--txt); font-weight:600; }
.hero .pitch{ margin:14px auto 0; max-width:60ch; color:var(--muted); font-size:15px; line-height:1.6; }
.cta{ display:flex; gap:12px; justify-content:center; flex-wrap:wrap; margin-top:26px; }
.btn{ display:inline-flex; align-items:center; gap:6px; text-decoration:none; font-weight:600; font-size:15px;
padding:11px 20px; border-radius:10px; border:1px solid var(--panel-bd); color:var(--txt); background:var(--panel-bg);
transition:.14s; }
.btn:hover{ border-color:var(--cyan); }
.btn.primary{ color:#04121b; border-color:transparent; background:linear-gradient(180deg, #34c6ff, var(--cyan)); }
.btn.primary:hover{ filter:brightness(1.06); }
.hero .tagline{ margin:14px auto 0; font-size:clamp(15px, 2.4vw, 19px); color:var(--txt); font-weight:600; }
.hero .pitch{ margin:12px auto 0; max-width:64ch; color:var(--muted); font-size:14.5px; line-height:1.6; }
/* form-factor cards (shared look with the Concepts gallery) */
.section-label{ text-align:center; font-size:11px; text-transform:uppercase; letter-spacing:.12em; color:var(--muted); margin:30px 0 14px; }
.grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(230px, 1fr)); gap:16px; }
.card{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:16px;
display:flex; flex-direction:column; gap:9px; }
.section-label{ text-align:center; font-size:11px; text-transform:uppercase; letter-spacing:.12em; color:var(--muted); margin:26px 0 14px; }
.grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(300px, 1fr)); gap:18px; align-items:start; }
.card{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:14px;
display:flex; flex-direction:column; gap:10px; }
.card.wide{ grid-column:1 / -1; }
.card-head{ display:flex; align-items:center; gap:9px; flex-wrap:wrap; }
.card h3{ margin:0; font-size:16px; }
.chip{ align-self:flex-start; font-size:10px; text-transform:uppercase; letter-spacing:.08em;
padding:2px 9px; border-radius:999px; border:1px solid var(--panel-bd); color:var(--muted); }
.chip{ font-size:10px; text-transform:uppercase; letter-spacing:.08em; padding:2px 9px; border-radius:999px;
border:1px solid var(--panel-bd); color:var(--muted); }
.chip.hw{ color:var(--cyan); border-color:rgba(10,179,247,.45); }
.chip.app{ color:#2fe07a; border-color:rgba(47,224,160,.45); }
.card p{ margin:0; font-size:13px; color:var(--muted); line-height:1.5; flex:1; }
.card .links{ display:flex; gap:16px; margin-top:4px; }
/* the embedded live widget sits in a recessed frame */
.widget{ border:1px solid var(--panel-bd); border-radius:10px; overflow:hidden; background:radial-gradient(circle at 50% 0, rgba(255,255,255,.02), transparent); }
.widget [data-varasys-metronome], .widget iframe{ display:block; width:100%; }
.card p{ margin:0; font-size:13px; color:var(--muted); line-height:1.5; }
.card .links{ display:flex; gap:16px; }
.card .links a{ color:var(--link); text-decoration:none; font-size:13px; font-weight:600; }
.more{ text-align:center; margin-top:18px; font-size:14px; }
/* philosophy section */
/* philosophy */
.philosophy{ margin-top:34px; }
.phil-grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(300px, 1fr)); gap:16px; }
.phil{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:18px 18px 16px; }
.phil h3{ margin:0 0 8px; font-size:15px; display:flex; align-items:center; gap:8px; }
.phil .ic{ font-size:17px; line-height:1; filter:grayscale(.1); }
.phil .ic{ font-size:17px; line-height:1; }
.phil p{ margin:0; font-size:13.5px; color:var(--muted); line-height:1.62; }
.phil p b{ color:var(--txt); }
.site-foot{ max-width:980px; margin:42px auto 0; font-size:12px; color:var(--muted); text-align:center; }
.site-foot a{ color:var(--muted); }
</style>
</head>
<body>
<header class="site-head">
<div class="head-left">
<a class="brand" href="/" title="VARASYS — Simplifying Complexity">
<img class="brand-logo brand-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" />
<img class="brand-logo brand-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS — Simplifying Complexity" />
</a>
<span class="page-name"><b>PolyMeter</b></span>
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/embed.html">Embed</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>
</header>
/*@BUILD:include:src/header.html@*/
<main>
<section class="hero">
@ -89,108 +67,99 @@
<p class="tagline">Polymetric grooves — one engine, many form factors.</p>
<p class="pitch">Stack independent meter lanes, each with its own subdivision, drum voice and perstep
accents, to build true polymeter and ratio polyrhythm. Design it in the browser, save it as a compact
program string, and play it back on the editor, the hardware concepts, or an embedded widget — all the
same engine.</p>
<div class="cta">
<a class="btn primary" href="/editor.html">Open the Editor →</a>
<a class="btn" href="/concepts.html">Browse concepts</a>
</div>
program string, then run it on any device below — they're all the same engine. Each box is <b>live</b>:
press play and try it right here.</p>
</section>
<div class="section-label">The PolyMeter family</div>
<div class="section-label">The PolyMeter family — try each one live</div>
<div class="grid">
<div class="card">
<span class="chip app">Web app</span>
<h3>PE1 — PolyMeter Editor</h3>
<p>The full editor: stack meter lanes, perstep accents / ghosts / mutes, swing &amp; ratio polyrhythm,
set lists, and shareable links. This is where you design grooves.</p>
<div class="links"><a href="/editor.html">Open ↗</a><a href="/info-editor.html">Info ⓘ</a></div>
<div class="card wide">
<div class="card-head"><span class="chip app">Web app</span><h3>PE1 — PolyMeter Editor</h3></div>
<div class="widget"><div data-varasys-metronome="editor" data-patch="v1;t120;b16;kick:4=X..x;snare:4=.X.X;hatClosed:4/4;tom:3" data-height="560"></div></div>
<p>The full workbench: stack meter lanes, perstep accents / ghosts / mutes, swing &amp; ratio polyrhythm,
set lists and shareable links. This is where you design grooves.</p>
<div class="links"><a href="/editor.html">Open ↗</a></div>
</div>
<div class="card">
<span class="chip hw">Hardware</span>
<h3>PM1 — Teacher</h3>
<p>Fullfeature desktop console for studio &amp; lessons: 2.0″ colour TFT showing every lane, arcade buttons,
thumbroller, 1/4″ instrument passthrough with analog click injection + balancedTRS out. USBC powered.</p>
<div class="links"><a href="/teacher.html">Open ↗</a><a href="/info-teacher.html">Info &amp; BOM ⓘ</a></div>
<div class="card-head"><span class="chip hw">Hardware</span><h3>PM1 — Teacher</h3></div>
<div class="widget"><div data-varasys-metronome="teacher" data-patch="v1;t120;b16;kick:4=X.x.;snare:4=.X.X;hatClosed:4/4" data-height="440"></div></div>
<p>Fullfeature desktop console for studio &amp; lessons: colour TFT showing every lane, arcade buttons,
thumbroller, instrument passthrough with analog click.</p>
<div class="links"><a href="/teacher.html">Open ↗</a></div>
</div>
<div class="card">
<span class="chip hw">Hardware</span>
<h3>PM1 — Stage</h3>
<div class="card-head"><span class="chip hw">Hardware</span><h3>PM1 — Stage</h3></div>
<div class="widget"><div data-varasys-metronome="stage" data-patch="v1;t120;b16;kick:4=X.x.;snare:4=.X.X;hatClosed:4/4" data-height="430"></div></div>
<p>Footpedal stompbox: two footswitches (tap / next), expressionpedal input, a big floorreadable RGB
beat light, instrument passthrough with analog click. DualUSBC daisychain.</p>
<div class="links"><a href="/stage.html">Open ↗</a><a href="/info-stage.html">Info &amp; BOM ⓘ</a></div>
beat light, instrument passthrough. DualUSBC daisychain.</p>
<div class="links"><a href="/stage.html">Open ↗</a></div>
</div>
<div class="card">
<span class="chip hw">Hardware</span>
<h3>PMµ — Micro</h3>
<div class="card-head"><span class="chip hw">Hardware</span><h3>PMµ — Micro</h3></div>
<div class="widget"><div data-varasys-metronome="micro" data-patch="v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2" data-height="240"></div></div>
<p>Long, narrow inline practice bar: instrument in one end, amp/headphones out the other, click mixed in.
Clickable thumbroller, amber 14segment display, USBC powered.</p>
<div class="links"><a href="/micro.html">Open ↗</a><a href="/info-micro.html">Info &amp; BOM ⓘ</a></div>
Clickable thumbroller, amber 14segment display, USBC.</p>
<div class="links"><a href="/micro.html">Open ↗</a></div>
</div>
<div class="card">
<span class="chip hw">Hardware</span>
<h3>PMS — Showcase</h3>
<p>A display piece shaped like a classic pyramid windup metronome — an RGBlight pendulum easing to the
beat, with light rows for every lane's subdivisions, accents &amp; mutes. USBC powered.</p>
<div class="links"><a href="/showcase.html">Open ↗</a><a href="/info-showcase.html">Info &amp; BOM ⓘ</a></div>
<div class="card-head"><span class="chip hw">Hardware</span><h3>PMS — Showcase</h3></div>
<div class="widget"><div data-varasys-metronome="showcase" data-patch="v1;t108;kick:4=X..x;snare:4=.X.X;hatClosed:4/4;tom:3~" data-height="540"></div></div>
<p>A display piece shaped like a classic pyramid windup metronome — the pendulum is RGB light easing to
the beat, with light rows for every lane's subdivisions, accents &amp; mutes.</p>
<div class="links"><a href="/showcase.html">Open ↗</a></div>
</div>
<div class="card">
<span class="chip">Widget</span>
<h3>Embed anywhere</h3>
<p>Drop any form factor into your own page with one <code>&lt;div&gt;</code> + a script — preloaded with a
program string and autosizing. Our own pages use the same loader.</p>
<div class="links"><a href="/embed.html">Docs</a></div>
<div class="card-head"><span class="chip">Concept</span><h3>PM1 — Initial</h3></div>
<div class="widget"><div data-varasys-metronome="initial" data-patch="v1;t120;b16;kick:4=X.x.;snare:4=.X.X;hatClosed:4/4" data-height="440"></div></div>
<p>The original idealized device render — full multilane display and setlist navigation. The northstar
concept; the buildable take is the Teacher.</p>
<div class="links"><a href="/player.html">Open</a></div>
</div>
<div class="card">
<div class="card-head"><span class="chip">Widget</span><h3>Embed anywhere</h3></div>
<p style="flex:1">Every form factor above is an embeddable widget — drop one into your own page with a single
<code>&lt;div&gt;</code> + a script, preloaded with a program string and autosizing. (Exactly how the
boxes on this page work.)</p>
<div class="links"><a href="/embed.html">Embed docs ↗</a></div>
</div>
</div>
<p class="more"><a href="/concepts.html">See all concepts, including the PM1 Initial render →</a></p>
<section class="philosophy">
<div class="section-label">Philosophy</div>
<div class="phil-grid">
<div class="phil">
<h3><span class="ic">🛠️</span> Program on the web, play on any device</h3>
<p>The website is the workbench. Design your grooves in the <a href="/editor.html">PE1 editor</a>
stack meters, set perstep accents, build set lists — and every pattern saves to a compact
<b>program string</b> (a whole set list to a single code). That same string loads into whichever
form factor fits the moment: the <a href="/teacher.html">Teacher</a> on a studio desk, the
<a href="/micro.html">Micro</a> inline at the practice desk, or an <a href="/embed.html">embedded
widget</a> in someone else's app. One engine, one language — you author once and run it anywhere,
choosing the device by the use scenario rather than relearning a new box each time.</p>
<p>The website is the workbench. Design your grooves in the <a href="/editor.html">PE1 editor</a> and
every pattern saves to a compact <b>program string</b> (a whole set list to a single code). That same
string loads into whichever form factor fits the moment — Teacher on a studio desk, Stage on a
pedalboard, Micro inline at the practice desk, or an <a href="/embed.html">embedded widget</a>. One
engine, one language: author once, run it anywhere.</p>
</div>
<div class="phil">
<h3><span class="ic">🔌</span> USBC power everywhere — no batteries</h3>
<p>Every device in the family is powered over a single <b>USBC</b> port — no internal battery to
swell, leak or wear out, and nothing proprietary to replace. Plug into a wall adapter for a
permanent install, or carry a power bank exactly the way you already do for your phone. Standardising
on one connector across the whole range keeps the builds simple and <b>futureproofs</b> the
project as USBC becomes universal.</p>
<p>Every device is powered over a single <b>USBC</b> port — no internal battery to wear out. Plug into a
wall adapter for a permanent install or carry a power bank like you already do for your phone; the
larger units add a second USBC <b>passthrough</b> so pedals daisychain off one source. One connector
across the range keeps builds simple and <b>futureproof</b>.</p>
</div>
</div>
</section>
</main>
<div class="site-foot">VARASYS · Simplifying Complexity ·
<a href="https://codeberg.org/VARASYS/metronome" target="_blank" rel="noopener">source</a>
<span id="appVersion">v0.0.1-dev</span></div>
/*@BUILD:include:src/footer.html@*/
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id)=>document.getElementById(id);
try{ $("appVersion").textContent = "v"+APP_VERSION.replace(/^v/,""); }catch(e){}
const THEMES=["system","light","dark"];
function effectiveTheme(p){ return p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches?"light":"dark") : p; }
function themePref(){ try{ const p=localStorage.getItem("metronome.theme"); return (p==="light"||p==="dark"||p==="system")?p:"system"; }catch(e){ return "system"; } }
function applyTheme(p){ try{ localStorage.setItem("metronome.theme",p); }catch(e){}
document.documentElement.dataset.theme = effectiveTheme(p);
$("themeBtn").textContent = p==="system" ? "◐" : p==="light" ? "☀" : "☾"; $("themeBtn").title="Theme: "+p+" (system → light → dark)"; }
$("themeBtn").onclick = ()=> applyTheme(THEMES[(THEMES.indexOf(themePref())+1)%THEMES.length]);
matchMedia("(prefers-color-scheme: light)").addEventListener("change", ()=>{ if(themePref()==="system") applyTheme("system"); });
applyTheme(themePref());
/*@BUILD:include:src/chrome.js@*/
</script>
<script src="/embed.js"></script>
</body>
</html>

View file

@ -41,7 +41,7 @@
</div>
<nav class="site-nav">
<a href="/editor.html">Open ↗</a>
<a href="/concepts.html">Concepts</a>
<a href="/">Concepts</a>
<a href="/embed.html">Embed</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>

View file

@ -42,7 +42,7 @@
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/">Concepts</a>
<a href="/player.html">Open ↗</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>

View file

@ -40,7 +40,7 @@
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/">Concepts</a>
<a href="/micro.html">Open ↗</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>

View file

@ -40,7 +40,7 @@
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/">Concepts</a>
<a href="/showcase.html">Open ↗</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>

View file

@ -40,7 +40,7 @@
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/">Concepts</a>
<a href="/stage.html">Open ↗</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>

View file

@ -40,7 +40,7 @@
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/">Concepts</a>
<a href="/teacher.html">Open ↗</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>

View file

@ -135,7 +135,7 @@
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/">Concepts</a>
<a href="/info-micro.html">Info</a>
<a href="/embed.html">Embed</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>

View file

@ -214,7 +214,7 @@
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/">Concepts</a>
<a href="/info-initial.html">Info</a>
<a href="/embed.html">Embed</a>
<button id="fsBtn" class="tbtn" title="Full screen (landscape)"></button>

View file

@ -6,7 +6,6 @@
<title>VARASYS PMS — Showcase (RGB pendulum metronome)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<script>
/* ?embed=1 → strip site chrome + auto-size to the host */
(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){} }
@ -15,12 +14,13 @@
</script>
<!--
PM-S "Showcase" — a display-piece metronome shaped like a classic pyramid wind-up
unit, but the swinging pendulum is simulated with RGB light (a glowing bob on a
rod, easing to the extremes on each beat exactly like the mechanical original),
and rows of RGB segment lights show every lane's subdivisions / accents / mutes.
Same RGB-everywhere RP2040 firmware/engine; USB-C powered (dual-port daisy-chain).
Visuals are latency-compensated so the swing lands when the click is HEARD.
Shares src/engine.js.
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");
@ -31,24 +31,24 @@
<style>
/*@BUILD:include:src/base.css@*/
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
--panel-bd:#2a313c; --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; --field-bg:#f1f4f8; --field-bd:#d2dae4 }
--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:14px }
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,.6)); }
#stage{ display:block; width:100%; height:auto }
.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:46px; font-size:15px }
.tempo, .trk{ display:flex; align-items:center; gap:8px; font-size:13px; color:var(--muted) }
.tempo b, .trk b{ color:var(--txt); min-width:34px; text-align:center; display:inline-block; font-variant-numeric:tabular-nums }
.u{ font-size:10px; letter-spacing:.08em; text-transform:uppercase }
#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 }
@ -56,34 +56,22 @@
</head>
<body>
<header class="site-head">
<div class="head-left">
<a class="brand" href="/" title="VARASYS — Simplifying Complexity">
<img class="brand-logo brand-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" />
<img class="brand-logo brand-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS — Simplifying Complexity" />
</a>
<span class="page-name"><b>PMS</b> · Showcase (RGB pendulum)</span>
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/info-showcase.html">Info</a>
<a href="/embed.html">Embed</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>
</header>
/*@BUILD:include:src/header.html@*/
<div class="device"><canvas id="stage" width="300" height="440" aria-label="RGB pendulum metronome"></canvas></div>
<main>
<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)"></button>
<div class="tempo"><button id="slower" title="Slower"></button><b id="bpmLbl">120</b><span class="u">bpm</span><button id="faster" title="Faster">+</button></div>
<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">A showcase piece: the <b>RGB pendulum</b> swings in time (easing to the beat like a real windup
metronome); the light rows below show each lane's <b>subdivisions, accents &amp; mutes</b>. Click the piece to
start/stop · scroll it for tempo.</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>
</main>
/*@BUILD:include:src/footer.html@*/
<script>
const APP_VERSION = "v0.0.1-dev";
@ -134,89 +122,75 @@ function loadTrack(i){
const was=state.running; if(was){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
if(was) startAudio(); syncBtns();
}
function syncBtns(){ $("play").textContent = state.running ? "■" : "▶";
$("bpmLbl").textContent = state.bpm; $("trkLbl").textContent = (trackIdx+1)+"/"+tracks.length; }
function syncBtns(){ $("play").textContent = state.running ? "■ Stop" : "▶ Start";
$("trkLbl").textContent = (trackIdx+1)+"/"+tracks.length; }
/* ========================= RGB PENDULUM + LANE LIGHTS (canvas) =============== */
const cv=$("stage"), g=cv.getContext("2d"), CW=300, CH=440;
/* ========================= 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.42; // pendulum swing (rad) ~24°
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 LEVELCOL = { 2:"#ff9b2e", 1:"#33d0ff", 3:"#9b7bff", 0:"#39424f" }; // accent / normal / ghost / mute
function roundRect(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 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(){
// truncated-pyramid silhouette (classic metronome), matte graphite
g.clearRect(0,0,CW,CH);
const tlx=92, trx=208, blx=24, brx=276, topY=14, botY=420;
const grd=g.createLinearGradient(0,0,0,botY); grd.addColorStop(0,"#2c2e34"); grd.addColorStop(1,"#141518");
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();
// left-edge sheen
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();
// brand silk
g.textAlign="center"; g.fillStyle="#aab2bc";
g.font="700 9px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("V A R A S Y S", CW/2, 30);
g.font="600 7.5px 'Segoe UI',Roboto,Arial,sans-serif"; g.globalAlpha=.8; g.fillText("PMS SHOWCASE", CW/2, 41); g.globalAlpha=1;
g.font="700 9px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("V A R A S Y S", CW/2, 33);
g.globalAlpha=.8; g.font="600 7.5px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("PMS SHOWCASE", CW/2, 44); g.globalAlpha=1;
}
function drawWindow(){
// recessed dark window for the pendulum
roundRect(70,56,160,212,10); g.fillStyle="#05070a"; g.fill();
g.lineWidth=1; g.strokeStyle="rgba(0,0,0,.8)"; g.stroke();
// faint scale ticks (an arc the bob sweeps across)
const px=CW/2, py=258; // pivot near the bottom of the window
g.strokeStyle="rgba(255,255,255,.07)"; g.lineWidth=1;
for(let k=-3;k<=3;k++){ const a=(k/3)*MAXANG; const x1=px+Math.sin(a)*150, y1=py-Math.cos(a)*150, x2=px+Math.sin(a)*162, y2=py-Math.cos(a)*162;
g.beginPath(); g.moveTo(px+Math.sin(a)*70,py-Math.cos(a)*70); g.lineTo(px+Math.sin(a)*150,py-Math.cos(a)*150); g.globalAlpha=.05; g.stroke(); g.globalAlpha=1; }
return {px,py};
}
function drawPendulum(piv){
const px=piv.px, py=piv.py, L=176, a=pend;
const tipX=px+Math.sin(a)*L, tipY=py-Math.cos(a)*L;
const bobX=px+Math.sin(a)*L*0.66, bobY=py-Math.cos(a)*L*0.66;
function drawPendulum(){
g.save(); g.translate(PIVX,PIVY); g.rotate(pend); // rod frame: up = -y, tilts with the swing
// rod
g.strokeStyle="rgba(170,180,196,.5)"; g.lineWidth=3; g.lineCap="round";
g.beginPath(); g.moveTo(px,py); g.lineTo(tipX,tipY); g.stroke();
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); });
// combined-meter LEDs: each lane is a moving point of light along the bar (its current step position)
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;
}
// 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; glows on the beat
const wy=-bpmToFrac(state.bpm)*ROD, lit=Math.max(0,flash);
const wc = flashAccent ? "rgb(255,155,46)" : "rgb(51,208,255)";
g.shadowColor=wc; g.shadowBlur=8+22*lit;
g.fillStyle="#3a4049"; roundRectP(-15,wy-11,30,22,4); g.fill(); g.shadowBlur=0;
g.fillStyle=wc; g.globalAlpha=.30+0.7*lit; roundRectP(-13,wy-3.5,26,7,2); g.fill(); g.globalAlpha=1;
g.fillStyle="rgba(255,255,255,.10)"; roundRectP(-15,wy-11,30,5,2); g.fill();
// pivot hub
g.beginPath(); g.arc(px,py,6,0,7); g.fillStyle="#2a2f37"; g.fill();
g.beginPath(); g.arc(px,py,2.5,0,7); g.fillStyle="#aab2bc"; g.fill();
// glowing RGB bob (brightens on the beat tick)
const col = flashAccent ? "#ff9b2e" : "#33d0ff", lit=0.4+0.6*Math.max(0,flash);
g.shadowColor=col; g.shadowBlur=18+26*Math.max(0,flash);
g.beginPath(); g.arc(bobX,bobY,12,0,7);
const bg=g.createRadialGradient(bobX-3,bobY-3,2,bobX,bobY,12); bg.addColorStop(0,"#ffffff"); bg.addColorStop(.4,col); bg.addColorStop(1,"rgba(0,0,0,.2)");
g.globalAlpha=lit; g.fillStyle=bg; g.fill(); g.globalAlpha=1; g.shadowBlur=0;
// tip glow dot
g.beginPath(); g.arc(tipX,tipY,3.5,0,7); g.fillStyle=col; g.shadowColor=col; g.shadowBlur=8*Math.max(.2,flash); g.fill(); g.shadowBlur=0;
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 drawLanes(){
// RGB segment rows for each lane: subdivisions / accents / mutes, with the playhead
const lanes=meters.slice(0,4), x0=40, x1=260, top=294, rowH=26;
g.textAlign="left";
for(let li=0; li<lanes.length; li++){ const m=lanes[li], steps=m.beatsPerBar*m.stepsPerBeat, y=top+li*rowH;
const cw=(x1-x0)/Math.max(1,steps), pad=Math.min(3, cw*0.18);
for(let s=0;s<steps;s++){ const lvl=m.beatsOn[s]|0, x=x0+s*cw, isBeat=(s%m.stepsPerBeat===0);
const cur = state.running && s===m.currentStep;
const base = cur ? 1 : (lvl===0 ? 0.10 : 0.18);
g.globalAlpha = base; g.fillStyle = LEVELCOL[lvl]||LEVELCOL[0];
const h = isBeat ? 13 : 9, yy = y + (13-h)/2;
if(cur){ g.shadowColor=LEVELCOL[lvl]||"#33d0ff"; g.shadowBlur=12; }
roundRect(x+pad, yy, cw-2*pad, h, 2); g.fill(); g.shadowBlur=0; g.globalAlpha=1;
}
}
// BPM + item name on the base plinth
g.textAlign="center"; g.fillStyle="#c7d0db"; g.font="700 22px 'Segoe UI',Roboto,Arial,sans-serif";
g.fillText(String(state.bpm), CW/2-2, 410); g.font="600 9px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillStyle="#7f8b9a";
g.fillText("BPM", CW/2+44, 410);
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 9px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText(nm.length>30?nm.slice(0,29)+"…":nm, CW/2, 396);
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(){
@ -235,37 +209,29 @@ function draw(){
if(m.vqPtr>512){ m.vq=m.vq.slice(m.vqPtr); m.vqPtr=0; }
}
}
// pendulum target angle (eases to extreme each beat, like a real metronome)
let tgt=0;
if(state.running && beatCount>=0){ let frac=(now-lastBeatTime)/(60/state.bpm); if(frac<0)frac=0; if(frac>1.2)frac=1.2;
tgt = MAXANG*Math.cos(Math.PI*(beatCount+frac)); }
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(); const piv=drawWindow(); drawLanes(); drawPendulum(piv);
drawBody(); drawPendulum(); drawReadout();
requestAnimationFrame(draw);
}
/* ========================= CONTROLS ========================================== */
cv.addEventListener("click", ()=>toggle());
// 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 = (e)=>{ e.stopPropagation(); toggle(); };
$("slower").onclick = ()=>{ setBpm(state.bpm-1); syncBtns(); };
$("faster").onclick = ()=>{ setBpm(state.bpm+1); syncBtns(); };
$("play").onclick = ()=>toggle();
$("prev").onclick = ()=>loadTrack(trackIdx-1);
$("next").onclick = ()=>loadTrack(trackIdx+1);
/* theme toggle */
const THEMES=["system","light","dark"];
function effTheme(p){ return p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches?"light":"dark") : p; }
function themePref(){ try{ const p=localStorage.getItem("metronome.theme"); return (p==="light"||p==="dark"||p==="system")?p:"system"; }catch(e){ return "system"; } }
function applyTheme(p){ try{ localStorage.setItem("metronome.theme",p); }catch(e){}
document.documentElement.dataset.theme = effTheme(p);
$("themeBtn").textContent = p==="system" ? "◐" : p==="light" ? "☀" : "☾"; $("themeBtn").title="Theme: "+p; }
$("themeBtn").onclick = ()=> applyTheme(THEMES[(THEMES.indexOf(themePref())+1)%THEMES.length]);
matchMedia("(prefers-color-scheme: light)").addEventListener("change", ()=>{ if(themePref()==="system") applyTheme("system"); });
applyTheme(themePref());
addEventListener("keydown",(e)=>{
const tag=e.target?e.target.tagName:""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
if(e.key===" "){ e.preventDefault(); toggle(); }
@ -279,6 +245,7 @@ addEventListener("keydown",(e)=>{
{ const ht=tracksFromHash(); if(ht) tracks=ht; }
loadTrack(0); syncBtns();
requestAnimationFrame(draw);
/*@BUILD:include:src/chrome.js@*/
</script>
</body>
</html>

View file

@ -45,3 +45,21 @@ body {
.bom .part { color:var(--txt,#c7d0db); }
.bom .part .spec { color:var(--muted,#7f8b9a); font-weight:400; }
.bom tr.total td { font-weight:700; color:var(--txt,#c7d0db); border-top:2px solid var(--panel-bd,#2a313c); border-bottom:none; padding-top:8px; }
/* ---- shared site footer ---- */
.site-foot { width:100%; max-width:980px; margin:40px auto 0; font-size:12px; color:var(--muted,#7f8b9a);
text-align:center; display:flex; align-items:center; justify-content:center; gap:8px; flex-wrap:wrap; }
.site-foot a { color:var(--muted,#7f8b9a); }
.site-foot a:hover { color:var(--txt,#c7d0db); }
.site-foot .dot { opacity:.5; }
/* ---- expandable "Spec & BOM" disclosure (merged onto each form-factor page) ---- */
details.spec { width:100%; max-width:760px; margin:18px auto 0; border:1px solid var(--panel-bd,#2a313c);
border-radius:12px; background:var(--panel-bg,#161b22); }
details.spec > summary { cursor:pointer; padding:13px 16px; font-weight:600; font-size:14px; color:var(--txt,#c7d0db);
list-style:none; display:flex; align-items:center; gap:9px; }
details.spec > summary::-webkit-details-marker { display:none; }
details.spec > summary::before { content:"▸"; color:var(--muted,#7f8b9a); transition:transform .15s; }
details.spec[open] > summary::before { transform:rotate(90deg); }
details.spec .spec-body { padding:2px 16px 16px; }
[data-embed] details.spec { display:none !important; }

19
src/chrome.js Normal file
View file

@ -0,0 +1,19 @@
/* Shared site chrome assembled into every page by build.sh.
Wires the header theme toggle (system - light - dark, shared "metronome.theme")
and stamps the footer version. Expects a global APP_VERSION declared earlier in
the page (deploy.sh rewrites that line) and the header/footer markup present. */
(function () {
var byId = function (id) { return document.getElementById(id); };
try { var v = byId("appVersion"); if (v && typeof APP_VERSION !== "undefined") v.textContent = "v" + APP_VERSION.replace(/^v/, ""); } catch (e) {}
var THEMES = ["system", "light", "dark"];
function eff(p) { return p === "system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p; }
function pref() { try { var p = localStorage.getItem("metronome.theme"); return (p === "light" || p === "dark" || p === "system") ? p : "system"; } catch (e) { return "system"; } }
function apply(p) {
try { localStorage.setItem("metronome.theme", p); } catch (e) {}
document.documentElement.dataset.theme = eff(p);
var b = byId("themeBtn"); if (b) { b.textContent = p === "system" ? "◐" : p === "light" ? "☀" : "☾"; b.title = "Theme: " + p + " (system → light → dark)"; }
}
var btn = byId("themeBtn"); if (btn) btn.onclick = function () { apply(THEMES[(THEMES.indexOf(pref()) + 1) % THEMES.length]); };
try { matchMedia("(prefers-color-scheme: light)").addEventListener("change", function () { if (pref() === "system") apply("system"); }); } catch (e) {}
apply(pref());
})();

8
src/footer.html Normal file
View file

@ -0,0 +1,8 @@
<!-- Shared site footer — assembled into every page by build.sh. -->
<footer class="site-foot">
<span>VARASYS · Simplifying Complexity</span>
<span class="dot">·</span>
<a href="https://codeberg.org/VARASYS/metronome" target="_blank" rel="noopener">source</a>
<span class="dot">·</span>
<span id="appVersion">v0.0.1-dev</span>
</footer>

14
src/header.html Normal file
View file

@ -0,0 +1,14 @@
<!-- Shared site header — assembled into every page by build.sh.
Brand goes to Concepts (the landing); nav: Concepts / Editor / Embed / Theme. -->
<header class="site-head">
<a class="brand" href="/" title="VARASYS PolyMeter — Concepts (home)">
<img class="brand-logo brand-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" />
<img class="brand-logo brand-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS — Simplifying Complexity" />
</a>
<nav class="site-nav">
<a href="/">Concepts</a>
<a href="/editor.html">Editor</a>
<a href="/embed.html">Embed</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>
</header>

View file

@ -119,7 +119,7 @@
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/">Concepts</a>
<a href="/info-stage.html">Info</a>
<a href="/embed.html">Embed</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>

View file

@ -212,7 +212,7 @@
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/">Concepts</a>
<a href="/info-teacher.html">Info</a>
<a href="/embed.html">Embed</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>