From c2e658c913f65dd22dd2c165c77671139435a4da Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Wed, 23 Sep 2026 08:38:17 +0100
Subject: [PATCH 1/9] Call the vocal mode Combined, not All
Two buttons sat a few pixels apart in the Extract row, both labelled All,
meaning different things. One is every stem. The other is the vocals kept as
one track rather than divided into Lead + Backing. The vocal toggle appears
whenever Vocals is selected, which is the default, so both were on screen
together in the state most people open the app in. The reporter of #657 read
them as a duplicate control and asked why there were two (#659).
The stem row's All is the older and more general meaning, so the vocal one is
the one that changed. Combined reads as the opposite of Lead + Backing without
borrowing a word the row already uses, and it carries no second meaning in
audio the way Mixed or Single would.
Only the value changed, in all ten tables. Nothing keys on the label: the
buttons are found by data-mode and class, so this is a rename on screen and
nowhere else. The phone UI carries the same toggle with its own hardcoded
string, renamed to match, and the three comments that named the pair by its
labels now name it correctly.
The guard is a unit test rather than an e2e one, because the risk is not the
English string. Before this, extract.all and vocalMode.all were identical in
every one of the ten tables. A translator reaching for the local word for
"all" twice restores the collision in one language and is invisible to anyone
who does not read it, so the test asserts every control in that row reads
differently, in each language, resolved through the same fallback chain the
app uses.
---
static/css/daw.css | 2 +-
static/index.html | 2 +-
static/js/i18n.js | 20 +++----
static/js/job.js | 2 +-
static/js/main.js | 2 +-
static/mobile/app.js | 2 +-
tests/js/extract-labels.test.mjs | 89 ++++++++++++++++++++++++++++++++
7 files changed, 104 insertions(+), 15 deletions(-)
create mode 100644 tests/js/extract-labels.test.mjs
diff --git a/static/css/daw.css b/static/css/daw.css
index 1ebcab65..23fa1837 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -310,7 +310,7 @@ input, textarea { font-family: inherit; }
color: var(--accent);
}
-/* Vocals: All / Lead + Backing (on-demand split, #275) */
+/* Vocals: Combined / Lead + Backing (on-demand split, #275) */
.vocal-mode-toggle {
display: inline-flex;
height: 30px;
diff --git a/static/index.html b/static/index.html
index fbd2260b..7fddfc0e 100644
--- a/static/index.html
+++ b/static/index.html
@@ -103,7 +103,7 @@
Vocals is selected above; toggling "Lead + Backing" auto-runs
the split once the import finishes (see main.js/job.js). -->
-
+
diff --git a/static/js/i18n.js b/static/js/i18n.js
index 14a86dbf..2d14a61a 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -257,7 +257,7 @@ const en = {
"stem.others": "Others",
"vocalMode.groupAria": "Vocals mode",
- "vocalMode.all": "All",
+ "vocalMode.all": "Combined",
"vocalMode.split": "Lead + Backing",
"aria.mute": "Mute {name}",
@@ -874,7 +874,7 @@ const pl = {
"stem.others": "Inne",
"vocalMode.groupAria": "Tryb wokalu",
- "vocalMode.all": "Wszystko",
+ "vocalMode.all": "Połączone",
"vocalMode.split": "Wokal + Chórki",
"aria.mute": "Wycisz: {name}",
@@ -1481,7 +1481,7 @@ const ja = {
"stem.others": "その他",
"vocalMode.groupAria": "ボーカルモード",
- "vocalMode.all": "すべて",
+ "vocalMode.all": "結合",
"vocalMode.split": "リード+コーラス",
"aria.mute": "{name}をミュート",
@@ -2063,7 +2063,7 @@ const zhHans = {
"stem.others": "其他",
"vocalMode.groupAria": "人声模式",
- "vocalMode.all": "全部",
+ "vocalMode.all": "合并",
"vocalMode.split": "主唱+和声",
"aria.mute": "静音{name}",
@@ -2645,7 +2645,7 @@ const de = {
"stem.others": "Sonstiges",
"vocalMode.groupAria": "Gesangsmodus",
- "vocalMode.all": "Alle",
+ "vocalMode.all": "Kombiniert",
"vocalMode.split": "Lead + Backing",
"aria.mute": "{name} stummschalten",
@@ -3238,7 +3238,7 @@ const pt = {
"stem.others": "Outros",
"vocalMode.groupAria": "Modo de vocais",
- "vocalMode.all": "Todos",
+ "vocalMode.all": "Combinados",
"vocalMode.split": "Principal + Apoio",
"aria.mute": "Mudo {name}",
@@ -3833,7 +3833,7 @@ const id = {
"stem.others": "Lainnya",
"vocalMode.groupAria": "Mode vokal",
- "vocalMode.all": "Semua",
+ "vocalMode.all": "Gabungan",
"vocalMode.split": "Utama + Latar",
"aria.mute": "Bisukan {name}",
@@ -4415,7 +4415,7 @@ const fr = {
"stem.others": "Autres",
"vocalMode.groupAria": "Mode voix",
- "vocalMode.all": "Tout",
+ "vocalMode.all": "Combinée",
"vocalMode.split": "Principale + chœurs",
"aria.mute": "Couper {name}",
@@ -5126,7 +5126,7 @@ const es = {
"stem.others": "Otros",
"vocalMode.groupAria": "Modo de voces",
- "vocalMode.all": "Todo",
+ "vocalMode.all": "Combinadas",
"vocalMode.split": "Principal + coros",
"aria.mute": "Mute {name}",
@@ -5742,7 +5742,7 @@ const ko = {
"stem.others": "기타 소리",
"vocalMode.groupAria": "보컬 모드",
- "vocalMode.all": "전체",
+ "vocalMode.all": "통합",
"vocalMode.split": "리드 + 백",
"aria.mute": "{name} 음소거",
diff --git a/static/js/job.js b/static/js/job.js
index 348b7231..0b9b453e 100644
--- a/static/js/job.js
+++ b/static/js/job.js
@@ -24,7 +24,7 @@ let jobPollTimerId = null;
const renderedJobs = new Set();
const jobSources = new Map();
// On-demand lead/backing vocal split (#275): which jobs asked for it at
-// submit time (via the Extract bar's Vocals "All / Lead + Backing" toggle).
+// submit time (via the Extract bar's Vocals "Combined / Lead + Backing" toggle).
// Exported so catalog.js's completeSettledJob (the background-job path --
// there is no per-job SSE stream for those) can trigger it too, via
// runVocalSplitIfWanted below. Only covers the primary single-file/single-URL
diff --git a/static/js/main.js b/static/js/main.js
index 74901f89..412b8076 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -44,7 +44,7 @@ import { initFooterFit, refitFooter } from "./footerFit.js";
// the All button and the toggle below cannot disagree about it. Only the
// rules above are here.
-// ─── Vocals: All / Lead + Backing toggle (on-demand split, #275) ───
+// ─── Vocals: Combined / Lead + Backing toggle (on-demand split, #275) ───
//
// Only meaningful while Vocals is actually selected above -- hidden
// otherwise so it can't imply a choice that has nothing to act on. Its
diff --git a/static/mobile/app.js b/static/mobile/app.js
index a7cc418b..02fa8342 100644
--- a/static/mobile/app.js
+++ b/static/mobile/app.js
@@ -617,7 +617,7 @@ function extractScreen() {
Vocals
-
+
` : ""}
diff --git a/tests/js/extract-labels.test.mjs b/tests/js/extract-labels.test.mjs
new file mode 100644
index 00000000..4d20d191
--- /dev/null
+++ b/tests/js/extract-labels.test.mjs
@@ -0,0 +1,89 @@
+// No two controls in the Extract row may carry the same word, in any language.
+//
+// The row holds three kinds of control that all sound like a selection: the
+// All beside the chips, which means every stem; the six stem chips; and the
+// vocal mode toggle, which is only about how the vocals are divided. The mode
+// button used to be called "All" too, a few pixels from the other one, so the
+// state most people open the app in showed two buttons with the same label
+// meaning different things. A reporter read them as a duplicate control and
+// asked why there were two (#659).
+//
+// It is now "Combined", and this is what stops it coming back. The risk is not
+// the English string, which is under someone's eye: it is a translator or a
+// future table reaching for the local word for "all" twice, which restores the
+// collision in one language and is invisible to everyone who does not read it.
+// Before the rename, every one of the ten tables had the two keys identical.
+//
+// Run: node tests/js/extract-labels.test.mjs
+
+import { TRANSLATIONS, LANGUAGES } from "../../static/js/i18n.js";
+
+let pass = 0,
+ fail = 0;
+const check = (name, cond, detail = "") => {
+ if (cond) {
+ pass++;
+ console.log(`PASS ${name}`);
+ } else {
+ fail++;
+ console.log(`FAIL ${name}${detail ? " -- " + detail : ""}`);
+ }
+};
+
+// Every label a user can read in that row, in the order they appear.
+const ROW_KEYS = [
+ "extract.all",
+ "stem.vocals",
+ "vocalMode.all",
+ "vocalMode.split",
+ "stem.drums",
+ "stem.bass",
+ "stem.guitar",
+ "stem.piano",
+ "stem.other",
+];
+
+// Regional tables are partial and fall through to the base language, so
+// pt-PT inherits most of its row from pt. Resolving the same way the app does
+// is the point: a collision that only appears after the fallback is still a
+// collision on screen.
+function label(code, key) {
+ const base = code.includes("-") ? code.split("-")[0] : null;
+ const chain = [TRANSLATIONS[code], base ? TRANSLATIONS[base] : null, TRANSLATIONS.en];
+ for (const table of chain) {
+ if (table && table[key] != null) return table[key];
+ }
+ return null;
+}
+
+const codes = LANGUAGES.map((l) => l.code);
+check("every shipped language has a table", codes.every((c) => TRANSLATIONS[c]), codes.join(","));
+
+for (const code of codes) {
+ const seen = new Map();
+ const clashes = [];
+ for (const key of ROW_KEYS) {
+ const text = label(code, key);
+ if (text == null) {
+ clashes.push(`${key} is missing`);
+ continue;
+ }
+ // Case and surrounding space are not what tells two buttons apart.
+ const norm = text.trim().toLowerCase();
+ if (seen.has(norm)) clashes.push(`${seen.get(norm)} and ${key} are both "${text}"`);
+ else seen.set(norm, key);
+ }
+ check(`${code}: every control in the Extract row reads differently`, clashes.length === 0, clashes.join("; "));
+}
+
+// The specific pair the bug was about, stated on its own so a failure names it.
+for (const code of codes) {
+ check(
+ `${code}: the vocal mode is not called what the stem row's All is called`,
+ label(code, "vocalMode.all").trim().toLowerCase() !== label(code, "extract.all").trim().toLowerCase(),
+ `both "${label(code, "extract.all")}"`,
+ );
+}
+
+console.log(`\n${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);
From 467add2fcc850e322d2204aae16f81ff12f348d1 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Wed, 23 Sep 2026 10:37:35 +0100
Subject: [PATCH 2/9] Stop shipping a NUL byte in the stylesheet
The click-track hint reserves its line with a non-breaking space on an empty
::before. The escape for it was written as a literal NUL byte followed by "a0"
rather than as "\00a0", so the stylesheet has carried a control character since
the top bar was reworked.
CSS preprocessing replaces a NUL with U+FFFD, so the declaration has been
rendering a replacement character where the space should be. Nothing looked
wrong because the element is visibility: hidden and any content reserves the
line equally well, which is also why it survived review.
It is not only cosmetic. A NUL makes every text tool treat the file as binary:
grep reports "binary file matches" and prints nothing, and git renders no diff
for it without --text. That is a poor thing to leave in the largest stylesheet
in the repo.
---
static/css/daw.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/static/css/daw.css b/static/css/daw.css
index 23fa1837..c59954c9 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -3171,7 +3171,7 @@ input, textarea { font-family: inherit; }
display: block;
visibility: hidden;
}
-.metro-note:empty::before { content: " a0"; }
+.metro-note:empty::before { content: "\00a0"; }
.metro-note.warn { color: var(--danger); }
/* Export chip button */
From fa25a0bfba2690d9f6838d7d9d53295e08ab05b0 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Wed, 23 Sep 2026 10:41:48 +0100
Subject: [PATCH 3/9] Put the logo in the corner, and let it open the site
The top left was the wordmark, a separator, and a hidden version span, sized to
nothing in particular. It is now the waveform symbol alone, in a cell exactly
as wide as the rail below it and closed by the same border, so the bar proper
begins where the library does.
The width is a variable rather than a number in four places. The rail, the
collapsed sidebar and this cell were all 66px written out separately, which is
three chances for them to stop agreeing.
The symbol is not centred on its own artboard: the bars run x110 to x452 of a
512 box, so their middle sits 25 units right of the middle of the image.
Centring the image would push the bars off the rail's centre line, which is
what it looked like. The 4px offset puts them back on it.
The logo is also a link to stemdeck.app, and an anchor rather than a click
handler on purpose. main.js already intercepts a[target="_blank"] and hands the
URL to the shell through Tauri's open_url; a handler here would have had to
repeat that and would have got the desktop case wrong. There is no second tab
inside the app's webview, so a link left to itself navigates the window away
from StemDeck and strands the user in a web page with no way back. The test for
that is the one a browser-only run would pass while the packaged app broke.
The tooltip is a translated key, not an English string. It is the only label
the logo has, so hardcoding it would make it the one control in the bar that
ignores the language setting.
---
static/css/daw.css | 70 +++++++++++++++--------
static/img/stemdeck-waveform-symbol.svg | 22 +++++++
static/index.html | 21 ++++---
static/js/i18n.js | 10 ++++
tests/e2e/brand-link.spec.mjs | 76 +++++++++++++++++++++++++
5 files changed, 167 insertions(+), 32 deletions(-)
create mode 100644 static/img/stemdeck-waveform-symbol.svg
create mode 100644 tests/e2e/brand-link.spec.mjs
diff --git a/static/css/daw.css b/static/css/daw.css
index c59954c9..76d2b8d6 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -11,6 +11,10 @@ input, textarea { font-family: inherit; }
/* ── Root ── */
.daw {
+ /* The rail's width, as a variable: the topbar's logo cell is sized from the
+ same number, so the divider under the logo is the one the rail already
+ draws, and the bar proper starts where the library does. */
+ --rail-w: 66px;
width: 100vw;
height: 100vh;
background: var(--bg);
@@ -58,36 +62,54 @@ input, textarea { font-family: inherit; }
inside a 77px bar, because align-items: center hid the gap; give the
composer a second row and the box fills the bar edge to edge and touches
the top of the window. */
- padding: 10px 16px;
+ /* No left padding and no vertical padding of its own any more: the brand
+ cell owns the left edge and runs the full height of the bar, so the
+ vertical padding moved onto the composer stack beside it. */
+ padding: 0 16px 0 0;
gap: 14px;
}
+.daw-topbar > .daw-composer-stack { padding: 10px 0; }
-/* Brand */
+/* Logo cell.
+ *
+ * The top-left corner, exactly as wide as the rail below it and closed by the
+ * same border, so the bar proper begins where the library does. It was the
+ * full 390px of the library at first, which put 330px of empty bar beside the
+ * logo and cost the composer the width it was short of. The rail's width is
+ * the part that has something in it, and it does not move when the library
+ * collapses, so the cell needs no transition of its own. */
.daw-brand {
+ align-self: stretch;
+ box-sizing: border-box;
+ width: var(--rail-w);
+ flex-shrink: 0;
display: flex;
align-items: center;
- gap: 9px;
- flex-shrink: 0;
-}
-.daw-brand img { height: 30px; width: auto; display: block; }
-.daw-brand-name {
- font-size: 18px;
- font-weight: 600;
- letter-spacing: -0.015em;
- line-height: 1;
+ border-right: 1px solid var(--border);
+ overflow: hidden;
}
-.daw-brand-name .fg { color: var(--fg); }
-.daw-brand-name .accent{ color: var(--accent); }
-
-.daw-version {
- display: none !important; /* version shown in notification panel instead */
+/* Lined up with the rail buttons below it, which means lined up by the bars,
+ not by the file. The symbol's artboard is not centred on its own artwork:
+ the bars run x110-x452 of a 512 box, so their middle sits 25 units right of
+ the middle of the image. Centring the image would therefore push the bars
+ 2.5px right of the rail's centre line, which is exactly what it looked like.
+ 4px is what puts the bars' middle on 32.5px, the centre of the 65px cell.
+ 52px, not the 40px of a rail button: that same padding means the bars
+ themselves land at about 35px. */
+/* The logo is the link, so the anchor has to be the flex item the cell
+ centres, not an inline box that would add a line box's worth of height
+ around a 52px image. */
+.daw-brand a {
+ display: flex;
+ align-items: center;
+ text-decoration: none;
}
-
-/* Topbar separator */
-.daw-sep {
- width: 1px;
- height: 28px;
- background: var(--border);
+.daw-brand img {
+ width: 52px;
+ height: 52px;
+ margin-left: 4px;
+ margin-right: auto;
+ display: block;
flex-shrink: 0;
}
@@ -519,7 +541,7 @@ input, textarea { font-family: inherit; }
/* Rail */
.sidebar-rail {
- width: 66px;
+ width: var(--rail-w);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
@@ -1195,7 +1217,7 @@ input, textarea { font-family: inherit; }
/* Collapsed sidebar strip */
.cat-collapsed-strip { display: none; }
-.daw.cat-collapsed .sidebar { width: 66px; }
+.daw.cat-collapsed .sidebar { width: var(--rail-w); }
.daw.cat-collapsed .sidebar-body { opacity: 0; pointer-events: none; visibility: hidden; }
.daw.cat-collapsed .cat-collapsed-strip { display: flex; flex-direction: column; gap: 8px; align-items: center; padding: 8px 0; overflow-y: auto; }
diff --git a/static/img/stemdeck-waveform-symbol.svg b/static/img/stemdeck-waveform-symbol.svg
new file mode 100644
index 00000000..cc3dc110
--- /dev/null
+++ b/static/img/stemdeck-waveform-symbol.svg
@@ -0,0 +1,22 @@
+
diff --git a/static/index.html b/static/index.html
index 7fddfc0e..c91c00ab 100644
--- a/static/index.html
+++ b/static/index.html
@@ -17,16 +17,21 @@
diff --git a/static/js/i18n.js b/static/js/i18n.js
index 2d14a61a..3072efae 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -240,6 +240,7 @@ const en = {
"topbar.urlPlaceholder": "Search or drop an audio file…",
"topbar.removeFile": "Remove file",
"topbar.uploadFile": "Upload audio file",
+ "topbar.website": "Open stemdeck.app",
"extract.label": "Extract",
"extract.all": "All",
"extract.moreAria": "Choose stems",
@@ -857,6 +858,7 @@ const pl = {
"topbar.urlPlaceholder": "Szukaj albo upuść plik audio…",
"topbar.removeFile": "Usuń plik",
"topbar.uploadFile": "Wgraj plik audio",
+ "topbar.website": "Otwórz stemdeck.app",
"extract.label": "Wyodrębnij",
"extract.all": "Wszystko",
"extract.moreAria": "Wybierz ścieżki",
@@ -1464,6 +1466,7 @@ const ja = {
"topbar.urlPlaceholder": "検索するか、音声ファイルをドロップ…",
"topbar.removeFile": "ファイルを削除",
"topbar.uploadFile": "音声ファイルをアップロード",
+ "topbar.website": "stemdeck.app を開く",
"extract.label": "抽出",
"extract.all": "すべて",
"extract.moreAria": "抽出するパートを選ぶ",
@@ -2046,6 +2049,7 @@ const zhHans = {
"topbar.urlPlaceholder": "搜索,或拖放音频文件…",
"topbar.removeFile": "移除文件",
"topbar.uploadFile": "上传音频文件",
+ "topbar.website": "打开 stemdeck.app",
"extract.label": "提取",
"extract.all": "全部",
"extract.moreAria": "选择要提取的音轨",
@@ -2628,6 +2632,7 @@ const de = {
"topbar.urlPlaceholder": "Suchen oder eine Audiodatei ablegen…",
"topbar.removeFile": "Datei entfernen",
"topbar.uploadFile": "Audiodatei hochladen",
+ "topbar.website": "stemdeck.app öffnen",
"extract.label": "Extrahieren",
"extract.all": "Alle",
"extract.moreAria": "Stems auswählen",
@@ -3221,6 +3226,7 @@ const pt = {
"topbar.urlPlaceholder": "Pesquise ou solte um arquivo de áudio…",
"topbar.removeFile": "Remover arquivo",
"topbar.uploadFile": "Enviar arquivo de áudio",
+ "topbar.website": "Abrir stemdeck.app",
"extract.label": "Extrair",
"extract.all": "Todos",
"extract.moreAria": "Escolher as faixas",
@@ -3816,6 +3822,7 @@ const id = {
"topbar.urlPlaceholder": "Cari atau seret file audio…",
"topbar.removeFile": "Hapus file",
"topbar.uploadFile": "Unggah file audio",
+ "topbar.website": "Buka stemdeck.app",
"extract.label": "Ekstrak",
"extract.all": "Semua",
"extract.moreAria": "Pilih stem",
@@ -4398,6 +4405,7 @@ const fr = {
"topbar.urlPlaceholder": "Recherchez ou déposez un fichier audio…",
"topbar.removeFile": "Retirer le fichier",
"topbar.uploadFile": "Importer un fichier audio",
+ "topbar.website": "Ouvrir stemdeck.app",
"extract.label": "Extraire",
"extract.all": "Tout",
"extract.moreAria": "Choisir les pistes",
@@ -5109,6 +5117,7 @@ const es = {
"topbar.urlPlaceholder": "Busca o suelta un archivo de audio…",
"topbar.removeFile": "Quitar archivo",
"topbar.uploadFile": "Subir archivo de audio",
+ "topbar.website": "Abrir stemdeck.app",
"extract.label": "Extraer",
"extract.all": "Todo",
"extract.moreAria": "Elegir las pistas",
@@ -5725,6 +5734,7 @@ const ko = {
"topbar.urlPlaceholder": "검색하거나 오디오 파일을 끌어다 놓으세요…",
"topbar.removeFile": "파일 제거",
"topbar.uploadFile": "오디오 파일 업로드",
+ "topbar.website": "stemdeck.app 열기",
"extract.label": "추출",
"extract.all": "전체",
"extract.moreAria": "추출할 스템 선택",
diff --git a/tests/e2e/brand-link.spec.mjs b/tests/e2e/brand-link.spec.mjs
new file mode 100644
index 00000000..ff81fb55
--- /dev/null
+++ b/tests/e2e/brand-link.spec.mjs
@@ -0,0 +1,76 @@
+// The logo opens the site, and on the desktop it opens it outside the app.
+//
+// This is the case a browser test would pass without checking: in a browser,
+// target="_blank" is the whole implementation and there is nothing to get
+// wrong. Inside the Tauri webview there is no second tab to open into, so a
+// link left to its own devices navigates the app window away from StemDeck and
+// leaves the user in a web page with no way back. main.js intercepts
+// a[target="_blank"] and hands the URL to the shell instead, and the logo has
+// to be reached by that path rather than by a click handler of its own.
+import { test, expect } from "@playwright/test";
+import { openStudio } from "./helpers.mjs";
+
+test.describe("the logo links to the site", () => {
+ test("it is an external anchor, not a decoration", async ({ page }) => {
+ await openStudio(page, { tauri: true });
+
+ const link = page.locator(".daw-brand a");
+ await expect(link).toHaveAttribute("href", "https://stemdeck.app");
+ await expect(link).toHaveAttribute("target", "_blank");
+ // Without noopener the opened page gets a handle on this one.
+ await expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ });
+
+ test("on the desktop the click goes to the shell, not the app window", async ({ page }) => {
+ await openStudio(page, { tauri: true });
+
+ const result = await page.evaluate(async () => {
+ const seen = [];
+ const original = window.__TAURI__.core.invoke;
+ window.__TAURI__.core.invoke = (cmd, args) => {
+ seen.push({ cmd, url: args?.url });
+ return original(cmd, args);
+ };
+ document.querySelector(".daw-brand a").click();
+ await new Promise((r) => setTimeout(r, 200));
+ window.__TAURI__.core.invoke = original;
+ return { seen, path: location.pathname };
+ });
+
+ expect(result.seen).toContainEqual({ cmd: "open_url", url: "https://stemdeck.app/" });
+ // And the app is still the thing on screen.
+ expect(result.path).toBe("/");
+ });
+
+ test("its tooltip is translated like every other label", async ({ page }) => {
+ // An icon with no text is only labelled by its title and aria-label, so a
+ // hardcoded English string here would be the one part of the bar that does
+ // not follow the language setting.
+ await page.addInitScript(() =>
+ localStorage.setItem("stemdeck.language", JSON.stringify("de")),
+ );
+ await openStudio(page, { tauri: true });
+
+ const link = page.locator(".daw-brand a");
+ await expect(link).toHaveAttribute("title", "stemdeck.app öffnen");
+ await expect(link).toHaveAttribute("aria-label", "stemdeck.app öffnen");
+ });
+
+ test("the link does not change the shape of the logo cell", async ({ page }) => {
+ // The anchor is a flex item inside a cell that centres it. Left inline it
+ // would carry a line box and push the logo off the rail's centre line.
+ await openStudio(page, { tauri: true });
+
+ const box = await page.evaluate(() => {
+ const cell = document.querySelector(".daw-brand").getBoundingClientRect();
+ const img = document.querySelector(".daw-brand img").getBoundingClientRect();
+ return {
+ above: Math.round(img.top - cell.top),
+ below: Math.round(cell.bottom - img.bottom),
+ height: Math.round(img.height),
+ };
+ });
+ expect(box.height).toBe(52);
+ expect(Math.abs(box.above - box.below)).toBeLessThanOrEqual(1);
+ });
+});
From b1b4154e272a73872d4ea61a08ae9c7288f367cd Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Wed, 23 Sep 2026 10:42:25 +0100
Subject: [PATCH 4/9] Keep the Extract row on one line in every language
The chips fold into a three dots button as the window narrows, so the bar never
gets taller. That was the intent; the row went to two lines anyway, in French
and Polish at 1280, on a stock laptop. English fit, which is why nobody saw it.
Three separate things were wrong, and each on its own was enough.
The row declares flex-wrap: nowrap for itself. A leftover rule from the earlier
composer layout set flex-wrap: wrap one class deeper, so the more specific
selector won and the row never stopped wrapping. That rule was right when it
was written, before folding existed to absorb the overflow. It is gone, and the
comment above it no longer describes a row that wraps.
The fold measured the row before revealing the button, so it kept one chip too
many every time it folded at all. The button takes its width out of the row,
not out of the bar, so the chip that had just been measured as fitting no
longer did. The button is now shown before the second measurement.
Margins were not counted. The Vocals group carries a right margin to set it
apart from Drums, and a bounding rect does not include it, so the sum came up
short by exactly that margin and the last chip sat a pixel past the edge with
the fitter reporting that everything fitted.
And the answer could invalidate itself. The row sits in a grid track sized from
its own content, so folding a chip shrinks the column and can leave the chips
that stayed overflowing a row that genuinely had room for them a moment before.
Chinese at 1024 is where that showed. One pass cannot see it, so it now folds,
looks again, and folds again until the row is honest.
None of this had a test, which is how all three shipped together. There is one
now, and it runs in ten languages at four widths, because English at a wide
window is the state that proves nothing.
---
static/css/daw.css | 15 ++-
static/js/main.js | 68 +++++++++--
tests/e2e/stem-chip-overflow.spec.mjs | 169 ++++++++++++++++++++++++++
3 files changed, 236 insertions(+), 16 deletions(-)
create mode 100644 tests/e2e/stem-chip-overflow.spec.mjs
diff --git a/static/css/daw.css b/static/css/daw.css
index 76d2b8d6..58234fb3 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -4282,17 +4282,20 @@ input, textarea { font-family: inherit; }
}
/* Row two, column one. This is what sizes the column, and therefore the search
- box above it. The chips wrap rather than forcing the row wider than the bar:
- at max-content they are 655px, which on their own overflow a 1100px window
- once the two actions have been paid for. */
+ box above it. min-width: 0 is what lets it be narrower than the chips inside
+ it: at max-content they are 655px, which on their own overflow a 1100px
+ window once the two actions have been paid for.
+
+ The chips used to be allowed to wrap here, which is how that overflow was
+ absorbed before the row could fold. It cannot be allowed now: the rule was
+ three classes deep and quietly outranked the two-class `flex-wrap: nowrap`
+ the folding row declares for itself, so the row kept wrapping to a second
+ line in any language whose words are longer than English (#673). */
.daw-composer > .daw-stem-section {
grid-column: 1;
grid-row: 2;
min-width: 0;
}
-.daw-composer > .daw-stem-section .daw-stem-chips {
- flex-wrap: wrap;
-}
/* The rule stays, but it now separates the search from the two actions on the
same line, which is what it does in the mockup. It used to divide the row
diff --git a/static/js/main.js b/static/js/main.js
index 412b8076..29ce8e61 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -195,29 +195,77 @@ function fitStemChips() {
if (!row || !panel || !btn || !_stemChipOrder) return;
for (const el of _stemChipOrder) row.appendChild(el);
+ // Both measurements below are taken against a row that is not paying for the
+ // button yet, so it starts hidden and is revealed only once the answer is
+ // known to need it.
+ btn.hidden = true;
// Measured after everything is back, so the number is what the row can have
// rather than what it was left with. A hidden row has no width to fit
// anything into, and measuring one would move every chip for nothing.
- const budget = row.clientWidth;
+ let budget = row.clientWidth;
if (!budget) return;
const gap = parseFloat(getComputedStyle(row).gap) || 0;
- const widths = _stemChipOrder.map((el) => el.getBoundingClientRect().width);
+ // A margin is part of what a chip costs the row. The Vocals group carries a
+ // right margin to set it apart from Drums, and a rect is measured without
+ // it, so the sum came up short by exactly that margin and the last chip sat
+ // a pixel past the edge with the fitter reporting everything fitted (#673).
+ const widths = _stemChipOrder.map((el) => {
+ const cs = getComputedStyle(el);
+ return (
+ el.getBoundingClientRect().width +
+ (parseFloat(cs.marginLeft) || 0) +
+ (parseFloat(cs.marginRight) || 0)
+ );
+ });
+ const total = widths.reduce((a, w, i) => a + w + (i ? gap : 0), 0);
+
+ // Half a pixel of rounding is not an overflow; the row is overflow: hidden
+ // and a chip that fits within a pixel is drawn whole.
+ const fits = (used) => used <= budget + 0.5;
- let used = 0;
let keep = _stemChipOrder.length;
- for (let i = 0; i < _stemChipOrder.length; i++) {
- used += widths[i] + (i ? gap : 0);
- // Half a pixel of rounding is not an overflow; the row is overflow: hidden
- // and a chip that fits within a pixel is drawn whole.
- if (used > budget + 0.5) {
- keep = i;
- break;
+ if (!fits(total)) {
+ // Something has to fold, so the button is going to be on screen, and it
+ // takes its width out of this row rather than out of the bar. Deciding
+ // against a budget measured without it is what made the fold stop one chip
+ // short every time it folded at all: the row lost the button's width the
+ // moment the answer was applied, and the chip that had just been measured
+ // as fitting no longer did (#673).
+ btn.hidden = false;
+ budget = row.clientWidth;
+
+ let used = 0;
+ keep = 0;
+ for (let i = 0; i < _stemChipOrder.length; i++) {
+ used += widths[i] + (i ? gap : 0);
+ if (!fits(used)) break;
+ keep = i + 1;
}
}
for (let i = keep; i < _stemChipOrder.length; i++) panel.appendChild(_stemChipOrder[i]);
+ btn.hidden = keep === _stemChipOrder.length;
+
+ // Then look at what that did, because the answer can invalidate itself.
+ //
+ // The row sits in a grid track sized from its own content, so the width it
+ // is given depends on what is in it. Folding a chip out shrinks the column,
+ // which can leave the chips that stayed overflowing a row that genuinely had
+ // room for them when they were measured. A single pass cannot see that, and
+ // Chinese at 1024 is where it showed: five chips folded, and the two left
+ // over hung 4px past an edge that had moved underneath them (#673).
+ //
+ // So fold, look again, fold again. Each turn only removes a chip, and the
+ // counter is the belt to the loop's braces.
+ let guard = _stemChipOrder.length;
+ while (keep > 0 && row.scrollWidth > row.clientWidth + 1 && guard-- > 0) {
+ keep -= 1;
+ btn.hidden = false;
+ panel.insertBefore(_stemChipOrder[keep], panel.firstChild);
+ }
+
btn.hidden = keep === _stemChipOrder.length;
if (btn.hidden) closeStemOverflow();
}
diff --git a/tests/e2e/stem-chip-overflow.spec.mjs b/tests/e2e/stem-chip-overflow.spec.mjs
new file mode 100644
index 00000000..47a970b1
--- /dev/null
+++ b/tests/e2e/stem-chip-overflow.spec.mjs
@@ -0,0 +1,169 @@
+// The Extract row is one line at every width, and folds its tail into a button
+// rather than wrapping.
+//
+// Chips used to wrap to a second line as the window narrowed, which moved
+// everything below them (#668). They now fold from the end into a popover
+// behind a three dots button, and come back on the way out. Nothing tested
+// any of that, and two separate bugs shipped underneath it:
+//
+// - The row declares `flex-wrap: nowrap` for itself, and a leftover rule
+// from the earlier composer layout set `flex-wrap: wrap` one class deeper.
+// The more specific rule won, so the row never stopped wrapping. English
+// fit anyway; French and Polish did not, and went to two lines at 1280
+// (#673).
+// - The fold measured the row before revealing the button, so it kept one
+// chip too many: the button took its width out of the row the moment it
+// appeared, and the chip measured as fitting no longer fit.
+//
+// Both are invisible in English at a wide window, which is the state a
+// maintainer looks at. Hence the languages below.
+import { test, expect } from "@playwright/test";
+import { openStudio } from "./helpers.mjs";
+
+// A chip is 30px. Anything above this is a second line.
+const ONE_LINE = 34;
+
+const row = (page) =>
+ page.evaluate(() => {
+ const el = document.getElementById("stemChips");
+ const btn = document.getElementById("stemMoreBtn");
+ const panel = document.getElementById("stemOverflow");
+ return {
+ height: Math.round(el.getBoundingClientRect().height),
+ // Overflow is hidden, so this is the only way to see a chip cut in half.
+ clipped: Math.round(el.scrollWidth - el.clientWidth),
+ inRow: el.querySelectorAll(".stem-choice, .stem-group").length,
+ folded: panel.querySelectorAll(".stem-choice, .stem-group").length,
+ moreShown: !btn.hidden,
+ };
+ });
+
+async function atWidth(page, width) {
+ await page.setViewportSize({ width, height: 900 });
+ await page.waitForTimeout(400);
+ return row(page);
+}
+
+const openIn = async (page, lang) => {
+ await page.addInitScript(
+ (l) => localStorage.setItem("stemdeck.language", JSON.stringify(l)),
+ lang,
+ );
+ await openStudio(page, { tauri: true });
+};
+
+test.describe("extract row overflow", () => {
+ test("a wide window folds nothing and hides the button", async ({ page }) => {
+ await page.setViewportSize({ width: 1600, height: 900 });
+ await openIn(page, "en");
+
+ const s = await atWidth(page, 1600);
+ expect(s.folded).toBe(0);
+ expect(s.moreShown).toBe(false);
+ expect(s.height).toBeLessThanOrEqual(ONE_LINE);
+ });
+
+ test("narrowing folds from the end, and the row stays one line", async ({ page }) => {
+ await page.setViewportSize({ width: 1600, height: 900 });
+ await openIn(page, "en");
+
+ let previous = 0;
+ for (const width of [1600, 1280, 1100, 1024, 960, 900]) {
+ const s = await atWidth(page, width);
+ expect(s.height, `two lines at ${width}`).toBeLessThanOrEqual(ONE_LINE);
+ // The failure the budget bug produced: the fold ran, and the row was
+ // still a chip wider than the space left over once the button appeared.
+ expect(s.clipped, `a chip is cut off at ${width}`).toBeLessThanOrEqual(1);
+ expect(s.folded, `folding went backwards at ${width}`).toBeGreaterThanOrEqual(previous);
+ if (s.folded > 0) expect(s.moreShown, `nothing offers the folded chips at ${width}`).toBe(true);
+ previous = s.folded;
+ }
+ // Something must have folded by 900, or this test is not exercising it.
+ expect(previous).toBeGreaterThan(0);
+ });
+
+ test("widening gives every chip back", async ({ page }) => {
+ // The fitter used to observe the row it was emptying, so once the row had
+ // collapsed it never saw the window grow again (#671).
+ await page.setViewportSize({ width: 1600, height: 900 });
+ await openIn(page, "en");
+
+ const narrow = await atWidth(page, 900);
+ expect(narrow.folded).toBeGreaterThan(0);
+
+ const wide = await atWidth(page, 1600);
+ expect(wide.folded).toBe(0);
+ expect(wide.moreShown).toBe(false);
+ expect(wide.inRow).toBe(narrow.inRow + narrow.folded);
+ });
+
+ test("it holds one line in every language, not just English", async ({ page }) => {
+ // English is the shortest of the ten and the only one anyone checks by eye.
+ const offenders = [];
+ for (const lang of ["en", "de", "pl", "pt", "fr", "es", "id", "ja", "ko", "zh-Hans"]) {
+ await page.setViewportSize({ width: 1600, height: 900 });
+ await openIn(page, lang);
+ for (const width of [1440, 1280, 1100, 1024]) {
+ const s = await atWidth(page, width);
+ if (s.height > ONE_LINE || s.clipped > 1) {
+ offenders.push({ lang, width, height: s.height, clipped: s.clipped });
+ }
+ }
+ }
+ expect(offenders).toEqual([]);
+ });
+
+ test("a settled row stops moving chips", async ({ page }) => {
+ // The fitter is driven by a ResizeObserver on the composer, and it now
+ // changes layout while it measures: it hides and shows the button, and
+ // folds one more chip at a time until the row is honest. Each of those is
+ // a size change the observer can hear, so a wrong stopping condition is an
+ // infinite loop that pins a core rather than a visible bug.
+ await page.setViewportSize({ width: 1100, height: 900 });
+ await openIn(page, "de");
+ await page.waitForTimeout(800);
+
+ const moves = await page.evaluate(async () => {
+ let n = 0;
+ const seen = new MutationObserver((records) => {
+ n += records.length;
+ });
+ seen.observe(document.getElementById("stemChips"), { childList: true });
+ seen.observe(document.getElementById("stemOverflow"), { childList: true });
+ await new Promise((r) => setTimeout(r, 1500));
+ seen.disconnect();
+ return n;
+ });
+ expect(moves).toBe(0);
+ });
+
+ test("a folded chip still selects its stem", async ({ page }) => {
+ // Folding is a place to put a control, not a way to retire it. The
+ // listeners are bound per chip rather than delegated from the row, which is
+ // what lets a chip keep working somewhere else in the document.
+ await page.setViewportSize({ width: 1600, height: 900 });
+ await openIn(page, "en");
+ await atWidth(page, 900);
+
+ const pressed = () =>
+ page.evaluate(() =>
+ [...document.querySelectorAll(".stem-choice[data-stem]")]
+ .filter((b) => b.getAttribute("aria-pressed") === "true")
+ .map((b) => b.dataset.stem)
+ .sort(),
+ );
+
+ const chip = page.locator("#stemOverflow .stem-choice[data-stem]").first();
+ const stem = await chip.getAttribute("data-stem");
+ const before = await pressed();
+
+ await page.locator("#stemMoreBtn").click();
+ await chip.click();
+
+ // Pressing a chip from the all-selected default means "only this one", so
+ // what is asserted is that the press was heard, not that it toggled.
+ const after = await pressed();
+ expect(after).not.toEqual(before);
+ expect(after).toContain(stem);
+ });
+});
From d016ed53611985d9f6a68dfbac61e7153ba0f647 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Wed, 23 Sep 2026 10:44:10 +0100
Subject: [PATCH 5/9] Make Combined and Lead + Backing the control for the
vocals
Vocals is the one stem with something to say about how it comes out, so the
row has three states for it where every other chip has two: as one track,
split into lead and backing, or not extracted. The two mode buttons carry all
three. Neither lit is what "no vocals" looks like, and the chip agrees with
them.
Before this the pair was a consequence of the chip rather than a control. It
was hidden until Vocals was pressed, which made the group change shape every
time that chip was pressed, and made saying how you want the vocals split
something you could only do after asking for them. Dimming it instead of
hiding it fixed the shape and left the ordering exactly as wrong.
So: pressing the mode already in force switches vocals off, pressing the other
switches mode and leaves them on, and pressing either from cold switches them
on in that mode. The chip is still a way in and the shorter one, switching
vocals on as Combined without a second press. The mode is remembered while the
vocals are off, so the buttons return to the way they were last asked for; the
chip does not restore it, because that press does not name a mode.
Two things came out of the same work.
Pressing the last remaining chip used to turn all six stems back on. The set
emptied and a guard caught it and refilled it, so narrowing down to one stem
and pressing it again produced the most there is, which is the opposite of
what the press asked for. An empty row does not need preventing here: All
reaches it in one press and is the control whose job that is.
And an empty row could be submitted, which was worse than it looked. The
server reads an empty stems list as every stem (app/api/jobs.py), so a submit
in that state would have extracted the six stems the row was showing as off.
Split is disabled while nothing is selected rather than the selection being
quietly rewritten underneath.
Painting stays in one place. The mode buttons used to set their own
aria-pressed from the click handler, which is the shape that put All a state
behind its own chips in #658: the painter reachable by a click is never the
one that is wrong.
---
static/css/daw.css | 12 +--
static/index.html | 12 ++-
static/js/main.js | 65 ++++++++++++----
static/js/stemChoice.js | 38 +++++++---
tests/e2e/extract-row.spec.mjs | 135 +++++++++++++++++++++++++++++++--
5 files changed, 222 insertions(+), 40 deletions(-)
diff --git a/static/css/daw.css b/static/css/daw.css
index 58234fb3..71ab4f1c 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -340,7 +340,6 @@ input, textarea { font-family: inherit; }
border-radius: 6px;
overflow: hidden;
}
-.vocal-mode-toggle.hidden { display: none; }
.vocal-mode-btn {
height: 100%;
padding: 0 10px;
@@ -4740,11 +4739,8 @@ input, textarea { font-family: inherit; }
gap: 0;
margin-right: 5px;
}
-.stem-group:not(:has(.vocal-mode-toggle:not(.hidden))) {
- margin-right: 0;
-}
/* Facing edges square, and only one border between them. */
-.stem-group:has(.vocal-mode-toggle:not(.hidden)) .stem-choice[data-stem="vocals"] {
+.stem-group .stem-choice[data-stem="vocals"] {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
@@ -4764,9 +4760,9 @@ input, textarea { font-family: inherit; }
* selected, the active mode is tinted because it is the mode, and the other
* one is plain.
*
- * Keyed off the chip's pressed state, because the toggle is only on screen
- * while Vocals is selected and should not be left as the one thing still
- * wearing the colour if that ever changes. */
+ * Keyed off the chip's pressed state rather than on the toggle being present,
+ * because it is always present: with Vocals off, the group drops back to the
+ * plain border and the toggle dims beside it. */
.stem-group:has(.stem-choice[data-stem="vocals"][aria-pressed="true"]) .vocal-mode-toggle {
--color: var(--vocals);
border-color: color-mix(in srgb, var(--color) 60%, transparent);
diff --git a/static/index.html b/static/index.html
index c91c00ab..0fc0d807 100644
--- a/static/index.html
+++ b/static/index.html
@@ -101,13 +101,19 @@
Loose in the row, the mode buttons read as peers of Drums and
Bass rather than as a choice about the chip beside them. -->
+
-
-
+
diff --git a/static/js/main.js b/static/js/main.js
index 29ce8e61..f245bff5 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -46,20 +46,42 @@ import { initFooterFit, refitFooter } from "./footerFit.js";
// ─── Vocals: Combined / Lead + Backing toggle (on-demand split, #275) ───
//
-// Only meaningful while Vocals is actually selected above -- hidden
-// otherwise so it can't imply a choice that has nothing to act on. Its
-// visibility rides along with the stem chips in refreshStemChoiceVisuals,
-// since it describes the same selection they do.
+// Two buttons, three states, and either of them can switch the vocals on.
+//
+// Vocals is the one stem with something to say about how it comes out, so the
+// pair carries a state the other chips do not have: as one track, split into
+// lead and backing, or not extracted at all. Neither button lit is that third
+// state, and the chip beside them agrees with it. Pressing the mode already in
+// force switches vocals off; pressing the other switches mode and leaves them
+// on; pressing either from cold switches them on in that mode.
+//
+// The chip is a way in as well, and the shorter one: it switches vocals on as
+// Combined without a second press. The modes were hidden, and later dimmed,
+// until that chip had been pressed, which made saying how you want the vocals
+// split something you could only do after asking for them.
+//
+// Which of the two is lit is decided in refreshStemChoiceVisuals, along with
+// the chips. Painting from here as well is what put the All button a state
+// behind its own chips once already (#658): two painters for one fact, and the
+// one reachable by a click is never the one that is wrong.
function wireVocalModeToggle() {
const wrap = document.getElementById("vocalModeToggle");
if (!wrap) return;
for (const btn of wrap.querySelectorAll(".vocal-mode-btn")) {
btn.addEventListener("click", () => {
- setVocalSplitMode(btn.dataset.mode);
- for (const b of wrap.querySelectorAll(".vocal-mode-btn")) {
- b.setAttribute("aria-pressed", String(b.dataset.mode === btn.dataset.mode));
+ const mode = btn.dataset.mode;
+ if (selectedStems.has("vocals") && vocalSplitMode === mode) {
+ // Pressing the mode that is already in force is how vocals are turned
+ // off, the same way pressing any other lit chip switches that stem off.
+ selectedStems.delete("vocals");
+ } else {
+ setVocalSplitMode(mode);
+ selectedStems.add("vocals");
}
+ saveSelectedStems();
+ refreshStemChoiceVisuals();
+ buildStripStems();
});
}
}
@@ -135,19 +157,34 @@ function wireAutoSectionsToggle() {
function handleStemChoiceClick(stem) {
const allSelected = selectedStems.size === STEM_NAMES.length;
+ const hadVocals = selectedStems.has("vocals");
if (allSelected) {
// Default state -> switch to "only this stem".
selectedStems.clear();
selectedStems.add(stem);
} else if (selectedStems.has(stem)) {
+ // A press that says "not this one" is answered with exactly that, even
+ // when it is the last one lit.
+ //
+ // Emptying the set used to refill it with all six, so narrowing down to
+ // Vocals and pressing it once more turned everything back on: the opposite
+ // of what was asked, and no way to switch that stem off at all. Refusing
+ // the press instead is no better, because then the last stem cannot be
+ // turned off either.
+ //
+ // An empty row is not a state that needs preventing here. All already
+ // reaches it, in one press, and it is the control whose job that is.
selectedStems.delete(stem);
- if (selectedStems.size === 0) {
- // Empty out wraps back to "all" so the user is never stuck.
- for (const n of STEM_NAMES) selectedStems.add(n);
- }
} else {
selectedStems.add(stem);
}
+
+ // Vocals switched on from its own chip has not been told which of the two
+ // ways it should come out, so it takes Combined. Only on the transition: a
+ // press that narrows an already-selected set down to Vocals is not a change
+ // of mind about the mode, and the mode buttons set their own.
+ if (!hadVocals && selectedStems.has("vocals")) setVocalSplitMode("all");
+
saveSelectedStems();
refreshStemChoiceVisuals();
buildStripStems();
@@ -436,10 +473,10 @@ onLanguageChange(fitStemChips);
await runStoreMigrationIfNeeded();
await stemSelectionReady;
refreshStemChoiceVisuals();
+ // Both halves of the Extract row's state arrive asynchronously, so the row is
+ // painted once more when the second of them lands.
await vocalSplitModeReady;
- for (const b of document.querySelectorAll(".vocal-mode-btn")) {
- b.setAttribute("aria-pressed", String(b.dataset.mode === vocalSplitMode));
- }
+ refreshStemChoiceVisuals();
// Before initCatalog: it runs the update check, which can itself notify.
// collectDiagnostics is injected rather than imported by notifications.js,
// which would make the two modules import each other.
diff --git a/static/js/stemChoice.js b/static/js/stemChoice.js
index a5dfb0a2..b9b57f7e 100644
--- a/static/js/stemChoice.js
+++ b/static/js/stemChoice.js
@@ -1,9 +1,10 @@
// The Extract row's visual state, in one place.
//
// Three controls describe the same fact, "which stems will be extracted": the
-// six stem chips, the All button beside them, and the Lead + Backing toggle
-// that is only meaningful while Vocals is among them. All three are derived
-// from `selectedStems` and none of them stores anything of its own.
+// six stem chips, the All button beside them, and the Combined / Lead +
+// Backing toggle that is only meaningful while Vocals is among them. All
+// three are painted from `selectedStems` and `vocalSplitMode`, and none of
+// them stores anything of its own.
//
// They used to be refreshed by whoever happened to change the selection, and
// the paths did not agree. The All button was synced from a closure inside
@@ -18,7 +19,7 @@
// same function. main.js imports catalog.js, so catalog.js cannot import back
// into main.js; a shared module is what lets the two of them agree without a
// cycle.
-import { selectedStems } from "./state.js";
+import { selectedStems, vocalSplitMode } from "./state.js";
import { STEM_NAMES } from "./constants.js";
/**
@@ -39,10 +40,27 @@ export function refreshStemChoiceVisuals() {
.getElementById("stemAllBtn")
?.setAttribute("aria-pressed", String(selectedStems.size === STEM_NAMES.length));
- // Lead + Backing has nothing to act on without vocals, so it is hidden
- // rather than left implying a choice. Same staleness applied here: a
- // restored selection without vocals used to leave it on screen.
- document
- .getElementById("vocalModeToggle")
- ?.classList.toggle("hidden", !selectedStems.has("vocals"));
+ // Nothing selected is a real state the row can be left in, and the server
+ // reads an empty list as "all of them" (app/api/jobs.py). Submitting from
+ // here would therefore extract six stems the row says it is not extracting,
+ // so the button is closed rather than the selection quietly rewritten.
+ const submit = document.getElementById("submit");
+ if (submit) submit.disabled = selectedStems.size === 0;
+
+ // Combined / Lead + Backing carries the whole of the vocals decision, so
+ // between them they have three states and not two: one of them lit, the
+ // other lit, or neither. Neither is what "vocals are not being extracted"
+ // looks like, and the chip beside them follows from the same fact above.
+ //
+ // The mode itself is remembered while they are both dark, so switching the
+ // vocals back on returns to the way they were last asked for.
+ const wrap = document.getElementById("vocalModeToggle");
+ if (!wrap) return;
+ const hasVocals = selectedStems.has("vocals");
+ for (const btn of wrap.querySelectorAll(".vocal-mode-btn")) {
+ btn.setAttribute(
+ "aria-pressed",
+ String(hasVocals && btn.dataset.mode === vocalSplitMode),
+ );
+ }
}
diff --git a/tests/e2e/extract-row.spec.mjs b/tests/e2e/extract-row.spec.mjs
index 1d4fbec4..237347f0 100644
--- a/tests/e2e/extract-row.spec.mjs
+++ b/tests/e2e/extract-row.spec.mjs
@@ -22,7 +22,11 @@ const row = (page) =>
]),
),
all: document.getElementById("stemAllBtn")?.getAttribute("aria-pressed") === "true",
- vocalToggleShown: !document.getElementById("vocalModeToggle")?.classList.contains("hidden"),
+ submitDisabled: !!document.getElementById("submit")?.disabled,
+ vocalToggleShown: !!document.getElementById("vocalModeToggle")?.offsetParent,
+ vocalModePressed: [...document.querySelectorAll(".vocal-mode-btn")]
+ .filter((b) => b.getAttribute("aria-pressed") === "true")
+ .map((b) => b.dataset.mode),
}));
// Puts a selection in the store the way an earlier session would have left it,
@@ -60,17 +64,138 @@ test.describe("extract row", () => {
expect((await row(page)).all).toBe(true);
});
- test("Lead + Backing is hidden when the restored selection has no vocals", async ({ page }) => {
- // Same staleness, other control: it used to stay on screen offering a
- // choice about vocals that were not being extracted.
+ test("with no vocals selected, neither mode is lit", async ({ page }) => {
+ // The pair carries three states, not two, and this is the third. Both dark
+ // is what "vocals are not being extracted" looks like.
await openWithStoredSelection(page, ["drums", "bass"]);
const state = await row(page);
expect(state.chips.vocals).toBe(false);
- expect(state.vocalToggleShown).toBe(false);
+ expect(state.vocalToggleShown).toBe(true);
+ expect(state.vocalModePressed).toEqual([]);
expect(state.all).toBe(false);
});
+ test("pressing a mode switches the vocals on", async ({ page }) => {
+ await openWithStoredSelection(page, ["drums", "bass"]);
+ expect((await row(page)).chips.vocals).toBe(false);
+
+ await page.locator('.vocal-mode-btn[data-mode="split"]').click();
+
+ const state = await row(page);
+ expect(state.chips.vocals).toBe(true);
+ expect(state.vocalModePressed).toEqual(["split"]);
+ // And it takes nothing else with it.
+ expect(state.chips.drums).toBe(true);
+ expect(state.chips.bass).toBe(true);
+ expect(state.chips.piano).toBe(false);
+ });
+
+ test("pressing the mode that is already on switches the vocals off", async ({ page }) => {
+ await openWithStoredSelection(page, ["vocals", "drums"]);
+ expect((await row(page)).vocalModePressed).toEqual(["all"]);
+
+ await page.locator('.vocal-mode-btn[data-mode="all"]').click();
+
+ const state = await row(page);
+ expect(state.chips.vocals).toBe(false);
+ expect(state.vocalModePressed).toEqual([]);
+ expect(state.chips.drums).toBe(true);
+ });
+
+ test("switching between the two modes keeps the vocals on", async ({ page }) => {
+ await openWithStoredSelection(page, ["vocals", "drums"]);
+
+ await page.locator('.vocal-mode-btn[data-mode="split"]').click();
+ let state = await row(page);
+ expect(state.chips.vocals).toBe(true);
+ expect(state.vocalModePressed).toEqual(["split"]);
+
+ await page.locator('.vocal-mode-btn[data-mode="all"]').click();
+ state = await row(page);
+ expect(state.chips.vocals).toBe(true);
+ expect(state.vocalModePressed).toEqual(["all"]);
+ });
+
+ test("the mode is remembered while the vocals are off", async ({ page }) => {
+ await openWithStoredSelection(page, ["vocals", "drums"]);
+ await page.locator('.vocal-mode-btn[data-mode="split"]').click();
+
+ // Off, then on again, through the same button.
+ await page.locator('.vocal-mode-btn[data-mode="split"]').click();
+ expect((await row(page)).chips.vocals).toBe(false);
+
+ await page.locator('.vocal-mode-btn[data-mode="split"]').click();
+ const state = await row(page);
+ expect(state.chips.vocals).toBe(true);
+ expect(state.vocalModePressed).toEqual(["split"]);
+ });
+
+ test("the Vocals chip switches them on as Combined", async ({ page }) => {
+ // The short way in. A press here says nothing about how the vocals should
+ // come out, so it takes the mode that means "leave them alone".
+ await openWithStoredSelection(page, ["drums", "bass"]);
+ expect((await row(page)).vocalModePressed).toEqual([]);
+
+ await page.locator('.stem-choice[data-stem="vocals"]').click();
+
+ const state = await row(page);
+ expect(state.chips.vocals).toBe(true);
+ expect(state.vocalModePressed).toEqual(["all"]);
+ });
+
+ test("the Vocals chip switches them off again", async ({ page }) => {
+ await openWithStoredSelection(page, ["vocals", "drums"]);
+
+ await page.locator('.stem-choice[data-stem="vocals"]').click();
+
+ const state = await row(page);
+ expect(state.chips.vocals).toBe(false);
+ expect(state.vocalModePressed).toEqual([]);
+ expect(state.chips.drums).toBe(true);
+ });
+
+ test("Combined is what the chip means, even after Lead + Backing", async ({ page }) => {
+ // The mode buttons remember their choice across an off and on of their own.
+ // The chip does not restore it, because pressing the chip is not asking for
+ // it: it is the press that does not name a mode.
+ await openWithStoredSelection(page, ["vocals", "drums"]);
+ await page.locator('.vocal-mode-btn[data-mode="split"]').click();
+ await page.locator('.vocal-mode-btn[data-mode="split"]').click();
+ expect((await row(page)).chips.vocals).toBe(false);
+
+ await page.locator('.stem-choice[data-stem="vocals"]').click();
+ expect((await row(page)).vocalModePressed).toEqual(["all"]);
+ });
+
+ test("the last remaining stem can still be switched off", async ({ page }) => {
+ // Narrowing to one stem and pressing it again used to turn all six back
+ // on: the set emptied, and an "if nothing is selected, select everything"
+ // guard caught it. The press asked for less and got the most there is.
+ await openWithStoredSelection(page, ["vocals"]);
+
+ await page.locator('.vocal-mode-btn[data-mode="all"]').click();
+
+ const state = await row(page);
+ expect(state.chips.vocals).toBe(false);
+ expect(Object.values(state.chips).filter(Boolean)).toHaveLength(0);
+ expect(state.all).toBe(false);
+ });
+
+ test("an empty row cannot be submitted", async ({ page }) => {
+ // The server reads an empty stems list as every stem, so a submit from
+ // here would extract six of them while the row showed none. The row is
+ // allowed to be empty; it just cannot be acted on.
+ await openWithStoredSelection(page, ["vocals"]);
+ expect((await row(page)).submitDisabled).toBe(false);
+
+ await page.locator('.vocal-mode-btn[data-mode="all"]').click();
+ expect((await row(page)).submitDisabled).toBe(true);
+
+ await page.locator('.stem-choice[data-stem="drums"]').click();
+ expect((await row(page)).submitDisabled).toBe(false);
+ });
+
test("pressing All still fills and empties the row", async ({ page }) => {
await openWithStoredSelection(page, ["vocals", "drums"]);
From 789938ed3fea5c8e106bc9aa60e487a5032d7542 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Wed, 23 Sep 2026 11:24:25 +0100
Subject: [PATCH 6/9] Give the phone the same vocals control as the studio
The Extract screen had the arrangement the studio has just moved away from:
the Combined / Lead + Backing pair was not rendered at all until the Vocals
chip was on, pressing a mode did not select vocals, and pressing the lit one
did not switch them off.
Leaving it there would be worse than either behaviour on its own. The same
control would answer the same question differently depending on which screen
it was on, and the phone is the half nobody checks by hand.
So the pair is always on screen, a mode is lit only while vocals are actually
selected, and the three presses behave as they do in the studio: the mode in
force switches vocals off, the other switches mode and leaves them on, either
one from cold switches them on in that mode. The chip switches them on as
Combined, because that press does not name a mode.
The empty-selection trap is here too, and it is the same trap: the server
reads an empty stems list as every stem, so the button would have extracted
six stems the screen was showing as off. It is disabled instead.
This screen had no test but the transpose file, which is how it sat a design
behind without anything noticing.
---
static/mobile/app.js | 33 +++++++--
tests/e2e/mobile-vocal-mode.spec.mjs | 106 +++++++++++++++++++++++++++
2 files changed, 133 insertions(+), 6 deletions(-)
create mode 100644 tests/e2e/mobile-vocal-mode.spec.mjs
diff --git a/static/mobile/app.js b/static/mobile/app.js
index 02fa8342..fee7d950 100644
--- a/static/mobile/app.js
+++ b/static/mobile/app.js
@@ -100,6 +100,16 @@ const state = {
vocalSplitMode: "all",
};
+/**
+ * Is anything going to come out of a split.
+ *
+ * The server reads an empty stems list as every stem (app/api/jobs.py), so
+ * submitting from an empty Extract screen would extract the six stems the
+ * screen is showing as off. The button is closed rather than the selection
+ * being rewritten underneath.
+ */
+const anyStemSelected = () => Object.values(state.selected).some(Boolean);
+
let extractES = null; // EventSource for the active extraction
let extractPoll = null; // REST poll fallback timer
@@ -613,15 +623,14 @@ function extractScreen() {
const onStyle = on ? `border-color:${s.color};background:${s.color}1c;` : "";
return ``;
}).join("")}
- ${state.selected.vocals ? `
Vocals
-
-
+
+
-
` : ""}
-
+
+
${extractProgressCard()}
`;
@@ -990,9 +999,21 @@ app.addEventListener("click", (e) => {
break;
case "chip":
state.selected[t.dataset.id] = !state.selected[t.dataset.id];
+ // Vocals switched on from its own chip has not been told which of the
+ // two ways it should come out, so it takes Combined, as on the desktop.
+ if (t.dataset.id === "vocals" && state.selected.vocals) state.vocalSplitMode = "all";
break;
case "vocalmode":
- state.vocalSplitMode = t.dataset.mode;
+ // The pair is the vocals control, not a setting that waits for the chip.
+ // Pressing the mode already in force switches vocals off; pressing the
+ // other switches mode and leaves them on; either one from cold switches
+ // them on in that mode.
+ if (state.selected.vocals && state.vocalSplitMode === t.dataset.mode) {
+ state.selected.vocals = false;
+ } else {
+ state.vocalSplitMode = t.dataset.mode;
+ state.selected.vocals = true;
+ }
break;
case "qual":
state.quality = t.dataset.q;
diff --git a/tests/e2e/mobile-vocal-mode.spec.mjs b/tests/e2e/mobile-vocal-mode.spec.mjs
new file mode 100644
index 00000000..1a0dc425
--- /dev/null
+++ b/tests/e2e/mobile-vocal-mode.spec.mjs
@@ -0,0 +1,106 @@
+// The phone's Extract screen answers the vocals question the same way the
+// studio does.
+//
+// Vocals is the one stem with something to say about how it comes out, so the
+// Combined / Lead + Backing pair carries three states: one lit, the other lit,
+// or neither, which is what "no vocals" looks like. Either button switches the
+// vocals on.
+//
+// This screen had the older arrangement, where the pair was a consequence of
+// the Vocals chip rather than a control: it was not rendered at all until the
+// chip was on, pressing a mode did not select vocals, and pressing the lit one
+// did not switch them off. Two UIs disagreeing about what a control does is
+// worse than either behaviour on its own, and nothing here would have caught
+// it, since this screen is only covered by the transpose file.
+import { test, expect } from "@playwright/test";
+
+const modeBtn = (page, mode) => page.locator(`[data-action="vocalmode"][data-mode="${mode}"]`);
+const chip = (page, id) => page.locator(`[data-action="chip"][data-id="${id}"]`);
+const cta = (page) => page.locator('[data-action="split"]');
+
+const on = (locator) => locator.evaluate((el) => el.classList.contains("on"));
+
+async function openExtract(page) {
+ await page.goto("/?ui=mobile", { waitUntil: "domcontentloaded" });
+ await page.locator('[data-action="tab"][data-tab="extract"]').first().click();
+ await modeBtn(page, "all").first().waitFor({ timeout: 20000 });
+}
+
+test.describe("phone: the vocal mode", () => {
+ test("the pair is on screen before anything is pressed", async ({ page }) => {
+ // It used to render only once the Vocals chip was on, so the way to say
+ // how you wanted the vocals split was hidden until you had asked for them.
+ await openExtract(page);
+
+ await expect(modeBtn(page, "all")).toBeVisible();
+ await expect(modeBtn(page, "split")).toBeVisible();
+ expect(await on(modeBtn(page, "all"))).toBe(true);
+ expect(await on(modeBtn(page, "split"))).toBe(false);
+ });
+
+ test("pressing the mode already in force switches the vocals off", async ({ page }) => {
+ await openExtract(page);
+ expect(await on(chip(page, "vocals"))).toBe(true);
+
+ await modeBtn(page, "all").click();
+
+ expect(await on(chip(page, "vocals"))).toBe(false);
+ // Neither lit is the third state, not a missing one.
+ expect(await on(modeBtn(page, "all"))).toBe(false);
+ expect(await on(modeBtn(page, "split"))).toBe(false);
+ await expect(modeBtn(page, "all")).toBeVisible();
+ // And it takes nothing else with it.
+ expect(await on(chip(page, "drums"))).toBe(true);
+ });
+
+ test("pressing a mode from cold switches the vocals on", async ({ page }) => {
+ await openExtract(page);
+ await modeBtn(page, "all").click();
+ expect(await on(chip(page, "vocals"))).toBe(false);
+
+ await modeBtn(page, "split").click();
+
+ expect(await on(chip(page, "vocals"))).toBe(true);
+ expect(await on(modeBtn(page, "split"))).toBe(true);
+ expect(await on(modeBtn(page, "all"))).toBe(false);
+ });
+
+ test("switching between the two keeps the vocals on", async ({ page }) => {
+ await openExtract(page);
+
+ await modeBtn(page, "split").click();
+ expect(await on(chip(page, "vocals"))).toBe(true);
+ expect(await on(modeBtn(page, "split"))).toBe(true);
+
+ await modeBtn(page, "all").click();
+ expect(await on(chip(page, "vocals"))).toBe(true);
+ expect(await on(modeBtn(page, "all"))).toBe(true);
+ });
+
+ test("the Vocals chip switches them on as Combined", async ({ page }) => {
+ await openExtract(page);
+ await modeBtn(page, "split").click();
+ await chip(page, "vocals").click();
+ expect(await on(chip(page, "vocals"))).toBe(false);
+
+ await chip(page, "vocals").click();
+
+ // A press on the chip does not name a mode, so it means Combined.
+ expect(await on(chip(page, "vocals"))).toBe(true);
+ expect(await on(modeBtn(page, "all"))).toBe(true);
+ });
+
+ test("an empty screen cannot be submitted", async ({ page }) => {
+ // The server reads an empty stems list as every stem, so this would have
+ // extracted six stems the screen was showing as off.
+ await openExtract(page);
+ await expect(cta(page)).toBeEnabled();
+
+ for (const id of ["drums", "bass", "guitar", "piano", "other"]) {
+ if (await on(chip(page, id))) await chip(page, id).click();
+ }
+ await modeBtn(page, "all").click();
+
+ await expect(cta(page)).toBeDisabled();
+ });
+});
From cfa84a2ddc06db47321ac8a7049938facdeaff9a Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Wed, 23 Sep 2026 11:32:18 +0100
Subject: [PATCH 7/9] Stop the favourite heart from starting an extraction
The composer is a form, and a button inside a form with no explicit type is a
submit button. The heart has never had one. It was harmless for as long as it
lived in the footer, outside the form; the top bar rework moved the now-playing
block up into the composer and put the heart inside it without touching the
button itself, which is why the diff that broke this does not mention it.
So pressing the heart favourited the track and started a split.
The one-line fix is type="button". The guard is the part worth having: the
composer is where controls keep being added, and the failure is silent because
the button does its own job perfectly well while also doing this one. The test
asserts that no button in the form is left to default, and that the only submit
in it is the one that splits stems, so it covers the next control added to that
bar rather than only this one.
Removing the attribute again fails three of the four tests, including the one
that watches for the submit event.
---
static/index.html | 6 ++-
tests/e2e/job-form-submit.spec.mjs | 68 ++++++++++++++++++++++++++++++
2 files changed, 73 insertions(+), 1 deletion(-)
create mode 100644 tests/e2e/job-form-submit.spec.mjs
diff --git a/static/index.html b/static/index.html
index 0fc0d807..42640ef5 100644
--- a/static/index.html
+++ b/static/index.html
@@ -182,7 +182,11 @@