From 0adbad52d1b755e21599d0407d55696cc766223e Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Sun, 13 Sep 2026 18:21:27 +0100
Subject: [PATCH] fix(footer): fit the click track to the row instead of piling
it up
The footer's only collapse for the click-track cluster capped its options
panel at a fixed 190px and let it wrap in place. 190px is narrower than
two of those controls side by side, so on a 15" laptop with the library
open the wrap was a five-row column and the footer grew to roughly three
times its height.
The width is now measured rather than written down. footerFit asks for
exactly the pixels the row is short and no more, floored at half the
panel's inline width so it stays a two-row wrap. Past that floor the
options move into a popover opened from a disclosure beside the on/off
pill, and in that state the clusters spread, because the popover frees
more width than the row was short and the surplus used to pool as a hole
before Export Mix.
The click volume was the widest single control in the cluster and the
only thing on a row of 34px pills that was neither a pill nor 34px tall.
It is now a pill of its own beside the on/off toggle, opening a vertical
fader, and it stays on the row at the widths where the rest goes behind
the disclosure.
The two loop bound fields were clipping the last digit of their own
value: fmtTimeMs writes nine characters and the box is eight wide, so
00:56.000 read as 00:56.00. They drop the minutes field and show total
seconds. parseTimecode still accepts mm:ss.mmm on the way in, so typing
is unchanged.
Also gone: the standing "Groups" label, whose box already carries the
same text as a placeholder, and two dead characters in "Auto (detected)".
---
static/css/daw.css | 169 ++++++++++++++++++++++++-------
static/index.html | 37 +++++--
static/js/footerFit.js | 111 ++++++++++++++++++--
static/js/i18n.js | 22 ++--
static/js/state.js | 3 +
static/js/transport.js | 132 ++++++++++++++++++++++--
static/js/utils.js | 16 +--
tests/e2e/click-track.spec.mjs | 12 ++-
tests/e2e/footer-fit.spec.mjs | 46 +++++++--
tests/e2e/helpers.mjs | 24 ++++-
tests/e2e/sections-loop.spec.mjs | 7 +-
11 files changed, 478 insertions(+), 101 deletions(-)
diff --git a/static/css/daw.css b/static/css/daw.css
index 6435728a..938979bb 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -2665,7 +2665,9 @@ input, textarea { font-family: inherit; }
}
.loop-times-sep { font-size: 12px; color: var(--muted); flex-shrink: 0; }
.loop-time-input {
- width: 8ch; box-sizing: content-box;
+ /* 7ch holds "105.100" -- the widest value a track under 1000 s produces now
+ that the minutes field is gone. */
+ width: 7ch; box-sizing: content-box;
padding: 2px 6px;
background: var(--panel); border: 1px solid var(--border-strong);
border-radius: 5px; outline: none;
@@ -2884,41 +2886,104 @@ input, textarea { font-family: inherit; }
}
.footer-metro-panel.hidden { display: none; }
-/* Narrow windows only. The options stay exactly where #269 put them and stay
- visible: nothing is hidden, moved behind a disclosure, or put in a popover.
- They just stop being one long line and start being a block, which is what
- the row has room for. See footerFit.js for why the threshold is measured
- rather than written as a breakpoint.
+/* First answer to a row that has run out of width: the options stay exactly
+ where #269 put them and stay visible, they just stop being one long line.
+
+ The width is set by footerFit.js, not written here, and that is the point.
+ A constant cannot be right: the row is short by a different number of pixels
+ at every window size and with the library open or shut, so a fixed cap
+ either leaves the options in a tall column when a shallow wrap would have
+ done, or frees far more width than the row needed and leaves the surplus
+ standing as a hole between the last cluster and Export Mix. The fallback
+ here is only for the frame before the first measurement lands.
`display: contents` above promotes the options to be peers of the on/off
pill, which is what makes them one wide line. Giving the panel its own box
- back is the whole mechanism: it becomes a single flex item the row can size,
- and its contents wrap inside it.
+ back is the whole mechanism: it becomes a single flex item of the measured
+ width, and its contents wrap inside it.
.daw-footer is min-height rather than a fixed height precisely so a taller
row is accommodated instead of clipped. */
+.footer-clusters.collapse-wrap .footer-metro-panel {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 9px;
+ width: var(--metro-wrap-w, 380px);
+}
+/* Nothing to wrap when the track has no beat grid. `.hidden` carries
+ !important, so it already wins; this is belt and braces against that
+ changing. */
+.footer-clusters.collapse-wrap .footer-metro-panel.hidden { display: none !important; }
+
+/* Narrow windows only, and only once wrapping to half the panel's inline width
+ is still too wide. See footerFit.js for why the threshold is measured rather
+ than written as a breakpoint.
+
+ This used to cap the panel at 190px and let it wrap in place. That kept
+ every option on screen without a click, which is what #269 asked for, but on
+ a 15" laptop with the library open it wrapped into a five-row column: six
+ controls stacked under a one-line footer, roughly 150px tall, with the rest
+ of the row sitting beside a wall of them. Wrapping is the right answer for a
+ couple of controls and the wrong one for six.
+
+ So at this size only, the options move into a popover, opened from a
+ disclosure beside the on/off pill.
+ Nothing is removed and nothing changes behaviour -- the same panel, one
+ click away, and back inline the moment the row has room for it again.
+
+ Fixed, not absolute: .footer-clusters is overflow-x: auto, which computes
+ overflow-y to auto as well, so an absolutely positioned panel would be
+ clipped by the strip it belongs to. Fixed escapes that (no ancestor here
+ creates a containing block), and transport.js sets left/top from the
+ trigger's rect -- the same shape as .search-panel below. */
.footer-clusters.collapse-click .footer-metro-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 9px;
- /* Measured, not chosen. The click cluster is 679px of the strip inline, and
- everything else on the row plus its gaps is about 700px, so the width left
- for the options is what decides where the row stops fitting. Against the
- e2e fixture, capping the panel here takes the cluster to 258px and the
- whole row to 971px, which is exactly the space a 1536px window has: the
- 4K-at-250%-scaling geometry from the report. 300px left it 100px short.
-
- Wider would drop that window back into a sideways scroll. Much narrower
- buys another window size at the cost of a taller and taller stack of
- one-control rows. */
- max-width: 190px;
-}
-/* Still nothing to show when the track has no beat grid. `.hidden` carries
- !important, so it already wins over the rule above; this is belt and braces
- against that changing. */
+ position: fixed;
+ z-index: 300;
+ /* 260px takes the count-in and meter selects side by side on the first row,
+ so the popover is two short rows rather than one control per line. */
+ width: 260px;
+ padding: 10px;
+ background: var(--panel); border: 1px solid var(--border-strong);
+ border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,0.42);
+}
+/* Shut until the trigger opens it. `.hidden` (no beat grid at all) carries
+ !important and still wins over both. */
+.footer-clusters.collapse-click .footer-metro-panel:not(.open) { display: none; }
.footer-clusters.collapse-click .footer-metro-panel.hidden { display: none !important; }
+/* The popover frees more width than the row was short -- the click cluster
+ goes from about 450px to about 140px in one step, and there is no smaller
+ step to take. Left-aligned, the surplus pools as a hole between the last
+ cluster and Export Mix. Spread, it reads as a band of controls across the
+ footer instead. Only at this level: with the options inline the clusters
+ already fill the row, and an overflowing flex row falls back to flex-start
+ on its own, so the scrolling case is untouched. */
+.footer-clusters.collapse-click { justify-content: space-between; }
+
+/* The trigger itself only exists at this size: inline, the options are already
+ on screen and a disclosure for them would be a button that does nothing. */
+.click-more-btn { display: none; }
+.footer-clusters.collapse-click .click-more-btn {
+ display: inline-flex; align-items: center; justify-content: center;
+ width: 28px; height: 34px; padding: 0;
+ background: var(--panel); border: 1px solid var(--border-strong);
+ border-radius: 8px; color: var(--fg-2); cursor: pointer;
+ transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
+}
+.footer-clusters.collapse-click .click-more-btn:hover { background: var(--panel-3); color: var(--fg); }
+.footer-clusters.collapse-click .click-more-btn[aria-expanded="true"] {
+ border-color: var(--accent); color: var(--accent);
+}
+.footer-clusters.collapse-click .click-more-btn svg { transition: transform var(--t-fast); }
+.footer-clusters.collapse-click .click-more-btn[aria-expanded="true"] svg { transform: rotate(180deg); }
+/* No grid, no options, nothing to disclose. */
+.footer-clusters.collapse-click .footer-metro-panel.hidden + .click-more-btn { display: none; }
+
/* Last resort before the strip would scroll: close up the spacing. Nothing is
hidden or moved, so it costs the user nothing but density. */
/* Only the gaps between clusters. Reaching into .footer-group-body as well was
@@ -2927,14 +2992,51 @@ input, textarea { font-family: inherit; }
widest cluster on the row got 2px roomier at the exact moment this is trying
to reclaim width. Eight gaps at 8px instead of 16px is the win here. */
.footer-clusters.collapse-tight { gap: 8px; }
-.metro-row { display: flex; align-items: center; gap: 9px; }
-.metro-vol-row { width: 140px; }
-.metro-vol-icon { color: var(--muted); flex-shrink: 0; }
-.metro-label {
- font-size: 9.5px; font-weight: 700; letter-spacing: 0.06em;
- color: var(--muted); flex-shrink: 0;
-}
-.metro-row input[type="range"] { flex: 1; min-width: 0; accent-color: var(--accent); }
+
+/* Click volume: a pill like everything else on the row, opening a vertical
+ fader. It was a 116px slider with a percentage after it -- the only control
+ on a row of 34px pills that was neither, so nothing lined up with it, and
+ the widest single item in the cluster that runs out of room first. A fader
+ is also the shape a level actually has.
+
+ It sits beside the on/off pill rather than among the options, so it stays on
+ the row at the widths where the rest goes behind the disclosure. */
+.metro-vol-wrap { display: inline-flex; flex: none; }
+.metro-vol-btn {
+ display: inline-flex; align-items: center; justify-content: center;
+ width: 34px; height: 34px; padding: 0;
+ background: var(--panel); border: 1px solid var(--border-strong);
+ border-radius: 8px; color: var(--fg-2); cursor: pointer;
+ transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
+}
+.metro-vol-btn:hover { background: var(--panel-3); color: var(--fg); }
+.metro-vol-btn[aria-expanded="true"] { border-color: var(--accent); color: var(--accent); }
+.metro-vol-icon { flex-shrink: 0; }
+
+/* Fixed rather than absolute for the same reason the options popover is:
+ .footer-clusters is overflow-x: auto, which computes overflow-y to auto as
+ well, so an absolutely positioned panel would be clipped by the strip its
+ button sits in. transport.js sets left/top from that button's rect. */
+.metro-vol-panel {
+ position: fixed; z-index: 320;
+ display: flex; flex-direction: column; align-items: center; gap: 8px;
+ padding: 10px 8px;
+ background: var(--panel); border: 1px solid var(--border-strong);
+ border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,0.42);
+}
+.metro-vol-panel.hidden { display: none !important; }
+.metro-vol-slider {
+ /* The standard vertical range (Chrome 121, Safari 17.4, Firefox 129) with
+ the older WebKit/Blink keyword behind it. On a browser that honours
+ neither, the input stays a horizontal slider inside the popover: smaller
+ than it should be, but still a working volume control. */
+ -webkit-appearance: slider-vertical;
+ writing-mode: vertical-rl;
+ direction: rtl;
+ width: 20px; height: 92px;
+ accent-color: var(--accent);
+ cursor: pointer;
+}
.metro-val {
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
width: 4ch; text-align: right;
@@ -2993,11 +3095,6 @@ input, textarea { font-family: inherit; }
/* A refused grouping: the box snapping back on its own reads as a bug unless
something says the numbers did not add up. */
.metro-group.invalid { border-color: var(--danger); color: var(--danger); }
-.metro-group-label {
- color: var(--muted); font-size: 11px; font-weight: 500;
- letter-spacing: .02em; flex-shrink: 0; align-self: center;
-}
-
/* Detection confidence / unavailability note: its own line under the
waveform, where it reads as a caption on the timeline it describes (design
1b). Hidden until it has text, clipped to one line with the full text as a
diff --git a/static/index.html b/static/index.html
index 5db0a9ab..f432726d 100644
--- a/static/index.html
+++ b/static/index.html
@@ -723,9 +723,9 @@
not a control, so nothing here looks pressable. -->
Scroll up/down to adjust
@@ -768,6 +768,24 @@
ON
+
+
+
+
+ 60%
+
+
+
-
-
-
- 60%
-
+
+
diff --git a/static/js/footerFit.js b/static/js/footerFit.js
index 408e8d5c..f4a0ccc7 100644
--- a/static/js/footerFit.js
+++ b/static/js/footerFit.js
@@ -20,33 +20,124 @@
// uncollapsed state rather than added on top of the last one, so the strip can
// open back up as room returns and there is no hysteresis to tune.
-// Least destructive first. "click" stops the click-track options being one
-// long line and lets them wrap into a block, taking that cluster from 679px to
-// about 258px of the 1443px strip. Nothing is hidden and nothing moves behind a
-// disclosure: #269 decided those controls stay visible, and a narrow window is
-// not a reason to walk that back. "tight" only closes up the spacing, and
-// exists for the windows where wrapping alone is still not enough.
-const LEVELS = ["click", "tight"];
+// Least destructive first, and each step costs more than the last.
+//
+// "wrap" keeps every click-track option on screen and lets the panel wrap,
+// at a width measured from the deficit rather than a constant: give back
+// exactly the pixels the row is short and no more. A fixed width here was the
+// whole trouble with the version before this one -- 190px turned six controls
+// into a five-row column, and the popover that replaced it freed 580px when
+// the row was 175px short, so the slack pooled as a hole before Export Mix.
+// Floored at half the panel's inline width so this stays a two-row wrap.
+//
+// "click" is that popover, opened from a disclosure beside the on/off pill,
+// now only for windows where two rows are still too
+// wide. #269 wanted the options visible without a click; they still are at
+// every width that can hold them, which after "wrap" is most of them.
+//
+// "tight" only closes up the spacing between clusters, for the windows where
+// even the popover leaves the row overflowing.
+const LEVELS = ["wrap", "click", "tight"];
+
+// Below this the panel is too narrow to hold the widest control in it with
+// anything beside it, and wrapping stops being a two-row layout.
+const MIN_WRAP_PX = 240;
+
+// Rounding headroom on the width asked for; see wrapWidthFor.
+const WRAP_SLACK = 4;
let strip = null;
-let level = 0;
+let level = 0; // count of LEVELS applied, in order; 0 is fully inline
let queued = false;
+/**
+ * Panel width that gives the row back `deficit` pixels, or 0 if wrapping
+ * cannot cover it.
+ *
+ * The panel is `display: contents` while inline, so it has no box of its own
+ * to measure: its width is the span its promoted children occupy in the
+ * cluster's body. Wrapping it to W shrinks the cluster by (that span - W), so
+ * W is just the span less what the row is short.
+ */
+function wrapWidthFor(deficit) {
+ const panel = document.getElementById("t-metro-panel");
+ if (!panel || panel.classList.contains("hidden")) return 0;
+
+ // First child's left edge to the last one's right edge, rather than the
+ // cluster's width less the controls that are not the panel's. Those controls
+ // are not fixed -- the on/off pill has a volume button beside it now -- and
+ // a subtraction that has to be kept in step with the markup is a sum that
+ // will one day be wrong without saying so.
+ const kids = [...panel.children].filter((el) => el.getBoundingClientRect().width > 0);
+ if (!kids.length) return 0;
+ const inline = kids[kids.length - 1].getBoundingClientRect().right - kids[0].getBoundingClientRect().left;
+ if (inline <= 0) return 0;
+
+ // Never below half: past that the wrap is a column, not two rows, and the
+ // popover is the better answer for that window.
+ //
+ // WRAP_SLACK is not a fudge for a wrong sum. Widths here are fractional and
+ // scrollWidth is a rounded integer, so asking for exactly the deficit back
+ // can land a pixel short -- and a pixel short is not "nearly": it drops the
+ // whole cluster to the popover, which is the difference between the options
+ // being on screen and being behind a click.
+ const want = Math.floor(inline - deficit) - WRAP_SLACK;
+ const width = Math.max(want, Math.ceil(inline / 2));
+ return width >= MIN_WRAP_PX && width < inline ? width : 0;
+}
+
function apply() {
queued = false;
if (!strip || !strip.isConnected) return;
+ const was = level;
+
// Always measure from the top. Deciding from the current, already-collapsed
// state means the strip can only ever tighten, and never recovers when the
// window grows or the sidebar opens.
while (level > 0) strip.classList.remove(`collapse-${LEVELS[--level]}`);
+ strip.style.removeProperty("--metro-wrap-w");
// A hidden strip has no width to fit anything into, and measuring one would
// collapse every level for nothing.
if (!strip.clientWidth) return;
- while (strip.scrollWidth > strip.clientWidth && level < LEVELS.length) {
- strip.classList.add(`collapse-${LEVELS[level++]}`);
+ let deficit = strip.scrollWidth - strip.clientWidth;
+
+ // Wrap: measured against the uncollapsed row, which is the only state where
+ // the panel's inline width can be read.
+ if (deficit > 0) {
+ const w = wrapWidthFor(deficit);
+ if (w) {
+ strip.style.setProperty("--metro-wrap-w", `${w}px`);
+ strip.classList.add("collapse-wrap");
+ level = 1;
+ deficit = strip.scrollWidth - strip.clientWidth;
+ }
+ }
+
+ // Popover: replaces the wrap rather than stacking on it. The two are
+ // different answers to the same problem, and a cluster cannot be both.
+ if (deficit > 0) {
+ strip.classList.remove("collapse-wrap");
+ strip.style.removeProperty("--metro-wrap-w");
+ strip.classList.add("collapse-click");
+ level = 2;
+ deficit = strip.scrollWidth - strip.clientWidth;
+ }
+
+ if (deficit > 0) {
+ strip.classList.add("collapse-tight");
+ level = 3;
+ }
+
+ // "click" is not only a layout change: it is what turns the click-track
+ // options into a popover, and a popover left open across the change would be
+ // stranded at coordinates that no longer describe its trigger. Announced
+ // rather than called directly so this stays a measuring module -- it has no
+ // business knowing which cluster it just reshaped.
+ if (level !== was) {
+ window.dispatchEvent(new CustomEvent("footerfit", { detail: { level: LEVELS[level - 1] ?? null } }));
}
}
diff --git a/static/js/i18n.js b/static/js/i18n.js
index dc3ba924..cae25a97 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -452,7 +452,7 @@ const en = {
"click.groupLabel": "Groups",
"click.groupMustSum": "A grouping has to add up to {beats}.",
"click.barAccentAria": "Accent every N beats",
- "click.auto": "Auto (detected)",
+ "click.auto": "Auto",
"click.autoNone": "Auto (none found)",
"click.off": "Off",
"click.editTitle": "Edit the beat grid",
@@ -1067,7 +1067,7 @@ const pl = {
"click.groupLabel": "Podział",
"click.groupMustSum": "Podział musi sumować się do {beats}.",
"click.barAccentAria": "Akcentuj co N uderzeń",
- "click.auto": "Auto (wykryto)",
+ "click.auto": "Auto",
"click.autoNone": "Auto (nie znaleziono)",
"click.off": "Wył.",
"click.editTitle": "Edytuj siatkę rytmiczną",
@@ -1673,7 +1673,7 @@ const ja = {
"click.groupLabel": "区切り",
"click.groupMustSum": "区切りの合計は{beats}拍にする必要があります。",
"click.barAccentAria": "N拍ごとにアクセント",
- "click.auto": "自動 (検出済み)",
+ "click.auto": "自動",
"click.autoNone": "自動 (未検出)",
"click.off": "オフ",
"click.editTitle": "ビートグリッドを編集",
@@ -2255,7 +2255,7 @@ const zhHans = {
"click.groupLabel": "分组",
"click.groupMustSum": "分组之和必须等于 {beats}。",
"click.barAccentAria": "每 N 拍重音一次",
- "click.auto": "自动(已检测)",
+ "click.auto": "自动",
"click.autoNone": "自动(未检测到)",
"click.off": "关闭",
"click.editTitle": "编辑节拍网格",
@@ -2838,7 +2838,7 @@ const de = {
"click.groupLabel": "Gruppen",
"click.groupMustSum": "Eine Gruppierung muss {beats} ergeben.",
"click.barAccentAria": "Jeden N. Beat betonen",
- "click.auto": "Auto (erkannt)",
+ "click.auto": "Auto",
"click.autoNone": "Auto (nicht gefunden)",
"click.off": "Aus",
"click.editTitle": "Beat-Raster bearbeiten",
@@ -3431,7 +3431,7 @@ const pt = {
"click.groupLabel": "Grupos",
"click.groupMustSum": "O agrupamento tem de somar {beats}.",
"click.barAccentAria": "Acentuar a cada N batidas",
- "click.auto": "Auto (detectado)",
+ "click.auto": "Auto",
"click.autoNone": "Auto (nenhum encontrado)",
"click.off": "Desligado",
"click.editTitle": "Editar a grade de batidas",
@@ -4025,7 +4025,7 @@ const id = {
"click.groupLabel": "Kelompok",
"click.groupMustSum": "Pengelompokan harus berjumlah {beats}.",
"click.barAccentAria": "Aksen setiap N ketukan",
- "click.auto": "Otomatis (terdeteksi)",
+ "click.auto": "Otomatis",
"click.autoNone": "Otomatis (tidak ditemukan)",
"click.off": "Nonaktif",
"click.editTitle": "Edit grid ketukan",
@@ -4608,7 +4608,7 @@ const fr = {
"click.groupLabel": "Groupes",
"click.groupMustSum": "Un groupement doit totaliser {beats}.",
"click.barAccentAria": "Accentuer tous les N temps",
- "click.auto": "Auto (détecté)",
+ "click.auto": "Auto",
"click.autoNone": "Auto (aucun trouvé)",
"click.off": "Désactivé",
"click.editTitle": "Modifier la grille rythmique",
@@ -5018,7 +5018,7 @@ const ptPT = {
"settings.stemsLocation.resetFailed": "Falha ao repor, verifique a sua ligação.",
"settings.stemsLocation.syncFailed": "Falha na sincronização, verifique a sua ligação.",
"beatgrid.resetTitle": "Descartar todas as edições e restaurar a grelha detetada",
- "click.auto": "Auto (detetado)",
+ "click.auto": "Auto",
"click.detected": "Tempo detetado",
"metro.note.detectedMultiBar": "Clique a {bpm} BPM, {conf}% das batidas coincidem com uma batida de bateria, acentuando {count} regiões métricas detetadas. Use /2 ou x2 se o clique parecer metade ou o dobro da velocidade.",
"metro.note.detectedSingleBar": "Clique a {bpm} BPM, {conf}% das batidas coincidem com uma batida de bateria, acentuando {beats}/4 a partir do tempo forte detetado. Use /2 ou x2 se o clique parecer metade ou o dobro da velocidade.",
@@ -5319,7 +5319,7 @@ const es = {
"click.groupLabel": "Grupos",
"click.groupMustSum": "La agrupación tiene que sumar {beats}.",
"click.barAccentAria": "Acentuar cada N tiempos",
- "click.auto": "Automático (detectado)",
+ "click.auto": "Automático",
"click.autoNone": "Automático (no se detectó)",
"click.off": "No",
"click.editTitle": "Editar el GRID",
@@ -5934,7 +5934,7 @@ const ko = {
"click.groupLabel": "묶음",
"click.groupMustSum": "묶음의 합이 {beats}가 되어야 합니다.",
"click.barAccentAria": "N 비트마다 강세",
- "click.auto": "자동 (감지됨)",
+ "click.auto": "자동",
"click.autoNone": "자동 (찾지 못함)",
"click.off": "끄기",
"click.editTitle": "비트 그리드 편집",
diff --git a/static/js/state.js b/static/js/state.js
index 3bd1f856..e181d58d 100644
--- a/static/js/state.js
+++ b/static/js/state.js
@@ -56,7 +56,10 @@ export const loopStartInput = $("t-loop-start");
export const loopEndInput = $("t-loop-end");
export const metroBtn = $("t-metro");
export const metroPanel = $("t-metro-panel");
+export const metroMoreBtn = $("t-metro-more");
export const metroVolEl = $("t-metro-vol");
+export const metroVolBtn = $("t-metro-vol-btn");
+export const metroVolPanel = $("t-metro-vol-panel");
export const metroVolLabel = $("t-metro-vol-label");
export const metroBarEl = $("t-metro-bar");
export const metroNoteEl = $("t-metro-note");
diff --git a/static/js/transport.js b/static/js/transport.js
index e715be20..82dfbb9e 100644
--- a/static/js/transport.js
+++ b/static/js/transport.js
@@ -15,7 +15,7 @@ import {
presenceRulerEl, presencePlayheadEl,
footerTimeElapsed, footerTimeTotal, footerWaveTicks, npScrubFill, footerWaveDrawFn,
loopStartInput, loopEndInput,
- metroBtn, metroPanel, metroVolEl, metroVolLabel, metroBarEl, metroBarCustomEl, metroGroupEl, metroNoteEl,
+ metroBtn, metroPanel, metroMoreBtn, metroVolEl, metroVolBtn, metroVolPanel, metroVolLabel, metroBarEl, metroBarCustomEl, metroGroupEl, metroNoteEl,
metroHalfBtn, metroOneBtn, metroDoubleBtn, metroCountInEl,
metronome, metronomeEnabled, metronomeVolume, metronomeBeatsPerBar, metronomeHasBars,
metronomeCountInBars, setMetronomeCountInBars,
@@ -28,7 +28,7 @@ import {
import { applyMix, nudgeAllLanePitches, resetAllLanePitches } from "./mixer.js";
import { isDownbeatIndex, barPositionIndex, getBeats as getGridBeats, getBars as getGridBars } from "./beatgrid.js";
import { computeCountIn, defaultGrouping, normaliseGrouping } from "./metronome.js";
-import { t } from "./i18n.js";
+import { t, onLanguageChange } from "./i18n.js";
import { pitchBlockedKey } from "./pitchBus.js";
import { refitFooter } from "./footerFit.js";
@@ -1114,10 +1114,16 @@ export function applyMetronomeAccent() {
function _renderMetroVolume() {
const pct = `${Math.round(metronomeVolume * 100)}%`;
- if (metroVolEl) { metroVolEl.value = String(metronomeVolume); metroVolEl.title = t("click.volumeTitle", { pct }); }
- // Readout sits next to the slider (design 1b): a click level you can only
- // learn by hovering is one you cannot match between sessions.
+ if (metroVolEl) metroVolEl.value = String(metronomeVolume);
+ // Readout sits with the fader, and on the button that opens it. A click
+ // level you can only learn by moving the fader is one you cannot match
+ // between sessions, and the button is the part that is always on screen.
if (metroVolLabel) metroVolLabel.textContent = pct;
+ // No data-i18n-title on the button: this title carries the live level, and
+ // the generic translation pass would flatten it back to a static string on a
+ // language switch. Re-derived from the same listener pattern main.js uses
+ // for the export label.
+ if (metroVolBtn) metroVolBtn.title = t("click.volumeTitle", { pct });
}
// Show the meter in the select when it is one of the presets, otherwise select
@@ -1164,11 +1170,9 @@ function _applyCustomBeatsPerBar() {
function _renderMetroGrouping() {
if (!metroGroupEl) return;
const n = metronomeBeatsPerBar;
- const label = document.getElementById("t-metro-group-label");
if (!(n >= 5)) {
metroGroupEl.classList.add("hidden");
- label?.classList.add("hidden");
- // Showing or hiding these changes how wide the row wants to be by 120-160px
+ // Showing or hiding it changes how wide the row wants to be by ~80px
// and the ResizeObserver cannot see it: the strip's own box is flex-sized
// and its height does not move, since everything on the row is one line of
// 34px controls. Without this, picking a 7/8 meter puts the strip back into
@@ -1177,7 +1181,6 @@ function _renderMetroGrouping() {
return;
}
metroGroupEl.classList.remove("hidden");
- label?.classList.remove("hidden");
metroGroupEl.value = normaliseGrouping(metronomeGrouping, n).join("+");
metroGroupEl.placeholder = defaultGrouping(n).join("+");
refitFooter(); // see the matching call on the hidden path above
@@ -1353,8 +1356,119 @@ export function updateMetronomeAvailability(grid, reason = "") {
_renderMetroNote(grid);
}
+// ── Footer popovers ──────────────────────────────────────────────────────
+//
+// Two of them, side by side in the click-track cluster: the options panel,
+// which only exists once footerFit decides the options do not fit inline, and
+// the volume fader, which is there at every width.
+//
+// Both are position:fixed. .footer-clusters is overflow-x: auto, which computes
+// overflow-y to auto as well, so an absolutely positioned panel would be
+// clipped by the strip it belongs to. Fixed escapes that, at the price of
+// placing them from the trigger's rect by hand.
+
+/**
+ * Put `panel` directly above `btn`.
+ *
+ * Upward because these triggers sit in the footer, so there is nothing below
+ * them to open into, and measured while the panel is shown: a display:none
+ * panel has no height to subtract.
+ */
+function _placeAbove(btn, panel) {
+ const r = btn.getBoundingClientRect();
+ const left = Math.max(8, Math.min(Math.round(r.left), window.innerWidth - panel.offsetWidth - 8));
+ panel.style.left = `${left}px`;
+ panel.style.top = `${Math.max(8, Math.round(r.top - panel.offsetHeight - 8))}px`;
+}
+
+/**
+ * Wire one trigger/panel pair.
+ *
+ * `shownClass` is what the panel's own CSS keys off: the options panel is shut
+ * by default and opts in with .open, the volume panel ships .hidden and opts
+ * out of it. `onOpen` runs before this one is shown, which is how the two take
+ * turns.
+ */
+function _wirePopover(btn, panel, { shownClass = "open", onOpen } = {}) {
+ const isOpen = () => btn.getAttribute("aria-expanded") === "true";
+ const place = () => _placeAbove(btn, panel);
+
+ const close = () => {
+ if (!isOpen()) return;
+ if (shownClass === "hidden") panel.classList.add("hidden");
+ else panel.classList.remove(shownClass);
+ btn.setAttribute("aria-expanded", "false");
+ };
+
+ const open = () => {
+ onOpen?.();
+ if (shownClass === "hidden") panel.classList.remove("hidden");
+ else panel.classList.add(shownClass);
+ btn.setAttribute("aria-expanded", "true");
+ place();
+ };
+
+ btn.addEventListener("click", (e) => {
+ // Stops the document handler below from treating the press that opened a
+ // panel as the click away that shuts it -- and, for a trigger sitting
+ // inside another panel, stops that outer one closing underneath it.
+ e.stopPropagation();
+ if (isOpen()) close();
+ else open();
+ });
+
+ // A click inside is a click on a control, not a click away from it.
+ panel.addEventListener("click", (e) => e.stopPropagation());
+
+ document.addEventListener("click", close);
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && isOpen()) {
+ close();
+ btn.focus();
+ }
+ });
+
+ // Anything that moves the trigger moves the panel with it, and a refit that
+ // puts the options back inline leaves nothing for its panel to hang off.
+ window.addEventListener("resize", close);
+ window.addEventListener("footerfit", close);
+
+ return { close, place, isOpen };
+}
+
+function wireFooterPopovers() {
+ // Each trigger stops the click that opened it from reaching the document,
+ // which is what keeps that press from being read as the click away that
+ // shuts the panel -- but it also means neither panel would ever see the
+ // other open. They sit side by side in the same cluster, so only one of them
+ // should be over the footer at a time.
+ const other = {};
+
+ if (metroVolBtn && metroVolPanel) {
+ // The fader is a drag, not a click: a pointerdown that leaves the panel
+ // still ends on the document, and without this the panel would shut
+ // mid-drag.
+ metroVolPanel.addEventListener("pointerdown", (e) => e.stopPropagation());
+ const volume = _wirePopover(metroVolBtn, metroVolPanel, {
+ shownClass: "hidden",
+ onOpen: () => other.options?.(),
+ });
+ other.volume = volume.close;
+ }
+
+ if (metroMoreBtn && metroPanel) {
+ const options = _wirePopover(metroMoreBtn, metroPanel, { onOpen: () => other.volume?.() });
+ other.options = options.close;
+ // Revealing the grouping box or picking a longer meter changes the panel's
+ // height, which for an upward-opening popover changes where its top belongs.
+ metroPanel.addEventListener("change", () => { if (options.isOpen()) options.place(); });
+ }
+}
+
function wireMetronomeControl() {
if (!metroBtn) return;
+ wireFooterPopovers();
+ onLanguageChange(_renderMetroVolume);
// Restore preferences before the first track loads so the click comes back
// on exactly as the user left it.
diff --git a/static/js/utils.js b/static/js/utils.js
index 40bbe710..4562462c 100644
--- a/static/js/utils.js
+++ b/static/js/utils.js
@@ -109,17 +109,17 @@ export function fmtTickLabel(s) {
return `${m}:${sec}`;
}
-// Millisecond-precise timecode "mm:ss.mmm" for the exact-loop inputs. Integer-ms
-// math avoids a rounding carry bug (e.g. 0.9999s -> "00:01.000", not "00:00.1000").
+// Millisecond-precise timecode "ss.mmm" for the exact-loop inputs: total
+// seconds, no minutes field. The minutes were two dead characters in a footer
+// that runs out of width first, and a loop bound is read as an offset anyway.
+// Integer-ms math avoids a rounding carry bug (e.g. 0.9999s -> "01.000", not
+// "00.1000"). parseTimecode still accepts mm:ss.mmm on the way in.
export function fmtTimeMs(s) {
- if (!isFinite(s) || s < 0) return "00:00.000";
+ if (!isFinite(s) || s < 0) return "00.000";
const totalMs = Math.round(s * 1000);
- const m = Math.floor(totalMs / 60000);
- const sec = Math.floor((totalMs % 60000) / 1000);
+ const sec = Math.floor(totalMs / 1000);
const ms = totalMs % 1000;
- return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}.${ms
- .toString()
- .padStart(3, "0")}`;
+ return `${sec.toString().padStart(2, "0")}.${ms.toString().padStart(3, "0")}`;
}
// Parse a user-typed loop time. Accepts "mm:ss(.mmm)" (seconds field 0-59) or a
diff --git a/tests/e2e/click-track.spec.mjs b/tests/e2e/click-track.spec.mjs
index dfd2cd61..75d6772a 100644
--- a/tests/e2e/click-track.spec.mjs
+++ b/tests/e2e/click-track.spec.mjs
@@ -34,7 +34,7 @@ test.describe("click track", () => {
// Bar marks present, so the detected-meter accent mode is live rather than
// silently degrading to "none found".
- await expect(page.locator('#t-metro-bar option[value="-1"]')).toHaveText("Auto (detected)");
+ await expect(page.locator('#t-metro-bar option[value="-1"]')).toHaveText("Auto");
await expect(page.locator('#t-metro-bar option[value="-1"]')).toBeEnabled();
});
@@ -111,9 +111,12 @@ test.describe("click track", () => {
await ui.accent.selectOption("7");
await expect(group).toBeVisible();
await expect(group).toHaveValue("3+2+2");
- // The box is labelled and the note spells out what "3+2+2" does, so the
- // grouping is not a bare number the user has to decode.
- await expect(page.locator("#t-metro-group-label")).toBeVisible();
+ // The standing "Groups" label went when the footer ran out of width. What
+ // said the box is not a bare number has to still say it, so assert on what
+ // replaced the label rather than dropping the check: the box's own
+ // placeholder and title, and the note under the waveform.
+ await expect(group).toHaveAttribute("placeholder", "3+2+2");
+ await expect(group).toHaveAttribute("title", /grouping|divides/i);
await expect(ui.note).toContainText("counting 7 in 3+2+2");
await expect(ui.note).toContainText("stress on 1, 4, 6");
@@ -142,7 +145,6 @@ test.describe("click track", () => {
// to describing a plain accent rather than a grouping that is not playing.
await ui.accent.selectOption("4");
await expect(group).toBeHidden();
- await expect(page.locator("#t-metro-group-label")).toBeHidden();
await expect(ui.note).toContainText("accenting every 4 beats");
});
diff --git a/tests/e2e/footer-fit.spec.mjs b/tests/e2e/footer-fit.spec.mjs
index bbfa2b87..1d24880d 100644
--- a/tests/e2e/footer-fit.spec.mjs
+++ b/tests/e2e/footer-fit.spec.mjs
@@ -30,7 +30,7 @@ test.describe("footer fit", () => {
test("a wide window leaves the strip exactly as it was", async ({ page }) => {
await page.setViewportSize({ width: 2200, height: 900 });
await openStudio(page, { tauri: true });
- await waitForClickTrack(page);
+ await waitForClickTrack(page, { revealOptions: false });
expect(await collapseLevels(page)).toEqual([]);
// display: contents, so the options are peers of the on/off pill: the #269
@@ -51,26 +51,58 @@ test.describe("footer fit", () => {
// pinning the fixture, not the behaviour.
await page.setViewportSize({ width: 1536, height: 864 });
await openStudio(page, { tauri: true });
- await waitForClickTrack(page);
+ await waitForClickTrack(page, { revealOptions: false });
expect(await stripFits(page)).toBe(true);
});
- test("collapsing keeps every click control visible and working", async ({ page }) => {
- // The point of wrapping rather than hiding: #269 decided these stay on
- // screen, and a narrow window must not quietly walk that back.
+ test("a row that is only a little short keeps the options inline", async ({ page }) => {
+ // The first thing a short row does is wrap the options to a measured
+ // width, not take them away: #269 decided they stay on screen, and being
+ // 100px short of the space for one long line is not a reason to walk that
+ // back.
+ //
+ // Asserts the property rather than the collapse class. Whether this width
+ // needs to wrap at all depends on the content, and the seeded fixture's
+ // strip is narrower than a real six-stem track's.
+ await page.setViewportSize({ width: 1728, height: 900 });
+ await openStudio(page, { tauri: true });
+ await waitForClickTrack(page, { revealOptions: false });
+
+ await expect(page.locator("#t-metro-bar")).toBeVisible();
+ await expect(page.locator("#t-metro-more")).toBeHidden();
+ expect(await stripFits(page)).toBe(true);
+ });
+
+ test("collapsing puts every click control one click away, not out of reach", async ({ page }) => {
+ // Past the width two rows can absorb, the options move into a popover,
+ // opened from a disclosure beside the on/off pill. Nothing is removed and
+ // nothing stops working --
+ // that is the whole difference between this and hiding them.
await page.setViewportSize({ width: 1280, height: 800 });
await openStudio(page, { tauri: true });
- await waitForClickTrack(page);
+ await waitForClickTrack(page, { revealOptions: false });
expect(await collapseLevels(page)).toContain("collapse-click");
+ const more = page.locator("#t-metro-more");
+ await expect(more).toBeVisible();
+ await expect(page.locator("#t-metro-bar")).toBeHidden();
+
+ await more.click();
for (const id of ["#t-metro-countin", "#t-metro-bar", "#t-metro-edit", "#t-metro-half"]) {
await expect(page.locator(id)).toBeVisible();
}
const half = page.locator("#t-metro-half");
await half.click();
await expect(half).toHaveAttribute("aria-checked", "true");
+ // Still open: a rate button is not a reason to close the panel you picked
+ // it from.
+ await expect(page.locator("#t-metro-bar")).toBeVisible();
+
+ await page.keyboard.press("Escape");
+ await expect(page.locator("#t-metro-bar")).toBeHidden();
+ await expect(more).toHaveAttribute("aria-expanded", "false");
});
test("the strip opens back up when the room comes back", async ({ page }) => {
@@ -78,7 +110,7 @@ test.describe("footer fit", () => {
// direction that catches a one-way ratchet.
await page.setViewportSize({ width: 1280, height: 800 });
await openStudio(page, { tauri: true });
- await waitForClickTrack(page);
+ await waitForClickTrack(page, { revealOptions: false });
expect(await collapseLevels(page)).toContain("collapse-click");
await page.setViewportSize({ width: 2200, height: 900 });
diff --git a/tests/e2e/helpers.mjs b/tests/e2e/helpers.mjs
index 6ee6ac65..8dc5d66f 100644
--- a/tests/e2e/helpers.mjs
+++ b/tests/e2e/helpers.mjs
@@ -249,12 +249,34 @@ export async function openStudio(page, { tauri = false, updateAvailable = false
* been fetched. Acting before then hits a null metronome, where the rate and
* accent controls silently no-op: the click looks present and does nothing.
*/
-export async function waitForClickTrack(page) {
+export async function waitForClickTrack(page, { revealOptions = true } = {}) {
await page.waitForFunction(
() => document.querySelector("#t-metro") && !document.querySelector("#t-metro").disabled,
null,
{ timeout: 20000 },
);
+ if (revealOptions) await openClickOptions(page);
+}
+
+/**
+ * Put the click-track options where a test can click them.
+ *
+ * Below a width footerFit measures for itself, the options move into a popover
+ * behind a disclosure beside the on/off pill, instead of sitting inline. A spec that goes straight for
+ * #t-metro-bar then finds a control that exists, is enabled, and cannot be
+ * clicked -- and times out saying nothing about why. Opening it here keeps
+ * those specs about the click track rather than about the footer's width, and
+ * it is a no-op at any width that still has room for the options.
+ *
+ * Pass revealOptions: false to waitForClickTrack when the popover itself is
+ * what is under test.
+ */
+export async function openClickOptions(page) {
+ const more = page.locator("#t-metro-more");
+ if (!(await more.isVisible())) return;
+ if ((await more.getAttribute("aria-expanded")) === "true") return;
+ await more.click();
+ await page.locator("#t-metro-panel.open").waitFor({ timeout: 5000 });
}
export const exportUi = (page) => ({
diff --git a/tests/e2e/sections-loop.spec.mjs b/tests/e2e/sections-loop.spec.mjs
index 527d6fc4..5a5ac289 100644
--- a/tests/e2e/sections-loop.spec.mjs
+++ b/tests/e2e/sections-loop.spec.mjs
@@ -19,12 +19,11 @@ test.afterEach(async ({ page }) => {
await page.request.patch(`/api/jobs/${JOB_ID}/sections`, { data: { sections: [] } });
});
-/** "00:02.000", the format fmtTimeMs writes into the loop fields. */
+/** "02.000", the format fmtTimeMs writes into the loop fields. */
const tc = (seconds) => {
const ms = Math.round(seconds * 1000);
- const m = String(Math.floor(ms / 60000)).padStart(2, "0");
- const s = String(Math.floor((ms % 60000) / 1000)).padStart(2, "0");
- return `${m}:${s}.${String(ms % 1000).padStart(3, "0")}`;
+ const s = String(Math.floor(ms / 1000)).padStart(2, "0");
+ return `${s}.${String(ms % 1000).padStart(3, "0")}`;
};
/** Arm the loop the way a user does: type both bounds, blur to commit. */