From a9bf74b54a0a6619b1a875a0b558f0bc105841b7 Mon Sep 17 00:00:00 2001 From: "Tha.Les" <31625914+thcp@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:23:44 +0100 Subject: [PATCH 1/2] Guide shows where to point, explains each song fact, and works with no song open (#762) feat(help): Guide shows where to point, explains each song fact, and works with no song open Three gaps in help mode, found by using it: - Turning Guide on gave no hint of where to point. Every control it can explain now glows once, for about a second and a half, and fades, so the screen stays calm afterwards. Large areas such as the waveforms are left out of the glow. - The row of song facts was explained as one block. Each card now explains itself: key and how sure its guess is, BPM, LUFS, duration, dynamic range, tempo stability, and every presence card, naming its instrument. - With no song open, the studio switches Export Mix, the song details button and the analysis cards off for the pointer, so help mode never heard it over them. In help mode they receive the pointer again; Guide still takes every click, so nothing is pressed by accident. Closes #760 --- static/css/daw.css | 26 ++++++++++- static/js/helpMode.js | 59 ++++++++++++++++++++++- static/js/i18n.js | 90 ++++++++++++++++++++++++++++-------- tests/e2e/help-mode.spec.mjs | 44 +++++++++++++++++- 4 files changed, 195 insertions(+), 24 deletions(-) diff --git a/static/css/daw.css b/static/css/daw.css index d015e4a0..ec2127f1 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -5677,12 +5677,36 @@ input, textarea { font-family: inherit; } .rail-guide[aria-pressed="true"] { color: var(--accent); background: var(--panel); } /* Only the control being explained is marked, so the screen stays calm; the pill at the bottom says help mode is on. */ -.help-mode .help-target { cursor: help; } +/* With no song open, the studio turns several controls off for the pointer + (Export Mix, the song facts, the presence cards). Help mode still has to hear + the pointer over them to explain them, and it takes every click itself, so + nothing can be pressed by accident. */ +.help-mode .help-target { cursor: help; pointer-events: auto !important; } .help-ring { position: fixed; z-index: 9998; pointer-events: none; border: 2px solid var(--accent); border-radius: var(--radius-sm); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 16%, transparent); } +/* The glow when Guide turns on: every explainable control at once, fading in + and out over --intro-ms (set from INTRO_MS in helpMode.js). With reduced + motion it simply shows, still, for the same time. */ +.help-intro { position: fixed; inset: 0; z-index: 9997; pointer-events: none; } +.help-intro-ring { + position: absolute; + border: 2px solid var(--accent); border-radius: var(--radius-sm); + box-shadow: 0 0 10px 2px color-mix(in srgb, var(--accent) 35%, transparent); + opacity: 0; + animation: help-intro-glow var(--intro-ms, 1600ms) ease-in-out forwards; +} +@keyframes help-intro-glow { + 0% { opacity: 0; } + 25% { opacity: 1; } + 65% { opacity: 1; } + 100% { opacity: 0; } +} +@media (prefers-reduced-motion: reduce) { + .help-intro-ring { animation: none; opacity: 0.8; } +} .help-pill { position: fixed; z-index: 9999; left: 50%; bottom: 18px; transform: translateX(-50%); max-width: calc(100vw - 32px); diff --git a/static/js/helpMode.js b/static/js/helpMode.js index 767b07e8..7749d4fe 100644 --- a/static/js/helpMode.js +++ b/static/js/helpMode.js @@ -12,6 +12,9 @@ import { t } from "./i18n.js"; /** Lane controls name their instrument in the text ("Silences the Drums"). */ const laneName = (el) => el.closest(".mx-row, .lane-header")?.querySelector(".mx-name")?.textContent.trim() || ""; const chipName = (el) => el.textContent.trim(); +/** The instrument a presence card measures, by its own name ("Drums"), not + * the card's heading ("Drum intensity"), so the sentence reads naturally. */ +const presenceName = (el) => t(`stem.${el.dataset.stem}`); // Order matters only where selectors overlap: the first match wins. const HELP = [ @@ -51,7 +54,13 @@ const HELP = [ [".mx-btn.mute", "help.mute", laneName], [".mx-btn.solo", "help.solo", laneName], [".lane-dl", "help.download", laneName], - ["#transport .daw-info-row", "help.songFacts", null, "help.songFactsTitle"], + ['.daw-meta-card[data-meta="key"]', "help.meta.key"], + ['.daw-meta-card[data-meta="bpm"]', "help.meta.bpm"], + ['.daw-meta-card[data-meta="lufs"]', "help.meta.lufs"], + ['.daw-meta-card[data-meta="duration"]', "help.meta.duration"], + ['.daw-meta-card[data-meta="dr"]', "help.meta.dr"], + ['.daw-meta-card[data-meta="stability"]', "help.meta.stability"], + [".stem-presence-panel .stem-card[data-stem]", "help.presence", presenceName], ["#ruler-time", "help.waves", null, "help.wavesTitle"], [".waves-column", "help.waves", null, "help.wavesTitle"], ["#t-stop", "help.stop"], @@ -108,6 +117,11 @@ let on = false; let tipEl = null; let pillEl = null; let ringEl = null; +let introEl = null; +let introTimer = null; +// How long the glow on every explainable control lasts when Guide turns on. +// daw.css reads it through --intro-ms, so the fade and the cleanup agree. +const INTRO_MS = 1600; let current = null; export function isHelpModeOn() { @@ -249,6 +263,40 @@ function markTargets() { } } +/** + * When Guide turns on, every control it can explain glows once and fades, so + * the user sees where to point without the screen staying busy. One ring per + * control, drawn the same way as the pointer's ring, so no panel clips them. + * Large areas (the waveforms, the song facts) are left out: a glowing box + * across half the screen says nothing about where to point. + */ +function playIntro() { + stopIntro(); + const vw = window.innerWidth, vh = window.innerHeight; + const big = vw * vh * 0.04; + for (const el of document.querySelectorAll(".help-target")) { + const r = visibleRect(el); + if (r.width * r.height > big || r.bottom < 0 || r.top > vh || r.right < 0 || r.left > vw) continue; + const ring = document.createElement("div"); + ring.className = "help-intro-ring"; + ring.style.left = `${Math.round(r.left)}px`; + ring.style.top = `${Math.round(r.top)}px`; + ring.style.width = `${Math.round(r.width)}px`; + ring.style.height = `${Math.round(r.height)}px`; + introEl.appendChild(ring); + } + introEl.hidden = false; + introTimer = setTimeout(stopIntro, INTRO_MS); +} + +function stopIntro() { + clearTimeout(introTimer); + introTimer = null; + if (!introEl) return; + introEl.hidden = true; + introEl.replaceChildren(); +} + function onPointerOver(e) { const entry = findEntry(e.target); if (entry) show(entry); @@ -302,6 +350,7 @@ export function setHelpMode(next) { window.addEventListener("resize", hide); document.addEventListener("scroll", hide, true); markTargets(); + playIntro(); } else { document.removeEventListener("pointerover", onPointerOver, true); for (const type of PRESS_EVENTS) document.removeEventListener(type, swallow, true); @@ -310,6 +359,7 @@ export function setHelpMode(next) { window.removeEventListener("resize", hide); document.removeEventListener("scroll", hide, true); hide(); + stopIntro(); markTargets(); } } @@ -331,6 +381,11 @@ export function initHelpMode() { ringEl.className = "help-ring"; ringEl.setAttribute("aria-hidden", "true"); ringEl.hidden = true; - document.body.append(ringEl, tipEl, pillEl); + introEl = document.createElement("div"); + introEl.className = "help-intro"; + introEl.setAttribute("aria-hidden", "true"); + introEl.style.setProperty("--intro-ms", `${INTRO_MS}ms`); + introEl.hidden = true; + document.body.append(introEl, ringEl, tipEl, pillEl); btn.addEventListener("click", () => setHelpMode(!on)); } diff --git a/static/js/i18n.js b/static/js/i18n.js index d6e9f29b..d66b0a56 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -481,8 +481,13 @@ const en = { "help.mute": "Silences {name}, so you can play or sing that part yourself.", "help.solo": "Plays only {name}, to hear it clearly.", "help.download": "Saves {name} as its own audio file.", - "help.songFactsTitle": "Song facts", - "help.songFacts": "Found for you: the key, the tempo in BPM, loudness, length and how much of each instrument the song has.", + "help.meta.key": "The song's key, found by listening to it. The small percentage says how sure that guess is.", + "help.meta.bpm": "The tempo, in beats per minute. The click track follows it.", + "help.meta.lufs": "How loud the song is on average, in LUFS. Closer to zero is louder; most streaming services play songs at about -14.", + "help.meta.duration": "How long the song is.", + "help.meta.dr": "The gap between the loudest moments and the average level, in dB. A higher number means more contrast between quiet and loud parts.", + "help.meta.stability": "How steady the tempo is. 100% means it never drifts; a lower number means it speeds up or slows down.", + "help.presence": "How loud the {name} part is next to the loudest instrument in the song. 100% is the loudest; a dash means it is not in the song.", "help.wavesTitle": "Waveforms", "help.waves": "Each instrument's sound, drawn over time. Click to jump to that moment. Drag across to loop that part.", "help.stop": "Stops the song.", @@ -1371,8 +1376,13 @@ const pl = { "help.mute": "Wycisza {name}, żebyś mógł sam zagrać lub zaśpiewać tę partię.", "help.solo": "Odtwarza tylko {name}, żeby było wyraźnie słychać.", "help.download": "Zapisuje {name} jako osobny plik audio.", - "help.songFactsTitle": "Dane utworu", - "help.songFacts": "Wyznaczone dla ciebie: tonacja, tempo w BPM, głośność, długość i udział każdego instrumentu.", + "help.meta.key": "Tonacja utworu, rozpoznana ze słuchu. Mały procent mówi, jak pewne jest to rozpoznanie.", + "help.meta.bpm": "Tempo w uderzeniach na minutę. Klik podąża za nim.", + "help.meta.lufs": "Średnia głośność utworu w LUFS. Bliżej zera znaczy głośniej; większość serwisów streamingowych odtwarza utwory na poziomie około -14.", + "help.meta.duration": "Długość utworu.", + "help.meta.dr": "Różnica między najgłośniejszymi momentami a średnim poziomem, w dB. Wyższa liczba oznacza większy kontrast między cichymi i głośnymi fragmentami.", + "help.meta.stability": "Jak równe jest tempo. 100% oznacza, że się nie zmienia; niższa liczba oznacza, że przyspiesza lub zwalnia.", + "help.presence": "Jak głośne jest {name} w porównaniu z najgłośniejszym instrumentem w utworze. 100% to najgłośniejszy; kreska oznacza, że go nie ma.", "help.wavesTitle": "Przebiegi", "help.waves": "Dźwięk każdego instrumentu w czasie. Kliknij, aby przejść do tego momentu. Przeciągnij, aby zapętlić ten fragment.", "help.stop": "Zatrzymuje utwór.", @@ -2251,8 +2261,13 @@ const ja = { "help.mute": "{name}をミュートします。そのパートを自分で演奏したり歌ったりできます。", "help.solo": "{name}だけを再生して、はっきり聞けるようにします。", "help.download": "{name}を単独の音声ファイルとして保存します。", - "help.songFactsTitle": "曲の情報", - "help.songFacts": "キー、BPM で表したテンポ、ラウドネス、長さ、各楽器の割合を自動で調べています。", + "help.meta.key": "曲を聴いて判定したキーです。小さなパーセントは、その判定の確かさです。", + "help.meta.bpm": "1 分あたりの拍数で表したテンポです。クリックはこれに合わせます。", + "help.meta.lufs": "曲の平均的な音量(LUFS)です。0 に近いほど大きく、多くの配信サービスは約 -14 で再生します。", + "help.meta.duration": "曲の長さです。", + "help.meta.dr": "最も大きい瞬間と平均レベルの差(dB)です。数値が大きいほど、静かな部分と大きな部分の差がはっきりしています。", + "help.meta.stability": "テンポの安定度です。100% はまったく揺れないこと、数値が低いほど速くなったり遅くなったりします。", + "help.presence": "曲の中で最も大きい楽器と比べた{name}の音量です。100% が最大、ダッシュはその楽器が入っていないことを示します。", "help.wavesTitle": "波形", "help.waves": "各楽器の音を時間に沿って描いたものです。クリックでその位置へ移動し、ドラッグでその部分をループします。", "help.stop": "曲を止めます。", @@ -3106,8 +3121,13 @@ const zhHans = { "help.mute": "将{name}静音,让你自己演奏或演唱这一部分。", "help.solo": "只播放{name},听得更清楚。", "help.download": "把{name}保存为单独的音频文件。", - "help.songFactsTitle": "歌曲信息", - "help.songFacts": "已为你分析:调性、以 BPM 表示的速度、响度、时长,以及每种乐器的占比。", + "help.meta.key": "通过聆听判断出的歌曲调性。小百分比表示这个判断有多可靠。", + "help.meta.bpm": "速度,以每分钟拍数表示。节拍声跟随它。", + "help.meta.lufs": "歌曲的平均响度,单位 LUFS。越接近零越响;大多数流媒体服务以约 -14 播放歌曲。", + "help.meta.duration": "歌曲的时长。", + "help.meta.dr": "最响时刻与平均电平之间的差值,单位 dB。数字越大,安静和响亮部分的对比越明显。", + "help.meta.stability": "速度有多稳定。100% 表示完全不漂移;数字越低,表示时快时慢。", + "help.presence": "{name}与歌曲中最响的乐器相比有多响。100% 为最响;横线表示歌曲中没有它。", "help.wavesTitle": "波形", "help.waves": "每种乐器的声音随时间的变化。点击跳到那一刻,拖动可循环那一段。", "help.stop": "停止播放。", @@ -3961,8 +3981,13 @@ const de = { "help.mute": "Schaltet {name} stumm, damit du diesen Part selbst spielen oder singen kannst.", "help.solo": "Spielt nur {name}, damit du es deutlich hörst.", "help.download": "Speichert {name} als eigene Audiodatei.", - "help.songFactsTitle": "Songdaten", - "help.songFacts": "Für dich ermittelt: Tonart, Tempo in BPM, Lautheit, Länge und wie viel von jedem Instrument der Song hat.", + "help.meta.key": "Die Tonart des Songs, durch Hören ermittelt. Die kleine Prozentzahl sagt, wie sicher diese Einschätzung ist.", + "help.meta.bpm": "Das Tempo in Schlägen pro Minute. Der Klick folgt ihm.", + "help.meta.lufs": "Wie laut der Song im Durchschnitt ist, in LUFS. Näher an null ist lauter; die meisten Streamingdienste spielen Songs bei etwa -14.", + "help.meta.duration": "Wie lang der Song ist.", + "help.meta.dr": "Der Abstand zwischen den lautesten Momenten und dem Durchschnittspegel, in dB. Eine höhere Zahl heißt mehr Kontrast zwischen leisen und lauten Teilen.", + "help.meta.stability": "Wie gleichmäßig das Tempo ist. 100 % heißt, es schwankt nie; eine niedrigere Zahl heißt, es wird schneller oder langsamer.", + "help.presence": "Wie laut {name} im Vergleich zum lautesten Instrument im Song ist. 100 % ist das lauteste; ein Strich heißt, es kommt nicht vor.", "help.wavesTitle": "Wellenformen", "help.waves": "Der Klang jedes Instruments über die Zeit. Klick, um an diese Stelle zu springen. Zieh darüber, um diesen Teil zu loopen.", "help.stop": "Stoppt den Song.", @@ -4827,8 +4852,13 @@ const pt = { "help.mute": "Silencia {name}, para você tocar ou cantar essa parte.", "help.solo": "Toca só {name}, para ouvir com clareza.", "help.download": "Salva {name} como um arquivo de áudio separado.", - "help.songFactsTitle": "Dados da música", - "help.songFacts": "Encontrados para você: o tom, o andamento em BPM, o volume, a duração e quanto há de cada instrumento.", + "help.meta.key": "O tom da música, encontrado ao ouvi-la. A pequena porcentagem diz o quão certo é esse palpite.", + "help.meta.bpm": "O andamento, em batidas por minuto. O clique segue ele.", + "help.meta.lufs": "O volume médio da música, em LUFS. Mais perto de zero é mais alto; a maioria dos serviços de streaming toca músicas em cerca de -14.", + "help.meta.duration": "A duração da música.", + "help.meta.dr": "A diferença entre os momentos mais altos e o nível médio, em dB. Um número maior significa mais contraste entre partes baixas e altas.", + "help.meta.stability": "O quão estável é o andamento. 100% significa que nunca varia; um número menor significa que acelera ou desacelera.", + "help.presence": "O quão alto {name} está em relação ao instrumento mais alto da música. 100% é o mais alto; um traço significa que não está na música.", "help.wavesTitle": "Formas de onda", "help.waves": "O som de cada instrumento ao longo do tempo. Clique para pular para esse momento. Arraste para repetir esse trecho em loop.", "help.stop": "Para a música.", @@ -5695,8 +5725,13 @@ const id = { "help.mute": "Membisukan {name}, agar Anda bisa memainkan atau menyanyikan bagian itu sendiri.", "help.solo": "Hanya memutar {name}, agar terdengar jelas.", "help.download": "Menyimpan {name} sebagai file audio tersendiri.", - "help.songFactsTitle": "Data lagu", - "help.songFacts": "Ditemukan untuk Anda: kunci nada, tempo dalam BPM, kekerasan suara, durasi, dan seberapa banyak tiap instrumen.", + "help.meta.key": "Kunci nada lagu, ditemukan dengan mendengarkannya. Persentase kecil menunjukkan seberapa yakin tebakan itu.", + "help.meta.bpm": "Tempo, dalam ketukan per menit. Ketukan klik mengikutinya.", + "help.meta.lufs": "Seberapa keras lagu secara rata-rata, dalam LUFS. Lebih dekat ke nol berarti lebih keras; kebanyakan layanan streaming memutar lagu di sekitar -14.", + "help.meta.duration": "Panjang lagu.", + "help.meta.dr": "Selisih antara momen paling keras dan tingkat rata-rata, dalam dB. Angka lebih tinggi berarti kontras lebih besar antara bagian pelan dan keras.", + "help.meta.stability": "Seberapa stabil temponya. 100% berarti tidak pernah bergeser; angka lebih rendah berarti tempo cepat atau melambat.", + "help.presence": "Seberapa keras {name} dibanding instrumen paling keras di lagu. 100% adalah yang paling keras; tanda strip berarti tidak ada di lagu.", "help.wavesTitle": "Bentuk gelombang", "help.waves": "Suara tiap instrumen dari waktu ke waktu. Klik untuk melompat ke saat itu. Seret untuk mengulang bagian itu.", "help.stop": "Menghentikan lagu.", @@ -6550,8 +6585,13 @@ const fr = { "help.mute": "Coupe {name}, pour que vous puissiez jouer ou chanter cette partie vous-même.", "help.solo": "Joue seulement {name}, pour l'entendre clairement.", "help.download": "Enregistre {name} comme fichier audio séparé.", - "help.songFactsTitle": "Infos du morceau", - "help.songFacts": "Trouvés pour vous : la tonalité, le tempo en BPM, le volume, la durée et la présence de chaque instrument.", + "help.meta.key": "La tonalité du morceau, trouvée à l'écoute. Le petit pourcentage indique la certitude de cette estimation.", + "help.meta.bpm": "Le tempo, en battements par minute. Le clic le suit.", + "help.meta.lufs": "Le volume moyen du morceau, en LUFS. Plus proche de zéro, c'est plus fort ; la plupart des plateformes de streaming diffusent vers -14.", + "help.meta.duration": "La durée du morceau.", + "help.meta.dr": "L'écart entre les moments les plus forts et le niveau moyen, en dB. Un nombre plus élevé signifie plus de contraste entre passages doux et forts.", + "help.meta.stability": "La régularité du tempo. 100 % signifie qu'il ne varie jamais ; un nombre plus bas signifie qu'il accélère ou ralentit.", + "help.presence": "Le volume de {name} par rapport à l'instrument le plus fort du morceau. 100 % est le plus fort ; un tiret signifie qu'il n'est pas dans le morceau.", "help.wavesTitle": "Formes d'onde", "help.waves": "Le son de chaque instrument dans le temps. Cliquez pour aller à ce moment. Faites glisser pour boucler ce passage.", "help.stop": "Arrête le morceau.", @@ -7590,8 +7630,13 @@ const es = { "help.mute": "Silencia {name}, para que puedas tocar o cantar esa parte tú.", "help.solo": "Reproduce solo {name}, para oírlo con claridad.", "help.download": "Guarda {name} como un archivo de audio aparte.", - "help.songFactsTitle": "Datos de la canción", - "help.songFacts": "Calculados para ti: la tonalidad, el tempo en BPM, el volumen, la duración y cuánto hay de cada instrumento.", + "help.meta.key": "La tonalidad de la canción, encontrada al escucharla. El pequeño porcentaje indica cuán segura es esa estimación.", + "help.meta.bpm": "El tempo, en pulsos por minuto. El clic lo sigue.", + "help.meta.lufs": "El volumen medio de la canción, en LUFS. Más cerca de cero es más fuerte; la mayoría de servicios de streaming reproducen canciones a unos -14.", + "help.meta.duration": "La duración de la canción.", + "help.meta.dr": "La diferencia entre los momentos más fuertes y el nivel medio, en dB. Un número mayor significa más contraste entre partes suaves y fuertes.", + "help.meta.stability": "Qué tan estable es el tempo. 100% significa que nunca varía; un número menor significa que se acelera o se frena.", + "help.presence": "Qué tan fuerte suena {name} frente al instrumento más fuerte de la canción. 100% es el más fuerte; un guion significa que no está en la canción.", "help.wavesTitle": "Formas de onda", "help.waves": "El sonido de cada instrumento a lo largo del tiempo. Haz clic para saltar a ese momento. Arrastra para repetir esa parte en bucle.", "help.stop": "Detiene la canción.", @@ -8479,8 +8524,13 @@ const ko = { "help.mute": "{name} 소리를 꺼서 그 부분을 직접 연주하거나 부를 수 있게 해요.", "help.solo": "{name}만 재생해서 또렷하게 들려줘요.", "help.download": "{name}을(를) 별도의 오디오 파일로 저장해요.", - "help.songFactsTitle": "곡 정보", - "help.songFacts": "자동으로 찾아 줘요: 키, BPM 단위의 템포, 음량, 길이, 그리고 악기별 비중이에요.", + "help.meta.key": "곡을 들어서 찾은 키예요. 작은 퍼센트는 그 판단이 얼마나 확실한지 보여 줘요.", + "help.meta.bpm": "분당 박자 수로 나타낸 템포예요. 클릭이 이를 따라가요.", + "help.meta.lufs": "곡의 평균 음량(LUFS)이에요. 0에 가까울수록 크고, 대부분의 스트리밍 서비스는 약 -14로 재생해요.", + "help.meta.duration": "곡의 길이예요.", + "help.meta.dr": "가장 큰 순간과 평균 레벨의 차이(dB)예요. 숫자가 클수록 조용한 부분과 큰 부분의 대비가 커요.", + "help.meta.stability": "템포가 얼마나 일정한지예요. 100%는 전혀 흔들리지 않는다는 뜻이고, 낮을수록 빨라지거나 느려져요.", + "help.presence": "곡에서 가장 큰 악기와 비교한 {name}의 음량이에요. 100%가 가장 크고, 대시는 곡에 없다는 뜻이에요.", "help.wavesTitle": "파형", "help.waves": "악기마다 소리를 시간에 따라 그린 거예요. 클릭하면 그 순간으로 가고, 끌면 그 부분을 반복해요.", "help.stop": "곡을 멈춰요.", diff --git a/tests/e2e/help-mode.spec.mjs b/tests/e2e/help-mode.spec.mjs index cdf7ac3b..a11ebc4e 100644 --- a/tests/e2e/help-mode.spec.mjs +++ b/tests/e2e/help-mode.spec.mjs @@ -5,7 +5,7 @@ import { test, expect } from "@playwright/test"; -import { openStudio } from "./helpers.mjs"; +import { openStudio, seedLibrary, stubUpdateCheck } from "./helpers.mjs"; const tip = (page) => page.locator("#helpTip"); @@ -29,6 +29,23 @@ test.describe("help mode", () => { await expect(page.locator(".help-pill")).toBeHidden(); }); + test("turning Guide on makes every explainable control glow once, then fade", async ({ page }) => { + await openStudio(page); + await page.locator("#helpModeBtn").click(); + const rings = page.locator(".help-intro-ring"); + expect(await rings.count()).toBeGreaterThan(20); + await expect(page.locator(".help-intro")).toBeVisible(); + // Gone by itself, so the screen is calm again. + await expect(rings).toHaveCount(0, { timeout: 4000 }); + await expect(page.locator(".help-intro")).toBeHidden(); + + // Leaving Guide mid-glow clears it at once. + await page.keyboard.press("Escape"); + await page.locator("#helpModeBtn").click(); + await page.keyboard.press("Escape"); + await expect(rings).toHaveCount(0); + }); + test("pointing at a control explains it, under its own label", async ({ page }) => { await openStudio(page); await helpOn(page); @@ -53,6 +70,31 @@ test.describe("help mode", () => { await expect(tip(page).locator("b")).toHaveText("Key of Drums"); }); + test("each song fact and presence card explains itself", async ({ page }) => { + await openStudio(page); + await helpOn(page); + await page.locator('.daw-meta-card[data-meta="lufs"]').hover(); + await expect(tip(page)).toContainText("streaming services"); + await page.locator('.daw-meta-card[data-meta="stability"]').hover(); + await expect(tip(page)).toContainText("How steady the tempo is"); + await page.locator('.stem-presence-panel .stem-card[data-stem="drums"]').hover(); + await expect(tip(page)).toContainText("How loud the Drums part is"); + }); + + test("with no song open, controls the studio switches off still explain themselves", async ({ page }) => { + await seedLibrary(page); + await stubUpdateCheck(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.locator(".cat-item").first().waitFor(); + await helpOn(page); + for (const sel of ["#t-export-btn", "#np-details-btn", '.daw-meta-card[data-meta="key"]', '.stem-presence-panel .stem-card[data-stem="vocals"]']) { + const box = await page.locator(sel).first().boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await expect(tip(page), sel).toBeVisible(); + await expect(page.locator(sel).first(), sel).toHaveClass(/help-current/); + } + }); + test("a click explains instead of acting", async ({ page }) => { await openStudio(page); await helpOn(page); From f3a18f13c164f962ee828413f1c44bc90cc19c94 Mon Sep 17 00:00:00 2001 From: "Tha.Les" <31625914+thcp@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:23:47 +0100 Subject: [PATCH 2/2] Adrianna Claro on We Recommend, and partner cards with several links (#763) feat(recommend): Adrianna Claro on We Recommend, and partner cards with several links Adrianna Claro, a soul and contemporary R&B singer, songwriter and composer, joins Artists & Creators, with her photo and her music on Spotify and Apple Music. Killah Trakz gains Spotify and Apple Music beside Instagram, and NIHIL gains Spotify. A partner card could only hold one link, because the whole card was the link. A card can now carry several: it is then a plain card with one labelled pill per destination, since a link inside a link is invalid HTML and which one a click follows is up to the browser. A card with a single url stays a link as a whole, exactly as before. The README table lists the same links, and is now sorted by category, then name, the order the app already shows them in. Closes #761 --- README.md | 21 +++++----- static/css/daw.css | 13 ++++++ static/img/friends/adrianna-claro.webp | Bin 0 -> 5264 bytes static/js/catalog.js | 54 +++++++++++++++++++++---- static/js/i18n.js | 10 +++++ tests/e2e/we-recommend.spec.mjs | 53 ++++++++++++++++++++++++ 6 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 static/img/friends/adrianna-claro.webp create mode 100644 tests/e2e/we-recommend.spec.mjs diff --git a/README.md b/README.md index f9bb21dd..f69f0ccc 100644 --- a/README.md +++ b/README.md @@ -53,22 +53,23 @@ StemDeck is free and **does not accept any money, sponsorship, or funding** fro | Category | Name | What they do | Link | |---|---|---|---| -| Artists & Creators | Joao Gaspar | Producer, film scorer, touring/session musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) | -| Artists & Creators | Killah Trakz | Industry Secret Villain and Media Influencer | [@killahtrakz](https://www.instagram.com/killahtrakz/) | -| Artists & Creators | More Notes Less Talk | Gear-focused creative project with a raw, tape-recorded identity | [@morenoteslesstalk](https://www.youtube.com/@morenoteslesstalk) | +| Artists & Creators | Adrianna Claro | Soul and Contemporary R&B Singer, Songwriter and Composer | [Spotify](https://open.spotify.com/album/10fZ48iJhIe3lWcXSMUlFG) · [Apple Music](https://music.apple.com/us/album/happy/1592026727?i=1592026728) | | Artists & Creators | Analog4Lyfe | Analog gear specialist | [@analog4lyfe](https://www.instagram.com/analog4lyfe) | | Artists & Creators | Dead röses | Cork-based punk rock band | [@dead_rosesband](https://www.instagram.com/dead_rosesband) | -| Artists & Creators | NIHIL | Modern alternative metal from Argentina. Fueled by emotionally charged vocals, anger and emptiness | [@somosnihil](https://www.instagram.com/somosnihil/) | +| Artists & Creators | Joao Gaspar | Producer, film scorer, touring/session musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) | +| Artists & Creators | Killah Trakz | Industry Secret Villain and Media Influencer | [@killahtrakz](https://www.instagram.com/killahtrakz/) · [Spotify](https://open.spotify.com/artist/6nePYOqT4E7D4C48twPtKk) · [Apple Music](https://music.apple.com/us/artist/killah-trakz/371296633) | +| Artists & Creators | More Notes Less Talk | Gear-focused creative project with a raw, tape-recorded identity | [@morenoteslesstalk](https://www.youtube.com/@morenoteslesstalk) | +| Artists & Creators | NIHIL | Modern alternative metal from Argentina. Fueled by emotionally charged vocals, anger and emptiness | [@somosnihil](https://www.instagram.com/somosnihil/) · [Spotify](https://open.spotify.com/artist/1OeKplJxFNM6JHrWeo2SaV) | | Instrument Builders & Repair | Dlima Guitars | Custom guitars and basses | [@dlimaguitars](https://www.instagram.com/dlimaguitars) | -| Instrument Builders & Repair | Lisbon Guitar Works | Handmade guitars in Lisbon | [dlimaguitars.com](https://dlimaguitars.com) | | Instrument Builders & Repair | Kris Luthier | Instrument repair and restoration | [@krisluthier](https://www.instagram.com/krisluthier) | -| Music Gear | Empress Effects | Boutique effects pedals | [empresseffects.com](https://empresseffects.com) | -| Music Gear | Thomann | Large music-equipment retailer | [@thomann.music](https://www.instagram.com/thomann.music) | +| Instrument Builders & Repair | Lisbon Guitar Works | Handmade guitars in Lisbon | [dlimaguitars.com](https://dlimaguitars.com) | +| Media & Community | Not Another Audio Podcast | Audio, music and tech industry podcast by working professionals | [Libsyn](https://directory.libsyn.com/shows/view/id/20f5a1e3-6fea-4d41-8cdf-451ce6fc6cda) | +| Media & Community | r/bass | Bass-player community | [r/Bass](https://www.reddit.com/r/Bass) | +| Media & Community | slashCAM | Camera, video, and post-production media | [@slashcam.de](https://www.instagram.com/slashcam.de) | | Music & Karaoke Technology | Beltr | Local, subscription-free karaoke software | [beltr.app](https://beltr.app/) | | Music & Karaoke Technology | Seratone | TV-based karaoke system | [seratone.audio](https://seratone.audio/) | -| Media & Community | slashCAM | Camera, video, and post-production media | [@slashcam.de](https://www.instagram.com/slashcam.de) | -| Media & Community | r/bass | Bass-player community | [r/Bass](https://www.reddit.com/r/Bass) | -| Media & Community | Not Another Audio Podcast | Audio, music and tech industry podcast by working professionals | [Libsyn](https://directory.libsyn.com/shows/view/id/20f5a1e3-6fea-4d41-8cdf-451ce6fc6cda) | +| Music Gear | Empress Effects | Boutique effects pedals | [empresseffects.com](https://empresseffects.com) | +| Music Gear | Thomann | Large music-equipment retailer | [@thomann.music](https://www.instagram.com/thomann.music) | | Writers & Storytellers | Alexandre Borges | Portuguese writer, screenwriter, and cultural commentator | [Books & author profile](https://www.instagram.com/alexgram_b/) | diff --git a/static/css/daw.css b/static/css/daw.css index ec2127f1..6131e9fc 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -4564,6 +4564,19 @@ input, textarea { font-family: inherit; } } .lib-friend:hover .lib-friend-ig, .lib-friend:hover .lib-friend-link { opacity: 1; } +/* A card with several links: the card itself is not a link, each pill is. */ +.lib-friend-multi:hover { background: var(--panel); border-color: var(--border); color: var(--fg-2); } +.lib-friend-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 4px; margin-top: auto; padding-top: 3px; } +.lib-friend-pill { + display: inline-flex; align-items: center; gap: 4px; + padding: 2px 7px; border-radius: 999px; + border: 1px solid var(--border-strong); background: var(--panel-2); + color: var(--fg-2); font-size: 9.5px; font-weight: 600; text-decoration: none; + transition: color var(--t-fast), border-color var(--t-fast); +} +.lib-friend-pill:hover { color: var(--fg); border-color: var(--accent); } +.lib-friend-pill .lib-friend-link, +.lib-friend-pill .lib-friend-ig { width: 10px; height: 10px; margin-top: 0; padding-top: 0; opacity: 1; } .lib-friend-name { width: 100%; font-size: 11px; diff --git a/static/img/friends/adrianna-claro.webp b/static/img/friends/adrianna-claro.webp new file mode 100644 index 0000000000000000000000000000000000000000..38f1e7f6a6a3fb62da6c6c472952eebd4384be7e GIT binary patch literal 5264 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 W`TzfT=M+?a*;RsM7C 0; + const a = multi ? document.createElement("div") : friendLink(f.url); + a.className = multi ? "lib-friend lib-friend-multi" : "lib-friend"; a.title = f.name; // A monogram badge (first initial) keeps the card on-brand when an entry has @@ -3575,7 +3602,20 @@ function friendCard(f) { a.appendChild(role); } - a.appendChild(friendGlyph(f.url)); + if (!multi) { + a.appendChild(friendGlyph(f.url)); + return a; + } + const row = document.createElement("span"); + row.className = "lib-friend-links"; + for (const link of f.links) { + const l = friendLink(link.url); + l.className = "lib-friend-pill"; + l.title = `${f.name}: ${link.label}`; + l.append(friendGlyph(link.url), link.label); + row.appendChild(l); + } + a.appendChild(row); return a; } diff --git a/static/js/i18n.js b/static/js/i18n.js index d66b0a56..015dcfe9 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -738,6 +738,7 @@ const en = { "friends.role.deadRoses": "Cork-based punk rock band", "friends.role.killahTrakz": "Industry Secret Villain and Media Influencer", "friends.role.nihil": "Modern alternative metal from Argentina. Fueled by emotionally charged vocals, anger and emptiness", + "friends.role.adriannaClaro": "Soul and Contemporary R&B Singer, Songwriter and Composer", "friends.role.empressEffects": "Boutique effects pedals", "friends.role.thomann": "Large music-equipment retailer", "friends.role.beltr": "Local, subscription-free karaoke software", @@ -1631,6 +1632,7 @@ const pl = { "friends.role.deadRoses": "Zespół punkrockowy z Corku", "friends.role.killahTrakz": "Sekretny złoczyńca branży i influencer medialny", "friends.role.nihil": "Nowoczesny metal alternatywny z Argentyny. Napędzany emocjonalnym wokalem, gniewem i pustką", + "friends.role.adriannaClaro": "Wokalistka soul i współczesnego R&B, autorka piosenek i kompozytorka", "friends.role.empressEffects": "Butikowe efekty gitarowe", "friends.role.thomann": "Duży sklep ze sprzętem muzycznym", "friends.role.beltr": "Lokalne karaoke bez abonamentu", @@ -2515,6 +2517,7 @@ const ja = { "friends.role.deadRoses": "コーク拠点のパンクロックバンド", "friends.role.killahTrakz": "業界の隠れた悪役でメディアインフルエンサー", "friends.role.nihil": "アルゼンチン発のモダンなオルタナティヴメタル。感情的なボーカル、怒り、そして虚無が原動力", + "friends.role.adriannaClaro": "ソウルとコンテンポラリーR&Bのシンガー、ソングライター、作曲家", "friends.role.empressEffects": "ブティック系エフェクターペダル", "friends.role.thomann": "大手の楽器機材販売店", "friends.role.beltr": "サブスク不要、ローカルで動くカラオケソフト", @@ -3375,6 +3378,7 @@ const zhHans = { "friends.role.deadRoses": "来自科克的朋克摇滚乐队", "friends.role.killahTrakz": "行业隐秘反派与媒体影响者", "friends.role.nihil": "来自阿根廷的现代另类金属。以饱含情感的人声、愤怒与虚无为驱动", + "friends.role.adriannaClaro": "灵魂乐与当代 R&B 歌手、词曲作者和作曲家", "friends.role.empressEffects": "精品效果器", "friends.role.thomann": "大型乐器器材零售商", "friends.role.beltr": "本地运行、无需订阅的卡拉OK软件", @@ -4236,6 +4240,7 @@ const de = { "friends.role.deadRoses": "Punkrock-Band aus Cork", "friends.role.killahTrakz": "Heimlicher Bösewicht der Branche und Medien-Influencer", "friends.role.nihil": "Moderner Alternative Metal aus Argentinien. Angetrieben von emotionsgeladenem Gesang, Wut und Leere", + "friends.role.adriannaClaro": "Soul- und Contemporary-R&B-Sängerin, Songwriterin und Komponistin", "friends.role.empressEffects": "Boutique-Effektpedale", "friends.role.thomann": "Großer Händler für Musik-Equipment", "friends.role.beltr": "Lokale Karaoke-Software ohne Abo", @@ -5107,6 +5112,7 @@ const pt = { "friends.role.deadRoses": "Banda de punk rock de Cork", "friends.role.killahTrakz": "Vilão secreto da indústria e influenciador digital", "friends.role.nihil": "Metal alternativo moderno da Argentina. Movido por vocais carregados de emoção, raiva e vazio", + "friends.role.adriannaClaro": "Cantora, compositora e autora de soul e R&B contemporâneo", "friends.role.empressEffects": "Pedais de efeito boutique", "friends.role.thomann": "Grande varejista de equipamentos musicais", "friends.role.beltr": "Software de karaokê local, sem assinatura", @@ -5979,6 +5985,7 @@ const id = { "friends.role.deadRoses": "Band punk rock asal Cork", "friends.role.killahTrakz": "Penjahat Rahasia Industri dan Influencer Media", "friends.role.nihil": "Metal alternatif modern dari Argentina. Digerakkan oleh vokal penuh emosi, amarah, dan kehampaan", + "friends.role.adriannaClaro": "Penyanyi, penulis lagu, dan komposer soul dan R&B kontemporer", "friends.role.empressEffects": "Pedal efek butik", "friends.role.thomann": "Peritel besar perangkat musik", "friends.role.beltr": "Perangkat lunak karaoke lokal tanpa langganan", @@ -6840,6 +6847,7 @@ const fr = { "friends.role.deadRoses": "Groupe de punk rock basé à Cork", "friends.role.killahTrakz": "Méchant secret de l'industrie et influenceur média", "friends.role.nihil": "Metal alternatif moderne d'Argentine. Porté par un chant chargé d'émotion, la colère et le vide", + "friends.role.adriannaClaro": "Chanteuse, autrice et compositrice de soul et de R&B contemporain", "friends.role.empressEffects": "Pédales d'effets boutique", "friends.role.thomann": "Grand détaillant de matériel de musique", "friends.role.beltr": "Logiciel de karaoké local, sans abonnement", @@ -7885,6 +7893,7 @@ const es = { "friends.role.deadRoses": "Banda de punk rock de Cork", "friends.role.killahTrakz": "Villano secreto de la industria e influencer de medios", "friends.role.nihil": "Metal alternativo moderno de Argentina. Impulsado por voces cargadas de emoción, rabia y vacío", + "friends.role.adriannaClaro": "Cantante, compositora y autora de soul y R&B contemporáneo", "friends.role.empressEffects": "Pedales de efectos boutique", "friends.role.thomann": "Gran tienda de equipo musical", "friends.role.beltr": "Software de karaoke local y sin suscripción", @@ -8778,6 +8787,7 @@ const ko = { "friends.role.deadRoses": "코크를 기반으로 활동하는 펑크 록 밴드", "friends.role.killahTrakz": "업계의 숨은 악당이자 미디어 인플루언서", "friends.role.nihil": "아르헨티나에서 온 모던 얼터너티브 메탈. 감정적인 보컬과 분노, 공허가 원동력", + "friends.role.adriannaClaro": "소울과 컨템퍼러리 R&B 가수, 송라이터, 작곡가", "friends.role.empressEffects": "부티크 이펙터 페달", "friends.role.thomann": "대형 음악 장비 판매점", "friends.role.beltr": "구독이 필요 없는 로컬 노래방 소프트웨어", diff --git a/tests/e2e/we-recommend.spec.mjs b/tests/e2e/we-recommend.spec.mjs new file mode 100644 index 00000000..9a157960 --- /dev/null +++ b/tests/e2e/we-recommend.spec.mjs @@ -0,0 +1,53 @@ +// We Recommend: a partner card with one link is a link as a whole; a card with +// several (Adrianna Claro: Spotify and Apple Music) is a plain card with one +// labelled link per destination, since a link cannot hold other links. + +import { test, expect } from "@playwright/test"; + +import { seedLibrary, stubUpdateCheck } from "./helpers.mjs"; + +async function openWeRecommend(page) { + await seedLibrary(page); + await stubUpdateCheck(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.locator("#friendsBtn").click(); +} + +test.describe("We Recommend", () => { + test("a card with two links offers both, each opening outside the app", async ({ page }) => { + await openWeRecommend(page); + const card = page.locator(".lib-friend").filter({ hasText: "Adrianna Claro" }); + await expect(card).toHaveCount(1); + await expect(card).toBeVisible(); + // Not a link itself, so the two below are the only things to click. + expect(await card.evaluate((el) => el.tagName)).toBe("DIV"); + // Her photo loads, rather than falling back to the monogram. + await expect(card.locator("img.lib-friend-avatar")).toHaveAttribute("src", "/img/friends/adrianna-claro.webp"); + await expect.poll(() => card.locator("img.lib-friend-avatar").evaluate((img) => img.naturalWidth)).toBeGreaterThan(0); + + const spotify = card.locator(".lib-friend-pill", { hasText: "Spotify" }); + const apple = card.locator(".lib-friend-pill", { hasText: "Apple Music" }); + await expect(spotify).toHaveAttribute("href", "https://open.spotify.com/album/10fZ48iJhIe3lWcXSMUlFG"); + await expect(apple).toHaveAttribute("href", "https://music.apple.com/us/album/happy/1592026727?i=1592026728"); + for (const link of [spotify, apple]) { + await expect(link).toHaveAttribute("target", "_blank"); + await expect(link).toHaveAttribute("rel", "noopener noreferrer"); + } + }); + + test("a card with one link is still a link as a whole", async ({ page }) => { + await openWeRecommend(page); + const card = page.locator(".lib-friend").filter({ hasText: "Analog4Lyfe" }); + expect(await card.evaluate((el) => el.tagName)).toBe("A"); + await expect(card).toHaveAttribute("href", "https://www.instagram.com/analog4lyfe"); + await expect(card.locator(".lib-friend-pill")).toHaveCount(0); + }); + + test("Killah Trakz and NIHIL offer Instagram and Spotify, and Killah Trakz Apple Music too", async ({ page }) => { + await openWeRecommend(page); + const pills = (name) => page.locator(".lib-friend").filter({ hasText: name }).locator(".lib-friend-pill"); + await expect(pills("Killah Trakz")).toHaveText(["Instagram", "Spotify", "Apple Music"]); + await expect(pills("NIHIL")).toHaveText(["Instagram", "Spotify"]); + await expect(pills("NIHIL").filter({ hasText: "Spotify" })).toHaveAttribute("href", "https://open.spotify.com/artist/1OeKplJxFNM6JHrWeo2SaV"); + }); +});