Compare commits

..

10 commits

Author SHA1 Message Date
Me Here
4e0a68f35b pm-mobile: fix landscape right-edge overflow + per-lane mute toggle
- Landscape grid used 40%/60% columns plus a 4vw gap, summing past 100% and
  overhanging the right edge. Switch to minmax(0,2fr)/minmax(0,3fr) so the gap
  is subtracted before sizing, add min-width:0 to grid items, and widen the
  inter-section (row) gaps. Bump the portrait section gap too.
- Add an inline speaker mute button at the left of every lane for quick
  selective muting; muted lanes dim the label/pads but keep the toggle crisp.
  The lane sheet's Mute checkbox stays in sync (both flip m.enabled).
- Help: note the lane mute toggle in the "Edit the beat" step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:21:26 -05:00
Me Here
9b5d24edc0 docs(CLAUDE.md): refresh stale Rust/firmware status, document mobile PWA
Audited with the claude-md-management plugin's claude-md-improver skill:
- Rust port: codec + scheduler done and RP2350-ready; per-board firmware
  crates exist; PM_G-1 Grid now ships native Rust firmware (rust/pm-grid).
- Mark pico-scroll (CircuitPython) as the superseded Grid prototype.
- Document mobile.html / mobile-sessions.html as the installable PWA plus its
  manifest + mobile-sw.js service worker.
- Device-ID note: .mpy for CircuitPython editions, .uf2 for Grid.
- rust/run.sh covers the workspace; add the pm-grid firmware build command.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:16:16 -05:00
Me Here
027202e154 pm-mobile: reorder layout — header (logo/icons + volume) full width, body grouped by track
Per UX review + user decisions:
- Header is just the logo/icon row then a full-width volume row, in both
  orientations (selector moved out of the header).
- Portrait body order: BPM -> set/track/save -> repeat/ramp/gap -> lanes ->
  transport -> journal, so a track's identity, settings and lanes stay grouped.
- Landscape keeps a 2-column body (tempo + transport left; selector + settings
  + lanes right) to avoid the cramped single-stack overlap; header rows stay
  full width.
- Help tour reordered to match (Tempo now follows Controls).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:19:49 -05:00
Me Here
7b11ebdd59 pm-mobile: logo + header icons are a full-width top bar in both orientations
Unwrap the #topctl grouping so #brandrow (logo + share/help/theme/fullscreen)
is always its own full-width row at the very top. In landscape it spans the
full width on its own line (flex 1 1 100%) while volume + the pickers share the
line below, instead of being squeezed into a side column.

Controls help now highlights the union of the brand bar + volume row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:03:05 -05:00
Me Here
2e57b4c507 pm-mobile: fix landscape overlap — rework grid + tame tall buttons
The taller (min-height:66) transport buttons and the track-panel-in-the-left-
column layout overflowed the short landscape viewport, causing sections to
bleed into each other.

- Landscape grid: left column is stage + transport again; the track panel now
  sits above the lanes in the right column (stage spans the two upper rows).
- Reset .tbtn min-height to 0 in landscape and cap height to ~10vmin so the
  portrait 66px floor doesn't blow up the transport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:58:17 -05:00
Me Here
185ed7736b pm-mobile: more vertical breathing room between sections
Bump the #mid inter-section gap and add padding above the first section so
the top bar, track panel, tempo, lanes and transport are less crowded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:51:46 -05:00
Me Here
80cebaad5a pm-mobile: ~75% taller transport buttons (min-height 38->66px), raise transport cap to fit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:42:47 -05:00
Me Here
ee0c6f329a pm-mobile: help tour covers tempo-nudge + prev/next; coachmark can span a group
- showTour now unions the rects of all elements matching a step's selector,
  so a step can highlight a row of buttons.
- New steps: "Nudge the tempo" (the four ±10/±1 buttons) and
  "Previous / next track" (the ⏮/⏭ buttons).
- Trim the now-duplicated ±10/±1 mention from the Tempo step; note the TAP
  button there instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:45:55 -05:00
Me Here
4401be3c5d pm-mobile: Controls help covers only icons+volume, not the pickers; drop logo from copy
Wrap the brand/icon row and volume slider in #topctl and aim the Controls
coachmark there, so the set-list / track pickers are no longer inside the
highlight. Reword the step to stop describing the logo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:44:44 -05:00
Me Here
355405d946 pm-mobile: help tour runs top-to-bottom; Controls highlights whole top bar
Reorder coachmarks to follow the on-screen layout top→down (track-settings
panel now comes before the tempo, matching its new position), and point the
Controls step at #top so the hole covers the logo, icons and volume slider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:41:44 -05:00
2 changed files with 67 additions and 41 deletions

View file

@ -14,8 +14,9 @@ VARASYS PolyMeter: a polymetric groove-trainer / metronome **engine** that ships
node tests/run.mjs # track-format conformance: every golden vector through engine.js AND pico-cp/app.py node tests/run.mjs # track-format conformance: every golden vector through engine.js AND pico-cp/app.py
node tests/run.mjs -v # + print expected/actual diffs for unexpected failures node tests/run.mjs -v # + print expected/actual diffs for unexpected failures
./rust/run.sh # cargo test (Rust track-format crate vs the same golden vectors), in container ./rust/run.sh # cargo test (Rust workspace — track-format crate vs the same golden vectors), in container
./rust/run.sh cargo build # or `bash` for a shell ./rust/run.sh cargo build # or `bash` for a shell
./rust/pm-grid/build.sh # build the PM_G-1 Grid firmware (pm-grid.uf2)
./hardware/eda/run.sh # interactive shell in the KiCad/ngspice container (lands in hardware/kicad/) ./hardware/eda/run.sh # interactive shell in the KiCad/ngspice container (lands in hardware/kicad/)
./hardware/eda/run.sh kicad-cli sch erc pm_k1_core.kicad_sch # ERC on the board schematic ./hardware/eda/run.sh kicad-cli sch erc pm_k1_core.kicad_sch # ERC on the board schematic
@ -43,7 +44,7 @@ Every deployed page is **one self-contained `.html` file, zero runtime dependenc
- `/*@BUILD:include:src/<file>@*/` inlines a shared partial. The important ones: `engine.js` (audio scheduler + DSL), `setlists.js` (seed set lists baked into every page), `base.css`, `header.html`/`footer.html`/`chrome.js`, `progbox.{html,js}` (program box), `infoembed.{html,js}` (info-page live widget), `livesync.js` (beta only). - `/*@BUILD:include:src/<file>@*/` inlines a shared partial. The important ones: `engine.js` (audio scheduler + DSL), `setlists.js` (seed set lists baked into every page), `base.css`, `header.html`/`footer.html`/`chrome.js`, `progbox.{html,js}` (program box), `infoembed.{html,js}` (info-page live widget), `livesync.js` (beta only).
- `@BUILD:favicon@` / `@BUILD:logo-dark@` / `@BUILD:logo-light@` inline base64 blobs from `assets/`. - `@BUILD:favicon@` / `@BUILD:logo-dark@` / `@BUILD:logo-light@` inline base64 blobs from `assets/`.
`build.sh` asserts no `@BUILD:` markers survive. **`dist/` is generated and git-ignored — never edit it by hand.** Edit the source `*.html` in the repo root and the partials in `src/`; `deploy.sh` always builds first. Each page exists as a lean widget (`<device>.html`, what `?embed=1` serves) plus a spec page (`info-<device>.html` that embeds it). `editor.html` is the main app; `editor-beta.html` is identical plus `livesync.js` (live mirror to a connected device over Web-MIDI SysEx — protocol in `docs/livesync-protocol.md`). `build.sh` asserts no `@BUILD:` markers survive. **`dist/` is generated and git-ignored — never edit it by hand.** Edit the source `*.html` in the repo root and the partials in `src/`; `deploy.sh` always builds first. Each page exists as a lean widget (`<device>.html`, what `?embed=1` serves) plus a spec page (`info-<device>.html` that embeds it). `editor.html` is the main app; `editor-beta.html` is identical plus `livesync.js` (live mirror to a connected device over Web-MIDI SysEx — protocol in `docs/livesync-protocol.md`). `mobile.html` is the installable phone/tablet **PWA** (with `mobile-sessions.html` as its practice journal); `build.sh` ships its PWA support files (`manifest.webmanifest` + the `mobile-sw.js` service worker for offline use).
State (set lists, practice log, theme) lives in `localStorage`; nothing is uploaded — share links encode everything in the URL hash (`#p=` patch, `#sl=` base64url set list). State (set lists, practice log, theme) lives in `localStorage`; nothing is uploaded — share links encode everything in the URL hash (`#p=` patch, `#sl=` base64url set list).
@ -54,15 +55,15 @@ All run the same DSL and `programs.json`. When you touch the grammar, the `pico-
- `pico/main.py` — single-file **MicroPython**, the simple no-computer fallback (52Pi EP-0172 Kit). - `pico/main.py` — single-file **MicroPython**, the simple no-computer fallback (52Pi EP-0172 Kit).
- `pico-cp/`**CircuitPython** appliance (USB-drive, push-programming over USB-MIDI, on-device practice log, one-click A/B firmware update). `build.sh` precompiles `app.py``app.mpy` (the RP2040 OOMs compiling the ~56KB source at boot). - `pico-cp/`**CircuitPython** appliance (USB-drive, push-programming over USB-MIDI, on-device practice log, one-click A/B firmware update). `build.sh` precompiles `app.py``app.mpy` (the RP2040 OOMs compiling the ~56KB source at boot).
- `pico-explorer/` — CircuitPython sibling for the Pimoroni Explorer (RP2350, buttons-only, no touch). - `pico-explorer/` — CircuitPython sibling for the Pimoroni Explorer (RP2350, buttons-only, no touch).
- `pico-scroll/`CircuitPython sibling for the Pimoroni Pico Scroll Pack (PIM545: 17×7 mono LED matrix + 4 buttons on a plain RP2040 Pico). Engine/scheduler/SysEx copied from `pico-explorer/`; vendors a bulk-framebuffer IS31FL3731 driver; renders three LED views (Grid/Pendulum/BPM). No speaker (audio over USB-MIDI). This is the UI prototype for the eventual Rust `pm-grid` board — see `docs/rust-port.md`. - `pico-scroll/`**superseded** CircuitPython prototype for the Pimoroni Pico Scroll Pack (PIM545: 17×7 mono LED matrix + 4 buttons on a plain RP2040 Pico). It was the UI prototype for the Grid; **the shipping Grid firmware is now the Rust `rust/pm-grid` crate** (`build.sh` no longer bundles the CircuitPython build). Kept for reference — see `docs/rust-port.md`.
Firmware device IDs (reported on the SysEx `0x02→0x03` version query, used by the editor's firmware-push to pick the right `.mpy`): `K` Kit, `X` Explorer, `G` Grid. Firmware device IDs (reported on the SysEx `0x02→0x03` version query, used by the editor's firmware-push to pick the right firmware — `.mpy` for the CircuitPython editions; Grid is a Rust `.uf2`): `K` Kit, `X` Explorer, `G` Grid.
**`pico-cp/app.py` and `pico-explorer/app.py` must stay pure ASCII** — they are pushed over USB-MIDI as 7-bit data, and a stray non-ASCII char gets mangled to NUL and bricks the device. `build.sh` enforces this with an assert. **`pico-cp/app.py` and `pico-explorer/app.py` must stay pure ASCII** — they are pushed over USB-MIDI as 7-bit data, and a stray non-ASCII char gets mangled to NUL and bricks the device. `build.sh` enforces this with an assert.
## Hardware (PM_K-1 custom board) & Rust port — containerized only ## Hardware (PM_K-1 custom board) & Rust port — containerized only
Per the standing rule (see memory), **all EDA and Rust tooling runs in containers via the `run.sh` wrappers — never install these on the host.** Add tools to the respective `Containerfile`. `hardware/eda/` holds the KiCad 9 + ngspice environment; the board design is `hardware/DESIGN.md` + `BOM*.csv` + `kicad/` + the code-defined circuits in `eda/circuits/`. The Rust port is staged inside-out (`docs/rust-port.md`): the pure track-format crate is done and passing; drivers/firmware come only after the engine is proven against the vectors. Per the standing rule (see memory), **all EDA and Rust tooling runs in containers via the `run.sh` wrappers — never install these on the host.** Add tools to the respective `Containerfile`. `hardware/eda/` holds the KiCad 9 + ngspice environment; the board design is `hardware/DESIGN.md` + `BOM*.csv` + `kicad/` + the code-defined circuits in `eda/circuits/`. The Rust port is staged inside-out (`docs/rust-port.md`): the `track-format` codec **and** scheduler are done, passing the golden vectors, and build for the RP2350. Per-board firmware crates now exist under `rust/` (`pm-kit`, `pm-grid`, `pm-explorer`, plus support crates `pm-synth`/`pm-ui`/`uisim`/`glyphgen`); `pm-kit` runs on real hardware and **PM_G-1 "Grid" ships the native Rust firmware today** (`rust/pm-grid` → `pm-grid.uf2`, built by `rust/pm-grid/build.sh`).
## Versioning ## Versioning

View file

@ -60,13 +60,15 @@
@media (display-mode:standalone){ #app{ --pad:26px; padding-top:max(20px,env(safe-area-inset-top)); padding-bottom:max(16px,env(safe-area-inset-bottom)); } } @media (display-mode:standalone){ #app{ --pad:26px; padding-top:max(20px,env(safe-area-inset-top)); padding-bottom:max(16px,env(safe-area-inset-bottom)); } }
/* ---- top ---- */ /* ---- top ---- */
/* the logo + header-icon row is always a full-width bar at the very top, in
both orientations — it never joins the side-by-side landscape header flow */
#top{ flex:0 0 auto; display:flex; flex-direction:column; gap:11px; } #top{ flex:0 0 auto; display:flex; flex-direction:column; gap:11px; }
#brandrow{ display:flex; align-items:center; gap:10px; } #brandrow{ flex:0 0 auto; display:flex; align-items:center; gap:10px; width:100%; }
#logoLink{ display:inline-flex; opacity:.9; } #logoLink{ display:inline-flex; opacity:.9; }
.brandlogo{ height:clamp(21px,4.2vmin,30px); width:auto; display:block; } .brandlogo{ height:clamp(21px,4.2vmin,30px); width:auto; display:block; }
.hicons{ display:flex; align-items:center; gap:8px; margin-left:auto; } .hicons{ display:flex; align-items:center; gap:8px; margin-left:auto; }
.hicons .icon{ width:36px; height:36px; font-size:16px; } .hicons .icon{ width:36px; height:36px; font-size:16px; }
.sels{ display:flex; gap:8px; align-items:flex-end; } .sels{ width:100%; display:flex; gap:8px; align-items:flex-end; }
.sel{ flex:1 1 0; min-width:0; display:flex; flex-direction:column; gap:3px; } .sel{ flex:1 1 0; min-width:0; display:flex; flex-direction:column; gap:3px; }
.sel > span{ font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); padding-left:3px; } .sel > span{ font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); padding-left:3px; }
.sel select{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px; padding:10px 8px; font-size:15px; } .sel select{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px; padding:10px 8px; font-size:15px; }
@ -79,7 +81,7 @@
.icon:active{ background:rgba(127,139,154,.30); } .icon:active{ background:rgba(127,139,154,.30); }
/* ---- middle: pulse + track panel + lanes + transport, centered as one block ---- */ /* ---- 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:flex-start; gap:clamp(12px,2.8vmin,24px); } #mid{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:flex-start; gap:clamp(18px,4.2vmin,34px); padding-top:clamp(8px,1.8vmin,16px); }
#stage{ flex:0 0 auto; display:flex; flex-direction:column; align-items:center; width:100%; } #stage{ flex:0 0 auto; display:flex; flex-direction:column; align-items:center; width:100%; }
/* tempo plate: [TAP] [big BPM] [thumbwheel] — flashes on the beat */ /* tempo plate: [TAP] [big BPM] [thumbwheel] — flashes on the beat */
#pulse{ position:relative; width:100%; padding:clamp(8px,1.8vmin,14px); border-radius:16px; #pulse{ position:relative; width:100%; padding:clamp(8px,1.8vmin,14px); border-radius:16px;
@ -135,7 +137,16 @@
#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-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; } #trackpanel .tp-msg{ color:#5fd08a; margin-left:auto; }
.lane{ display:flex; align-items:center; gap:8px; } .lane{ display:flex; align-items:center; gap:8px; }
.lane.off{ opacity:.5; } /* dim only the label + pads of a muted lane, so the mute toggle stays crisp */
.lane.off .lmeta, .lane.off .pads{ opacity:.45; }
.lmute{ flex:0 0 auto; width:clamp(28px,5.4vmin,36px); height:clamp(28px,5.4vmin,36px); border-radius:8px; padding:0;
display:flex; align-items:center; justify-content:center; cursor:pointer;
background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--cyan); }
.lmute:active{ background:rgba(127,139,154,.22); }
.lmute.muted{ color:var(--muted); background:transparent; }
.lmute svg{ width:62%; height:62%; }
.lane.poly .lmute{ color:var(--poly); }
.lane.poly .lmute.muted{ color:var(--muted); }
.lmeta{ flex:0 0 auto; width:36%; max-width:168px; min-width:94px; display:flex; align-items:center; gap:5px; text-align:left; .lmeta{ flex:0 0 auto; width:36%; max-width:168px; min-width:94px; display:flex; align-items:center; gap:5px; text-align:left;
background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--txt); border-radius:8px; padding:5px 8px; background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--txt); border-radius:8px; padding:5px 8px;
font-size:clamp(10px,1.8vmin,13px); font-family:"Courier New",monospace; cursor:pointer; } font-size:clamp(10px,1.8vmin,13px); font-family:"Courier New",monospace; cursor:pointer; }
@ -173,11 +184,11 @@
/* ---- transport: tempo row (10//+/+10) then nav+play row (prev/play/practice/next) ---- */ /* ---- transport: tempo row (10//+/+10) then nav+play row (prev/play/practice/next) ---- */
/* grows to fill freed space; SHRINKS (rather than the page scrolling) when the panel expands */ /* grows to fill freed space; SHRINKS (rather than the page scrolling) when the panel expands */
#transport{ flex:0 1 auto; min-height:0; max-height:clamp(140px,36vh,250px); margin-top:auto; display:flex; flex-direction:column; align-items:stretch; width:100%; padding-top:6px; gap:clamp(5px,1.2vmin,9px); } #transport{ flex:0 1 auto; min-height:0; max-height:clamp(200px,46vh,310px); margin-top:auto; display:flex; flex-direction:column; align-items:stretch; width:100%; padding-top:6px; gap:clamp(5px,1.2vmin,9px); }
.tgrid{ display:grid; width:100%; flex:1 1 auto; min-height:0; gap:clamp(7px,1.7vmin,14px); .tgrid{ display:grid; width:100%; flex:1 1 auto; min-height:0; gap:clamp(7px,1.7vmin,14px);
grid-template-columns:1fr 1.5fr 1.5fr 1fr; grid-template-rows:1fr 1fr; grid-template-areas:"dn10 dn up up10" "prev play prac next"; } grid-template-columns:1fr 1.5fr 1.5fr 1fr; grid-template-rows:1fr 1fr; grid-template-areas:"dn10 dn up up10" "prev play prac next"; }
.tbtn{ background:linear-gradient(180deg,var(--btn1),var(--btn2)); color:var(--txt); border:1px solid var(--btn-bd); .tbtn{ background:linear-gradient(180deg,var(--btn1),var(--btn2)); color:var(--txt); border:1px solid var(--btn-bd);
border-radius:13px; height:auto; min-height:38px; font-size:clamp(16px,4vmin,27px); cursor:pointer; border-radius:13px; height:auto; min-height:66px; font-size:clamp(16px,4vmin,27px); cursor:pointer;
box-shadow:0 3px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px; } box-shadow:0 3px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px; }
.journal{ flex:0 0 auto; width:100%; height:clamp(30px,6vmin,42px); border-radius:11px; cursor:pointer; .journal{ flex:0 0 auto; width:100%; height:clamp(30px,6vmin,42px); border-radius:11px; cursor:pointer;
background:rgba(127,139,154,.10); border:1px solid var(--panel-bd); color:var(--muted); font-size:13px; background:rgba(127,139,154,.10); border:1px solid var(--panel-bd); color:var(--muted); font-size:13px;
@ -194,22 +205,23 @@
.tbtn.prac{ background:linear-gradient(180deg,#1c87b8,#136488); border-color:#1f9bd0; color:#eaf8ff; } .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; } .tbtn.prac.on{ background:linear-gradient(180deg,#c8922a,#9c6f12); border-color:#e0a93a; color:#fff; }
/* landscape (phone AND tablet — same layout, just scaled): 2-column grid — /* landscape (phone AND tablet): header stays a full-width column (logo+icons
pulse + transport on the left, panel + lanes on the right */ row, then volume row); the body becomes a 2-column grid — tempo + transport
on the left, selector + settings + lanes on the right */
@media (orientation:landscape){ @media (orientation:landscape){
#app{ --maxw:1060px; } #app{ --maxw:1060px; }
#top{ flex-direction:row; flex-wrap:wrap; align-items:center; gap:8px 14px; } /* fr columns (not 40%/60%) so the column gap is subtracted before sizing —
#brandrow{ flex:1 1 100%; } percentages + gap summed past 100% and overhung the right edge */
#top .vol{ order:2; flex:2 1 0; width:auto; min-width:0; } #mid{ display:grid; align-items:stretch; gap:clamp(12px,2.6vh,22px) clamp(16px,3vw,38px); padding-top:0;
#top .sels{ order:3; flex:3 1 0; min-width:0; } grid-template-columns:minmax(0,2fr) minmax(0,3fr); grid-template-rows:auto auto 1fr auto;
#mid{ display:grid; align-items:center; gap:8px 4vw; grid-template-areas:"stage sels" "stage panel" "stage detail" "transport detail"; }
grid-template-columns:40% 60%; grid-template-rows:auto 1fr auto; #stage, .sels, #trackpanel, #detail, #transport{ min-width:0; }
grid-template-areas:"panel detail" "stage detail" "transport detail"; } #stage{ grid-area:stage; align-self:center; justify-content:center; }
.sels{ grid-area:sels; align-self:start; }
#trackpanel{ grid-area:panel; align-self:start; } #trackpanel{ grid-area:panel; align-self:start; }
#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; } #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; max-height:none; margin-top:0; } #transport{ grid-area:transport; align-self:end; max-height:none; margin-top:0; }
.tbtn{ height:clamp(36px,11vmin,58px); } .tbtn{ min-height:0; height:clamp(34px,10vmin,54px); }
} }
[data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; } [data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; }
@ -273,15 +285,24 @@
<div class="icon" id="fsBtn" title="Full screen" aria-label="Full screen"></div> <div class="icon" id="fsBtn" title="Full screen" aria-label="Full screen"></div>
</div> </div>
</div> </div>
<div class="vol"><span class="dyn" aria-hidden="true">p</span><input id="vol" type="range" min="0" max="100" value="85" aria-label="Volume" /><span class="dyn" aria-hidden="true">f</span></div> <div class="vol" id="volrow"><span class="dyn" aria-hidden="true">p</span><input id="vol" type="range" min="0" max="100" value="85" aria-label="Volume" /><span class="dyn" aria-hidden="true">f</span></div>
</div>
<div id="mid">
<div id="stage">
<div id="pulse">
<button id="bTapBtn" class="tapbtn" title="Tap tempo">TAP</button>
<div id="bpm"><span id="bpmNum">120</span><span id="bpmlab">BPM</span></div>
<input id="bpmIn" type="number" inputmode="numeric" min="30" max="300" />
<div id="wheel" title="Drag to set tempo" aria-label="Tempo wheel"></div>
</div>
<div id="meterline"></div>
</div>
<div class="sels"> <div class="sels">
<label class="sel"><span>Set list</span><select id="slSel"></select></label> <label class="sel"><span>Set list</span><select id="slSel"></select></label>
<label class="sel"><span>Track</span><select id="trkSel"></select></label> <label class="sel"><span>Track</span><select id="trkSel"></select></label>
<div class="icon" id="saveBtn" title="Save &amp; library" aria-label="Save and library"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M5 4 H15 L19 8 V20 H5 Z"/><path d="M8.5 4 V8 H13.5 V4"/><rect x="8" y="13" width="8" height="7"/></svg></div> <div class="icon" id="saveBtn" title="Save &amp; library" aria-label="Save and library"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M5 4 H15 L19 8 V20 H5 Z"/><path d="M8.5 4 V8 H13.5 V4"/><rect x="8" y="13" width="8" height="7"/></svg></div>
</div> </div>
</div>
<div id="mid">
<div id="trackpanel"> <div id="trackpanel">
<div class="tp-row"> <div class="tp-row">
<label class="tp-chk"><input type="checkbox" id="ipRepeat" /> Repeat</label> <label class="tp-chk"><input type="checkbox" id="ipRepeat" /> Repeat</label>
@ -292,15 +313,6 @@
<div class="tp-sub off" id="ipRampRow"><input id="ipRampStart" type="number" min="30" max="300" /> → +<input id="ipRampAmt" type="number" min="1" max="50" /> bpm / <input id="ipRampEvery" type="number" min="1" max="64" /> bars</div> <div class="tp-sub off" id="ipRampRow"><input id="ipRampStart" type="number" min="30" max="300" /> → +<input id="ipRampAmt" type="number" min="1" max="50" /> bpm / <input id="ipRampEvery" type="number" min="1" max="64" /> bars</div>
<div class="tp-sub off" id="ipGapRow"><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-sub off" id="ipGapRow"><input id="ipGapPlay" type="number" min="1" max="32" /> play / <input id="ipGapMute" type="number" min="1" max="32" /> mute bars</div>
</div> </div>
<div id="stage">
<div id="pulse">
<button id="bTapBtn" class="tapbtn" title="Tap tempo">TAP</button>
<div id="bpm"><span id="bpmNum">120</span><span id="bpmlab">BPM</span></div>
<input id="bpmIn" type="number" inputmode="numeric" min="30" max="300" />
<div id="wheel" title="Drag to set tempo" aria-label="Tempo wheel"></div>
</div>
<div id="meterline"></div>
</div>
<div id="detail"> <div id="detail">
<div id="lanes"></div> <div id="lanes"></div>
</div> </div>
@ -546,10 +558,17 @@ function laneMetaHTML(m){ const eff=laneNoteValue(m);
const poly=m.poly?"<span class='polybadge' title='polyrhythm — "+m.beatsPerBar+" over "+ref+"'>↻"+m.beatsPerBar+":"+ref+"</span>":""; const poly=m.poly?"<span class='polybadge' title='polyrhythm — "+m.beatsPerBar+" over "+ref+"'>↻"+m.beatsPerBar+":"+ref+"</span>":"";
return "<span class='ln-name'>"+esc(m.sound)+"</span><span class='rh-host' title='Note value'>"+rhythmSVG(eff)+"</span><span class='lg'>"+esc(m.groupsStr)+"</span>"+poly; } return "<span class='ln-name'>"+esc(m.sound)+"</span><span class='rh-host' title='Note value'>"+rhythmSVG(eff)+"</span><span class='lg'>"+esc(m.groupsStr)+"</span>"+poly; }
function setLaneMeta(m){ if(!m._meta) return; m._meta.innerHTML=laneMetaHTML(m); } function setLaneMeta(m){ if(!m._meta) return; m._meta.innerHTML=laneMetaHTML(m); }
// speaker glyphs for the inline per-lane mute toggle
const SPK_ON='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9v6h4l5 4V5L8 9H4z"/><path d="M16.5 8.5a5 5 0 0 1 0 7"/></svg>';
const SPK_OFF='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9v6h4l5 4V5L8 9H4z"/><path d="M17 9.5l4 5M21 9.5l-4 5"/></svg>';
function toggleLaneMute(i){ const m=meters[i]; if(!m) return; m.enabled=!m.enabled; laneSig=null; renderAll(); saveState(); }
function buildLanes(){ function buildLanes(){
const box=$("lanes"); box.innerHTML=""; const box=$("lanes"); box.innerHTML="";
meters.forEach((m,i)=>{ meters.forEach((m,i)=>{
const lane=document.createElement("div"); lane.className="lane"+(m.enabled?"":" off")+(m.poly?" poly":""); const lane=document.createElement("div"); lane.className="lane"+(m.enabled?"":" off")+(m.poly?" poly":"");
const mute=document.createElement("button"); mute.className="lmute"+(m.enabled?"":" muted");
mute.title=m.enabled?"Mute lane":"Unmute lane"; mute.setAttribute("aria-label",mute.title);
mute.innerHTML=m.enabled?SPK_ON:SPK_OFF; mute.onclick=(e)=>{ e.stopPropagation(); toggleLaneMute(i); };
const meta=document.createElement("button"); meta.className="lmeta"; m._meta=meta; m._idx=i; setLaneMeta(m); const meta=document.createElement("button"); meta.className="lmeta"; m._meta=meta; m._idx=i; setLaneMeta(m);
meta.onclick=()=>openLaneSheet(i); meta.onclick=()=>openLaneSheet(i);
const pads=document.createElement("div"); pads.className="pads"; const pads=document.createElement("div"); pads.className="pads";
@ -557,7 +576,7 @@ function buildLanes(){
for(let b=0;b<m.beatsPerBar;b++){ const cell=document.createElement("div"); cell.className="beatcell"; for(let b=0;b<m.beatsPerBar;b++){ const cell=document.createElement("div"); cell.className="beatcell";
for(let s=0;s<spb;s++){ const k=b*spb+s; const p=document.createElement("button"); p.className="pad"; p.onclick=()=>cyclePad(m,k,p); cell.appendChild(p); m._padEls[k]=p; } for(let s=0;s<spb;s++){ const k=b*spb+s; const p=document.createElement("button"); p.className="pad"; p.onclick=()=>cyclePad(m,k,p); cell.appendChild(p); m._padEls[k]=p; }
pads.appendChild(cell); } pads.appendChild(cell); }
lane.appendChild(meta); lane.appendChild(pads); box.appendChild(lane); lane.appendChild(mute); lane.appendChild(meta); lane.appendChild(pads); box.appendChild(lane);
}); });
const add=document.createElement("button"); add.className="addlane"; add.textContent="+ Add lane"; add.onclick=addLane; box.appendChild(add); const add=document.createElement("button"); add.className="addlane"; add.textContent="+ Add lane"; add.onclick=addLane; box.appendChild(add);
renderPadLevels(); renderPadLevels();
@ -841,12 +860,14 @@ function loadFromHash(text){
/* ========================= HELP TOUR ========================================= */ /* ========================= HELP TOUR ========================================= */
const TOUR=[ const TOUR=[
{sel:"#utilrow", title:"Controls", text:"Up top: ↑ share menu, ? to replay this tour, ◐ light/dark theme, ⛶ full screen. The volume slider (soft p → loud f) runs full-width just below."}, {sel:"#brandrow,#volrow", title:"Controls", text:"The ↑ share menu, ? to replay this tour, ◐ light/dark theme, ⛶ full screen, and the full-width volume slider (soft p → loud f)."},
{sel:"#pulse", title:"Tempo", text:"Tap the BPM to tap-tempo, press-and-hold to type an exact value, the TAP button to tap it out, or drag the wheel up/down to scrub."},
{sel:".sels", title:"Pick what to play", text:"Choose a set list and the track within it. Tracks are your practice items — name them for whatever you're working on, even if two share the same beat."}, {sel:".sels", title:"Pick what to play", text:"Choose a set list and the track within it. Tracks are your practice items — name them for whatever you're working on, even if two share the same beat."},
{sel:"#saveBtn", title:"Save & library", text:"Save the current track — “Save as new”, or “Update” one of yours. The same sheet is your library: make set lists and rename / reorder / delete tracks. It all lives with the full editor too."}, {sel:"#saveBtn", title:"Save & library", text:"Save the current track — “Save as new”, or “Update” one of yours. The same sheet is your library: make set lists and rename / reorder / delete tracks. It all lives with the full editor too."},
{sel:"#pulse", title:"Tempo", text:"Tap the BPM to tap-tempo, press-and-hold to type an exact value, or drag up/down to scrub. ±10 / ±1 buttons nudge it."}, {sel:"#trackpanel", title:"Track settings", text:"Optional per-track extras: Repeat for N bars then stop / next / prev track, a tempo ramp, and practice gaps."},
{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 set its note value (eighths, triplets, sixteenths…), sound, grouping, mute or polymeter. “+ Add lane” for more."}, {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. The speaker button at the left of each lane mutes/unmutes it. Tap a lane's label to set its note value (eighths, triplets, sixteenths…), sound, grouping or polymeter. “+ Add lane” for more."},
{sel:"#trackpanel", title:"Track settings", text:"Optional per-track extras (above the tempo): Repeat for N bars then stop / next / prev track, a tempo ramp, and practice gaps."}, {sel:"#bDn10,#bDown,#bUp,#bUp10", title:"Nudge the tempo", text:"Step the BPM up or down while it keeps playing: 10 / 1 / +1 / +10. Great for settling on a comfortable speed or pushing it faster as you improve."},
{sel:"#bPrev,#bNext", title:"Previous / next track", text:"⏮ and ⏭ move to the previous or next track in the current set list. If the metronome is running it carries straight on into the new track."},
{sel:"#bPrac", title:"Practice = a timed session", text:"Play just runs the metronome. Practice times your playing and logs it (not audio): it starts a session clock and Play becomes Stop — start/pause each track, then Stop to save the session."}, {sel:"#bPrac", title:"Practice = a timed session", text:"Play just runs the metronome. Practice times your playing and logs it (not audio): it starts a session clock and Play becomes Stop — start/pause each track, then Stop to save the session."},
{sel:"#bJournal", title:"Practice journal", text:"Opens your saved practice sessions — notes and a per-track breakdown across days. While Practice is recording, this shows the live session timer."}, {sel:"#bJournal", title:"Practice journal", text:"Opens your saved practice sessions — notes and a per-track breakdown across days. While Practice is recording, this shows the live session timer."},
]; ];
@ -856,7 +877,11 @@ function endTour(){ $("tour").classList.remove("open"); lsSet(LS_TOURED,1); }
function showTour(){ function showTour(){
while(tstep<TOUR.length && !document.querySelector(TOUR[tstep].sel)) tstep++; while(tstep<TOUR.length && !document.querySelector(TOUR[tstep].sel)) tstep++;
if(tstep>=TOUR.length){ endTour(); return; } if(tstep>=TOUR.length){ endTour(); return; }
const s=TOUR[tstep], el=document.querySelector(s.sel), r=el.getBoundingClientRect(), pad=6, hole=$("tourHole"); const s=TOUR[tstep], pad=6, hole=$("tourHole");
// sel may match several elements (e.g. a row of buttons) — highlight their union
let r=null; document.querySelectorAll(s.sel).forEach(el=>{ const b=el.getBoundingClientRect();
r = r ? {left:Math.min(r.left,b.left),top:Math.min(r.top,b.top),right:Math.max(r.right,b.right),bottom:Math.max(r.bottom,b.bottom)} : {left:b.left,top:b.top,right:b.right,bottom:b.bottom}; });
r.width=r.right-r.left; r.height=r.bottom-r.top;
hole.style.left=(r.left-pad)+"px"; hole.style.top=(r.top-pad)+"px"; hole.style.width=(r.width+2*pad)+"px"; hole.style.height=(r.height+2*pad)+"px"; hole.style.left=(r.left-pad)+"px"; hole.style.top=(r.top-pad)+"px"; hole.style.width=(r.width+2*pad)+"px"; hole.style.height=(r.height+2*pad)+"px";
$("tourTitle").textContent=s.title; $("tourText").textContent=s.text; $("tourDots").textContent=(tstep+1)+" / "+TOUR.length; $("tourTitle").textContent=s.title; $("tourText").textContent=s.text; $("tourDots").textContent=(tstep+1)+" / "+TOUR.length;
$("tourPrev").style.visibility=tstep?"visible":"hidden"; $("tourNext").textContent=(tstep===TOUR.length-1)?"Done":"Next"; $("tourPrev").style.visibility=tstep?"visible":"hidden"; $("tourNext").textContent=(tstep===TOUR.length-1)?"Done":"Next";