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