From 175872a24eb54763933362b304e12d4ad359f421 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 22 Sep 2026 21:49:28 +0100
Subject: [PATCH 1/3] Drop "All" from the Collapse row
It governed two panels by the end, Analysis and Sections, so one press saved
exactly one press. And it was a third control in this bar wearing that word,
next to a row where "All" already means every stem and, inside that, keeping
the vocals as one track.
What it existed to prove still holds and is now the row's only job: whatever
this row collapses, it can bring back. Each panel has its own button, which is
the only way back from a collapsed one, and the library is still not among them
(#588).
Its wiring goes with it: wireAllToggle, the MutationObserver it kept on .app to
stay honest about a state it no longer reports, and the panels.all and
panels.allTitle keys across all ten tables.
The spec is rewritten around what survives rather than patched: the row is the
panels it governs and nothing else, every panel it puts away it can bring back,
it leaves the library alone, collapsing the library does not disturb it, and a
collapsed panel survives a reload.
---
static/index.html | 32 ++++------
static/js/i18n.js | 40 ------------
static/js/ui-chrome.js | 46 --------------
tests/e2e/panel-toggles.spec.mjs | 102 +++++++++++++++----------------
4 files changed, 62 insertions(+), 158 deletions(-)
diff --git a/static/index.html b/static/index.html
index ac1ffaa5..590e100c 100644
--- a/static/index.html
+++ b/static/index.html
@@ -193,26 +193,20 @@
stub costs most of what collapsing the smaller ones returns. -->
+ after it: everything to its right is a button. The label is one
+ word because the row has no width for a sentence.
+
+ There was an "All" here, one press to put every panel away. It
+ governed two panels by the end, so it saved a single press, and
+ it was another control in this bar wearing a word that already
+ means something else two rows up. The panels it governed each
+ have their own button, which is the only way back from a
+ collapsed one.
+
+ The library was never among them, and not by omission: it used to
+ be, and nothing in this row could put it back (#588). Its control
+ lives in the rail. -->
Collapse
-
- All
-
-
-
Analysis
diff --git a/static/js/i18n.js b/static/js/i18n.js
index 6d5239bf..323f65d8 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -345,11 +345,7 @@ const en = {
"panels.timelineTitle": "Show or hide the timeline",
- "panels.all": "All",
-
"panels.collapse": "Collapse",
-
- "panels.allTitle": "Show or hide every panel",
"mixer.hint": "Drag fader · M/S",
"stemsPanel.ariaLabel": "Stems",
@@ -964,11 +960,7 @@ const pl = {
"panels.timelineTitle": "Pokaż lub ukryj oś czasu",
- "panels.all": "Wszystko",
-
"panels.collapse": "Zwiń",
-
- "panels.allTitle": "Pokaż lub ukryj wszystkie panele",
"mixer.hint": "Przeciągnij suwak · M/S",
"stemsPanel.ariaLabel": "Ścieżki",
@@ -1573,11 +1565,7 @@ const ja = {
"panels.timelineTitle": "タイムラインの表示を切り替えます",
- "panels.all": "すべて",
-
"panels.collapse": "折りたたむ",
-
- "panels.allTitle": "すべてのパネルの表示を切り替えます",
"mixer.hint": "フェーダーをドラッグ · M/S",
"stemsPanel.ariaLabel": "パート",
@@ -2157,11 +2145,7 @@ const zhHans = {
"panels.timelineTitle": "显示或隐藏时间轴",
- "panels.all": "全部",
-
"panels.collapse": "折叠",
-
- "panels.allTitle": "显示或隐藏所有面板",
"mixer.hint": "拖动推子 · M/S",
"stemsPanel.ariaLabel": "音轨",
@@ -2741,11 +2725,7 @@ const de = {
"panels.timelineTitle": "Zeitleiste ein- oder ausblenden",
- "panels.all": "Alle",
-
"panels.collapse": "Einklappen",
-
- "panels.allTitle": "Alle Bereiche ein- oder ausblenden",
"mixer.hint": "Fader ziehen · M/S",
"stemsPanel.ariaLabel": "Stems",
@@ -3336,11 +3316,7 @@ const pt = {
"panels.timelineTitle": "Mostrar ou ocultar a linha do tempo",
- "panels.all": "Tudo",
-
"panels.collapse": "Recolher",
-
- "panels.allTitle": "Mostrar ou ocultar todos os painéis",
"mixer.hint": "Arraste o fader · M/S",
"stemsPanel.ariaLabel": "Stems",
@@ -3933,11 +3909,7 @@ const id = {
"panels.timelineTitle": "Tampilkan atau sembunyikan lini masa",
- "panels.all": "Semua",
-
"panels.collapse": "Ciutkan",
-
- "panels.allTitle": "Tampilkan atau sembunyikan semua panel",
"mixer.hint": "Seret fader · M/S",
"stemsPanel.ariaLabel": "Stem",
@@ -4517,11 +4489,7 @@ const fr = {
"panels.timelineTitle": "Afficher ou masquer la chronologie",
- "panels.all": "Tout",
-
"panels.collapse": "Réduire",
-
- "panels.allTitle": "Afficher ou masquer tous les panneaux",
"mixer.hint": "Glissez le fader · M/S",
"stemsPanel.ariaLabel": "Pistes",
@@ -5230,11 +5198,7 @@ const es = {
"panels.timelineTitle": "Mostrar u ocultar la línea de tiempo",
- "panels.all": "Todo",
-
"panels.collapse": "Contraer",
-
- "panels.allTitle": "Mostrar u ocultar todos los paneles",
"mixer.hint": "Arrastra el fader · M/S",
"stemsPanel.ariaLabel": "Stems",
@@ -5848,11 +5812,7 @@ const ko = {
"panels.timelineTitle": "타임라인 표시하거나 숨기기",
- "panels.all": "전체",
-
"panels.collapse": "접기",
-
- "panels.allTitle": "모든 패널 표시하거나 숨기기",
"mixer.hint": "페이더 드래그 · M/S",
"stemsPanel.ariaLabel": "스템",
diff --git a/static/js/ui-chrome.js b/static/js/ui-chrome.js
index 40eb557c..90119ff0 100644
--- a/static/js/ui-chrome.js
+++ b/static/js/ui-chrome.js
@@ -110,52 +110,6 @@ function wirePanelToggles() {
persist(name, next);
});
}
-
- wireAllToggle(app, toggles.map((btn) => btn.dataset.panel), apply, persist);
-}
-
-// "All" clears the three panels in one press and brings them back in one more.
-//
-// Deliberately not the library. It used to take that too, and the row could
-// not put it back: the three buttons beside it only know about their own
-// panels, so after an "All" press you could turn Analysis, Sections and
-// Timeline back on, watch "All" light up as though everything had returned,
-// and still be looking at a collapsed library with nothing in this row able to
-// reach it (#588). The library has its own control in the rail, which is where
-// a reader of this row would not think to look for it.
-//
-// It greys and strikes through like the other three once all three are away.
-// The threshold is the whole trick. Read as "is anything hidden", it struck
-// itself through the moment Analysis was hidden on its own, which looks exactly
-// like you pressed it. It is off only when every panel it governs is off, so it
-// describes the row rather than the loudest thing in it.
-//
-// It stores nothing. The state is derived from .app either way, so there is no
-// fourth flag to disagree with the other three.
-function wireAllToggle(app, names, apply, persist) {
- const btn = document.querySelector(".daw-panel-toggle[data-panel-all]");
- if (!btn) return;
-
- const hiddenCount = () =>
- names.filter((name) => app.classList.contains(`panel-${name}-off`)).length;
-
- const sync = () => btn.setAttribute("aria-pressed", String(hiddenCount() < names.length));
-
- btn.addEventListener("click", () => {
- // Anything still on screen means the press is asking to clear it away.
- const show = hiddenCount() === names.length;
- for (const name of names) {
- apply(name, show);
- persist(name, show);
- }
- });
-
- // Each panel can also be moved from its own button, and they all land as a
- // class on .app, so watching that one attribute keeps this button honest
- // without every other handler having to remember it exists. The library's
- // class lands there too and is ignored, which is the point.
- new MutationObserver(sync).observe(app, { attributes: true, attributeFilter: ["class"] });
- sync();
}
wirePanelToggles();
diff --git a/tests/e2e/panel-toggles.spec.mjs b/tests/e2e/panel-toggles.spec.mjs
index c176a054..98749106 100644
--- a/tests/e2e/panel-toggles.spec.mjs
+++ b/tests/e2e/panel-toggles.spec.mjs
@@ -1,15 +1,17 @@
// The Collapse row governs the panels beside it. Not the library.
//
-// "All" used to take the library with it, and nothing in the row could put it
-// back: the buttons beside it only know about their own panels. So the
-// reported sequence was press All, then turn each panel back on one at a time,
-// and watch All light up as though everything had returned while the library
-// stayed collapsed with no way to reach it from there (#588). The library has
-// its own control in the rail.
+// It used to carry an "All" as well, one press to put every panel away. That
+// button is gone: it governed two panels by the end, so it saved a single
+// press, and it wore a word the Extract row already uses for something else.
+// What survives it is the rule it existed to prove, which is still the thing
+// worth guarding: whatever this row collapses, it can also bring back, and the
+// library is not one of the things it touches.
//
-// Two panels now, not three. Timeline went with the footer's waveform strip:
-// it governed a region that no longer has anything visible in it, so the
-// button named something that was not there.
+// "All" used to take the library with it, and nothing in the row could put it
+// back: the buttons beside it only know about their own panels, so you could
+// turn each panel on again, see the row looking complete, and still be staring
+// at a collapsed library with no way to reach it from here (#588). The library
+// has its own control in the rail.
import { test, expect } from "@playwright/test";
import { openStudio } from "./helpers.mjs";
@@ -18,75 +20,69 @@ const PANELS = ["analysis", "sections"];
const state = (page) =>
page.evaluate((panels) => {
const app = document.querySelector(".app");
- const all = document.querySelector(".daw-panel-toggle[data-panel-all]");
return {
hidden: panels.filter((n) => app.classList.contains(`panel-${n}-off`)),
libraryCollapsed: app.classList.contains("cat-collapsed"),
- allPressed: all.getAttribute("aria-pressed") === "true",
+ buttons: [...document.querySelectorAll(".daw-panel-toggle")].map((b) => b.textContent.trim()),
};
}, PANELS);
-const clickAll = (page) => page.locator(".daw-panel-toggle[data-panel-all]").click();
-const clickPanel = (page, name) => page.locator(`.daw-panel-toggle[data-panel="${name}"]`).click();
+const clickPanel = (page, name) =>
+ page.locator(`.daw-panel-toggle[data-panel="${name}"]`).click();
test.describe("collapse row", () => {
- test("All leaves the library alone in both directions", async ({ page }) => {
+ test("the row is the panels it governs, and nothing else", async ({ page }) => {
await openStudio(page, { tauri: true });
- expect((await state(page)).libraryCollapsed).toBe(false);
- await clickAll(page);
- let s = await state(page);
- expect(s.hidden, "every panel it governs is away").toEqual(PANELS);
- expect(s.libraryCollapsed, "the library is not one of them").toBe(false);
-
- await clickAll(page);
- s = await state(page);
- expect(s.hidden).toEqual([]);
- expect(s.libraryCollapsed).toBe(false);
+ const s = await state(page);
+ expect(s.buttons).toEqual(["Analysis", "Sections"]);
+ // No "All": one press saved is not worth a third control called All in a
+ // bar that already has one.
+ expect(s.buttons).not.toContain("All");
});
- test("the reporter's sequence leaves nothing stranded", async ({ page }) => {
+ test("every panel it puts away, it can bring back", async ({ page }) => {
await openStudio(page, { tauri: true });
+ expect((await state(page)).hidden).toEqual([]);
- // Press All, then bring each panel back from its own button.
- await clickAll(page);
for (const name of PANELS) await clickPanel(page, name);
+ expect((await state(page)).hidden).toEqual(PANELS);
- const s = await state(page);
- // The bug was that All read as fully on here while the library was still
- // collapsed and unreachable from this row.
- expect(s.allPressed, "All reflects the row").toBe(true);
- expect(s.hidden).toEqual([]);
- expect(s.libraryCollapsed, "nothing was left collapsed behind it").toBe(false);
+ // The way back is the same button. Collapsing to nothing is only safe
+ // because of this.
+ for (const name of PANELS) await clickPanel(page, name);
+ expect((await state(page)).hidden).toEqual([]);
});
- test("All still greys out only when every panel it governs is away", async ({ page }) => {
+ test("it leaves the library alone in both directions", async ({ page }) => {
await openStudio(page, { tauri: true });
+ expect((await state(page)).libraryCollapsed).toBe(false);
- // One panel hidden must not read as "you pressed All".
- await clickPanel(page, "analysis");
- expect((await state(page)).allPressed).toBe(true);
-
- await clickPanel(page, "sections");
- expect((await state(page)).allPressed, "both away, so the row is away").toBe(false);
+ for (const name of PANELS) await clickPanel(page, name);
+ // The bug was that the library went with them and this row could not
+ // return it (#588).
+ expect((await state(page)).libraryCollapsed).toBe(false);
});
- test("collapsing the library does not change what All reports", async ({ page }) => {
- // The library's class lands on the same element the observer watches, so
- // this is the check that it is genuinely ignored rather than accidentally
- // absent from the count.
+ test("collapsing the library does not disturb the panels", async ({ page }) => {
+ // The library's class lands on the same element the panels' classes do, so
+ // this is the check that the two are genuinely independent rather than
+ // accidentally not colliding.
await openStudio(page, { tauri: true });
+ await clickPanel(page, "analysis");
+
await page.locator("#sidebarCollapseBtn").click();
- await expect
- .poll(async () => (await state(page)).libraryCollapsed, { timeout: 5000 })
- .toBe(true);
+ await expect.poll(async () => (await state(page)).libraryCollapsed).toBe(true);
- expect((await state(page)).allPressed, "the library is not part of the row").toBe(true);
+ expect((await state(page)).hidden).toEqual(["analysis"]);
+ });
+
+ test("a collapsed panel survives a reload", async ({ page }) => {
+ await openStudio(page, { tauri: true });
+ await clickPanel(page, "sections");
+ expect((await state(page)).hidden).toEqual(["sections"]);
- // And All must not resurrect it on the way past.
- await clickAll(page);
- expect((await state(page)).libraryCollapsed).toBe(true);
- await clickAll(page);
- expect((await state(page)).libraryCollapsed).toBe(true);
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await expect.poll(async () => (await state(page)).hidden).toEqual(["sections"]);
});
});
From 7f1ff91eb10872a8f78e492057d8d445cd3dec39 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 22 Sep 2026 21:49:44 +0100
Subject: [PATCH 2/3] Fold the Extract chips that do not fit, and group Vocals
with its modes
Two things about that row, both about what it does when there is not enough
width for it.
Vocals, All and Lead + Backing were three loose chips among six stems, so the
two that describe Vocals read as peers of Drums and Bass. They are joined now:
the chip and the mode buttons touch, their facing corners square off, and one
outline in the Vocals colour runs round the three of them. The outline carries
the grouping, the fill carries the state, which is why the active mode still
reads at a glance. Tinting the whole group instead made Lead + Backing look
selected when it is not.
The chips no longer wrap. Whichever ones do not fit move into a panel behind a
three-dot button, one at a time, from the end of the row, and come back as the
window widens. Wrapping was the alternative and it is the wrong one: the bar
went from 96px tall to 168 and then 236 as they spilled onto further lines, so
it grew exactly as the window shrank, and the controls on the right were left
stranded at the top of a column stretched by chips they have nothing to do
with. It is 96px at every width from 2560 down to 900 now.
Measured, not a breakpoint: seven chips in ten languages have no width anyone
can write down. It also retired the 1320px rule that used to drop the Collapse
row onto a third line, which existed only because the chips filled row two.
The observer watches the composer rather than the chip row, and that is the
part worth keeping in mind. Watching the row meant measuring the thing being
changed: emptying it shrank the column it sits in, so at a narrow window the
row collapsed to 127px and widening the window never widened it back, because
the chips that would have done so were in the panel. The chips never came home.
And the now-playing card stops stretching. Filling its column turned spare bar
into spare card with the same text in it, so it has a real ceiling at 460px,
what the meta line needs at its longest. Identical at 1920, 2200 and 2560.
---
static/css/daw.css | 136 ++++++++++++++++++++++++++++++++++++++++---
static/index.html | 37 ++++++++----
static/js/i18n.js | 10 ++++
static/js/main.js | 142 +++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 307 insertions(+), 18 deletions(-)
diff --git a/static/css/daw.css b/static/css/daw.css
index a47219f2..1ebcab65 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -4467,7 +4467,18 @@ input, textarea { font-family: inherit; }
position: relative;
grid-column: 2;
grid-row: 1 / 3;
- justify-self: stretch;
+ /* As wide as the track it is naming, not as wide as the window.
+ It was stretched to fill its column, so a spare 400px of bar became 400px
+ of card with the same text sitting in it. It sizes to its content now and
+ stops; a long title clips with an ellipsis at the cap rather than dragging
+ the card out to meet it. */
+ justify-self: start;
+ /* A real ceiling, not a percentage of the column: 100% just meant "as wide
+ as the bar allows", so the card was 422px at 1600 and 460 at 1920 with the
+ same text in it. 460 is what the meta line needs at its longest, duration
+ through to the extracted date, so the card is the same size at every
+ window width and a long title clips rather than pushing it wider. */
+ max-width: min(100%, 460px);
align-self: stretch;
/* .footer-track-slot is `width: var(--daw-col-w)`, which is the gutter width
it was built to fill in the footer. Here the column decides the width, so
@@ -4678,13 +4689,6 @@ input, textarea { font-family: inherit; }
@media (max-width: 1460px) {
.daw-composer > #nowPlayingPanel { display: none; }
}
-@media (max-width: 1320px) {
- .daw-composer > .daw-panel-toggles {
- grid-column: 1 / -1;
- grid-row: 3;
- justify-self: end;
- }
-}
/* The click-track options with nothing to act on.
*
@@ -4695,3 +4699,119 @@ input, textarea { font-family: inherit; }
opacity: 0.4;
pointer-events: none;
}
+
+/* Vocals and its two mode buttons, as one thing.
+ *
+ * Grouped by proximity, not by a box. A frame around them needed padding and
+ * borders of its own, which made the group 38px tall in a row of 30px chips:
+ * it hung 8px below everything beside it. Joining them instead costs no height
+ * at all, and a segmented control is what this is.
+ *
+ * So the chip and the toggle touch, their facing corners square off, and the
+ * gap that used to sit between them moves to the outside of the pair. */
+.stem-group {
+ display: inline-flex;
+ align-items: center;
+ 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"] {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+.stem-group .vocal-mode-toggle {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ border-left: none;
+}
+/* The mode buttons take the Vocals chip's border, and only its border, so one
+ outline runs round the three of them.
+ *
+ * The outline is the grouping cue; the fill is not available for it. Tinting
+ * the whole toggle made "Lead + Backing" look selected when it is not, and
+ * left the active mode barely distinguishable from the inactive one, because
+ * that mode's own 16% was sitting on top of the group's 14%. Inside the
+ * outline the three states stay readable: Vocals is tinted because the stem is
+ * 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. */
+.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);
+}
+/* The divider between All and Lead + Backing comes from the same colour, so it
+ reads as an internal seam rather than a second frame. */
+.stem-group:has(.stem-choice[data-stem="vocals"][aria-pressed="true"]) .vocal-mode-btn + .vocal-mode-btn {
+ border-left: 1px solid color-mix(in srgb, var(--color) 60%, transparent);
+}
+
+/* ── The Extract chips, and what happens when they stop fitting ───────────
+ The row never wraps and the bar never gets taller. As the window narrows,
+ whichever chips no longer fit move into a panel behind a three-dot button,
+ one at a time, from the end of the row. The button sits after the row, on the
+ side the chips are leaving from, and is hidden entirely while everything
+ fits.
+
+ Wrapping was the alternative and it is the wrong one here: the composer went
+ from 96px tall to 168 and then 236 as the chips spilled onto further lines,
+ so the bar grew exactly as the window shrank, and the controls on the right
+ were left stranded at the top of a column stretched by chips they have
+ nothing to do with.
+
+ The measuring is in fitStemChips in main.js; this is only what it looks
+ like.
+ ───────────────────────────────────────────────────────────────────────── */
+.stem-more-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: none;
+ width: 34px;
+ height: 30px;
+ margin-left: 5px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: none;
+ color: var(--muted-2);
+ cursor: pointer;
+ transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
+}
+.stem-more-btn[hidden] { display: none; }
+.stem-more-btn:hover { background: var(--panel-2); color: var(--fg); }
+.stem-more-btn[aria-expanded="true"] { border-color: var(--accent); color: var(--accent); }
+
+/* One line, always. Overflow hidden so a chip mid-move is never half drawn,
+ and so this element's clientWidth is the honest budget fitStemChips
+ measures against. */
+.daw-composer .daw-stem-chips {
+ flex: 1 1 auto;
+ min-width: 0;
+ flex-wrap: nowrap;
+ overflow: hidden;
+}
+
+/* Fixed rather than absolute, for the same reason the footer's options panel
+ is: it has to escape the bar it is anchored in. JS sets left/top from the
+ button's own rect. */
+.stem-overflow {
+ position: fixed;
+ z-index: 320;
+ display: none;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ max-width: min(560px, calc(100vw - 32px));
+ padding: 10px;
+ background: var(--panel);
+ border: 1px solid var(--border-strong);
+ border-radius: 10px;
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.42);
+}
+.stem-overflow.open { display: flex; }
diff --git a/static/index.html b/static/index.html
index 590e100c..fbd2260b 100644
--- a/static/index.html
+++ b/static/index.html
@@ -90,17 +90,22 @@
-
+
All
-
- Vocals
-
-
-
-
All
-
Lead + Backing
+
+
+
+ Vocals
+
+
+
+ All
+ Lead + Backing
+
Drums
@@ -118,6 +123,18 @@
Other
+
+
+
+
+
+
+