diff --git a/static/css/daw.css b/static/css/daw.css
index 1ebcab65..71ab4f1c 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;
}
@@ -310,7 +332,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;
@@ -318,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;
@@ -519,7 +540,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 +1216,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; }
@@ -3171,7 +3192,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 */
@@ -4260,17 +4281,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
@@ -4715,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;
}
@@ -4739,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/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 fbd2260b..42640ef5 100644
--- a/static/index.html
+++ b/static/index.html
@@ -17,16 +17,21 @@
@@ -990,9 +989,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/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);
+ });
+});
diff --git a/tests/e2e/extract-row.spec.mjs b/tests/e2e/extract-row.spec.mjs
index 1d4fbec4..b2c31600 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,154 @@ 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("a chip pressed during a submit does not reopen the button", async ({ page }) => {
+ // setSubmitProcessing holds Split shut while a submit is in flight, which
+ // for a large upload is the whole upload. The row repaints the same button
+ // on every chip press, and used to reopen it, allowing a second submit.
+ await openWithStoredSelection(page, ["vocals", "drums"]);
+
+ await page.evaluate(() => {
+ const b = document.getElementById("submit");
+ b.disabled = true;
+ b.classList.add("loading");
+ });
+ await page.locator('.stem-choice[data-stem="bass"]').click();
+
+ expect((await row(page)).submitDisabled).toBe(true);
+ });
+
test("pressing All still fills and empties the row", async ({ page }) => {
await openWithStoredSelection(page, ["vocals", "drums"]);
diff --git a/tests/e2e/job-form-submit.spec.mjs b/tests/e2e/job-form-submit.spec.mjs
new file mode 100644
index 00000000..2b7f3407
--- /dev/null
+++ b/tests/e2e/job-form-submit.spec.mjs
@@ -0,0 +1,68 @@
+// Only the Split button submits the job form.
+//
+// The composer is a