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. */