From 3375864bf4952c9657995da3ae45ddb4a396800e Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Tue, 20 Oct 2026 00:45:17 +0100
Subject: [PATCH] fix(sections): grey out Add when there is no room for a
section
Add silently did nothing when the sections left no free space. It is now aria-disabled with a translated reason in its tooltip, kept in sync with sections, the loop, the song and the language. The Guide sentence says so.
Closes #775
---
static/css/daw.css | 10 +++
static/index.html | 2 +-
static/js/i18n.js | 30 +++++---
static/js/main.js | 5 +-
static/js/sections.js | 20 ++++-
static/js/state.js | 11 ++-
tests/e2e/sections-add-disabled.spec.mjs | 96 ++++++++++++++++++++++++
7 files changed, 157 insertions(+), 17 deletions(-)
create mode 100644 tests/e2e/sections-add-disabled.spec.mjs
diff --git a/static/css/daw.css b/static/css/daw.css
index 351b2a12..108b0e8d 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -1796,6 +1796,16 @@ input, textarea { font-family: inherit; }
border-color: rgba(255,255,255,0.18);
}
+.sections-add-btn-label[aria-disabled="true"] {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+.sections-add-btn-label[aria-disabled="true"]:hover {
+ background: none;
+ color: var(--muted);
+ border-color: var(--border);
+}
+
/* Clear-all sections. Neutral until it is armed, then it states plainly that
the next click destroys something, because nothing here can be undone. */
.sections-clear-btn[data-armed="1"] {
diff --git a/static/index.html b/static/index.html
index 03ee2421..97db87cd 100644
--- a/static/index.html
+++ b/static/index.html
@@ -600,7 +600,7 @@
Lyrics
Clear
-
+
Add
diff --git a/static/js/i18n.js b/static/js/i18n.js
index cbc8c442..9f38eb3c 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -896,7 +896,7 @@ const en = {
"help.newFolder": "Makes a folder, so you can sort your songs.",
"help.trashedSong": "A deleted song. Hold it for a second to bring it back.",
"help.song": "Click a song to open it in the studio.",
- "help.addSection": "Adds a section, like a verse or a chorus, to the sections bar.",
+ "help.addSection": "Adds a section, like a verse or a chorus, to the sections bar. It is greyed out when there is no free space for one.",
"help.volume": "Drag to make {name} louder or quieter.",
"help.laneKeyUp": "Moves only {name} up one semitone, the smallest step in music.",
"help.laneKeyDown": "Moves only {name} down one semitone, the smallest step in music.",
@@ -995,6 +995,7 @@ const en = {
"sections.title": "Sections",
"sections.add": "Add",
"sections.addAria": "Add section",
+ "sections.addNoRoom": "No free space for a new section",
"sections.savingAria": "Saving sections",
"mixer.title": "Mixer",
@@ -2660,7 +2661,7 @@ const pl = {
"help.newFolder": "Tworzy folder, w którym możesz porządkować utwory.",
"help.trashedSong": "Usunięty utwór. Przytrzymaj go przez sekundę, aby go przywrócić.",
"help.song": "Kliknij utwór, aby otworzyć go w studiu.",
- "help.addSection": "Dodaje sekcję, na przykład zwrotkę lub refren, do paska sekcji.",
+ "help.addSection": "Dodaje sekcję, na przykład zwrotkę lub refren, do paska sekcji. Jest wyszarzony, gdy nie ma na nią wolnego miejsca.",
"help.volume": "Przeciągnij, aby {name} grało głośniej lub ciszej.",
"help.laneKeyUp": "Podnosi tylko {name} o półton, najmniejszy krok w muzyce.",
"help.laneKeyDown": "Obniża tylko {name} o półton, najmniejszy krok w muzyce.",
@@ -2759,6 +2760,7 @@ const pl = {
"sections.title": "Sekcje",
"sections.add": "Dodaj",
"sections.addAria": "Dodaj sekcję",
+ "sections.addNoRoom": "Brak wolnego miejsca na nową sekcję",
"sections.savingAria": "Zapisywanie sekcji",
"mixer.title": "Mikser",
@@ -4414,7 +4416,7 @@ const ja = {
"help.newFolder": "曲を整理するためのフォルダーを作ります。",
"help.trashedSong": "削除した曲です。1 秒長押しすると元に戻ります。",
"help.song": "曲をクリックするとスタジオで開きます。",
- "help.addSection": "Aメロやサビなどのセクションをセクションバーに追加します。",
+ "help.addSection": "Aメロやサビなどのセクションをセクションバーに追加します。 空きがないときはグレーになります。",
"help.volume": "ドラッグして{name}の音量を上げ下げします。",
"help.laneKeyUp": "{name}だけを半音上げます。半音は音楽で最も小さな音程の単位です。",
"help.laneKeyDown": "{name}だけを半音下げます。半音は音楽で最も小さな音程の単位です。",
@@ -4513,6 +4515,7 @@ const ja = {
"sections.title": "セクション",
"sections.add": "追加",
"sections.addAria": "セクションを追加",
+ "sections.addNoRoom": "新しいセクションを置く空きがありません",
"sections.savingAria": "セクションを保存中",
"mixer.title": "ミキサー",
@@ -6143,7 +6146,7 @@ const zhHans = {
"help.newFolder": "新建文件夹,用来整理歌曲。",
"help.trashedSong": "已删除的歌曲。按住一秒即可恢复。",
"help.song": "点击歌曲,在工作室中打开它。",
- "help.addSection": "在段落栏中添加一个段落,例如主歌或副歌。",
+ "help.addSection": "在段落栏中添加一个段落,例如主歌或副歌。 没有空间时按钮会变灰。",
"help.volume": "拖动以调大或调小{name}的音量。",
"help.laneKeyUp": "只把{name}升高一个半音,半音是音乐中最小的音程。",
"help.laneKeyDown": "只把{name}降低一个半音,半音是音乐中最小的音程。",
@@ -6242,6 +6245,7 @@ const zhHans = {
"sections.title": "段落",
"sections.add": "添加",
"sections.addAria": "添加段落",
+ "sections.addNoRoom": "没有空间添加新段落",
"sections.savingAria": "正在保存段落",
"mixer.title": "混音台",
@@ -7872,7 +7876,7 @@ const de = {
"help.newFolder": "Legt einen Ordner an, damit du deine Songs sortieren kannst.",
"help.trashedSong": "Ein gelöschter Song. Halte ihn eine Sekunde lang gedrückt, um ihn zurückzuholen.",
"help.song": "Klick auf einen Song, um ihn im Studio zu öffnen.",
- "help.addSection": "Fügt der Abschnittsleiste einen Abschnitt hinzu, etwa eine Strophe oder einen Refrain.",
+ "help.addSection": "Fügt der Abschnittsleiste einen Abschnitt hinzu, etwa eine Strophe oder einen Refrain. Er ist ausgegraut, wenn kein Platz frei ist.",
"help.volume": "Zieh, um {name} lauter oder leiser zu machen.",
"help.laneKeyUp": "Hebt nur {name} um einen Halbton an, den kleinsten Schritt in der Musik.",
"help.laneKeyDown": "Senkt nur {name} um einen Halbton, den kleinsten Schritt in der Musik.",
@@ -7971,6 +7975,7 @@ const de = {
"sections.title": "Abschnitte",
"sections.add": "Hinzufügen",
"sections.addAria": "Abschnitt hinzufügen",
+ "sections.addNoRoom": "Kein freier Platz für einen neuen Abschnitt",
"sections.savingAria": "Abschnitte werden gespeichert",
"mixer.title": "Mixer",
@@ -9612,7 +9617,7 @@ const pt = {
"help.newFolder": "Cria uma pasta para organizar suas músicas.",
"help.trashedSong": "Uma música apagada. Segure-a por um segundo para trazê-la de volta.",
"help.song": "Clique em uma música para abri-la no estúdio.",
- "help.addSection": "Adiciona uma seção, como um verso ou um refrão, à barra de seções.",
+ "help.addSection": "Adiciona uma seção, como um verso ou um refrão, à barra de seções. Fica cinza quando não há espaço livre.",
"help.volume": "Arraste para deixar {name} mais alto ou mais baixo.",
"help.laneKeyUp": "Sobe só {name} um semitom, o menor passo na música.",
"help.laneKeyDown": "Desce só {name} um semitom, o menor passo na música.",
@@ -9711,6 +9716,7 @@ const pt = {
"sections.title": "Seções",
"sections.add": "Adicionar",
"sections.addAria": "Adicionar seção",
+ "sections.addNoRoom": "Sem espaço livre para uma nova seção",
"sections.savingAria": "Salvando seções",
"mixer.title": "Mixer",
@@ -11354,7 +11360,7 @@ const id = {
"help.newFolder": "Membuat folder untuk mengatur lagu Anda.",
"help.trashedSong": "Lagu yang dihapus. Tahan selama satu detik untuk mengembalikannya.",
"help.song": "Klik lagu untuk membukanya di studio.",
- "help.addSection": "Menambahkan bagian, seperti bait atau reff, ke bilah bagian.",
+ "help.addSection": "Menambahkan bagian, seperti bait atau reff, ke bilah bagian. Tombol ini redup jika tidak ada ruang kosong.",
"help.volume": "Geser untuk membuat {name} lebih keras atau lebih pelan.",
"help.laneKeyUp": "Menaikkan hanya {name} satu semitone, langkah terkecil dalam musik.",
"help.laneKeyDown": "Menurunkan hanya {name} satu semitone, langkah terkecil dalam musik.",
@@ -11453,6 +11459,7 @@ const id = {
"sections.title": "Bagian",
"sections.add": "Tambah",
"sections.addAria": "Tambah bagian",
+ "sections.addNoRoom": "Tidak ada ruang kosong untuk bagian baru",
"sections.savingAria": "Menyimpan bagian",
"mixer.title": "Mixer",
@@ -13083,7 +13090,7 @@ const fr = {
"help.newFolder": "Crée un dossier pour ranger vos morceaux.",
"help.trashedSong": "Un morceau supprimé. Maintenez-le une seconde pour le récupérer.",
"help.song": "Cliquez sur un morceau pour l'ouvrir dans le studio.",
- "help.addSection": "Ajoute une section, comme un couplet ou un refrain, à la barre des sections.",
+ "help.addSection": "Ajoute une section, comme un couplet ou un refrain, à la barre des sections. Il est grisé quand il n'y a pas de place libre.",
"help.volume": "Faites glisser pour rendre {name} plus fort ou plus faible.",
"help.laneKeyUp": "Monte seulement {name} d'un demi-ton, le plus petit pas en musique.",
"help.laneKeyDown": "Descend seulement {name} d'un demi-ton, le plus petit pas en musique.",
@@ -13182,6 +13189,7 @@ const fr = {
"sections.title": "Sections",
"sections.add": "Ajouter",
"sections.addAria": "Ajouter une section",
+ "sections.addNoRoom": "Pas de place libre pour une nouvelle section",
"sections.savingAria": "Enregistrement des sections",
"mixer.title": "Mixage",
@@ -15224,7 +15232,7 @@ const es = {
"help.newFolder": "Crea una carpeta para ordenar tus canciones.",
"help.trashedSong": "Una canción borrada. Mantenla pulsada un segundo para recuperarla.",
"help.song": "Haz clic en una canción para abrirla en el estudio.",
- "help.addSection": "Añade una sección, como una estrofa o un estribillo, a la barra de secciones.",
+ "help.addSection": "Añade una sección, como una estrofa o un estribillo, a la barra de secciones. Aparece en gris cuando no hay espacio libre.",
"help.volume": "Arrastra para subir o bajar el volumen de {name}.",
"help.laneKeyUp": "Sube solo {name} un semitono, el paso más pequeño en música.",
"help.laneKeyDown": "Baja solo {name} un semitono, el paso más pequeño en música.",
@@ -15323,6 +15331,7 @@ const es = {
"sections.title": "Secciones",
"sections.add": "Añadir",
"sections.addAria": "Añadir sección",
+ "sections.addNoRoom": "No hay espacio libre para una nueva sección",
"sections.savingAria": "Guardando secciones",
"mixer.title": "Mezclador",
@@ -16987,7 +16996,7 @@ const ko = {
"help.newFolder": "곡을 정리할 폴더를 만들어요.",
"help.trashedSong": "삭제한 곡이에요. 1초 동안 누르고 있으면 되살아나요.",
"help.song": "곡을 클릭하면 스튜디오에서 열려요.",
- "help.addSection": "벌스나 코러스 같은 구간을 섹션 막대에 추가해요.",
+ "help.addSection": "벌스나 코러스 같은 구간을 섹션 막대에 추가해요. 빈 공간이 없으면 회색으로 바뀌어요.",
"help.volume": "끌어서 {name} 소리를 키우거나 줄여요.",
"help.laneKeyUp": "{name}만 반음 올려요. 반음은 음악에서 가장 작은 음 간격이에요.",
"help.laneKeyDown": "{name}만 반음 내려요. 반음은 음악에서 가장 작은 음 간격이에요.",
@@ -17086,6 +17095,7 @@ const ko = {
"sections.title": "구간",
"sections.add": "추가",
"sections.addAria": "구간 추가",
+ "sections.addNoRoom": "새 구간을 넣을 빈 공간이 없어요",
"sections.savingAria": "구간 저장 중",
"mixer.title": "믹서",
diff --git a/static/js/main.js b/static/js/main.js
index c10be1de..0ef601a6 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -4,7 +4,7 @@ import {
playBtn, loopBtn, totalDuration, loopEnabled, loopStart, loopEnd,
setLoopStart, setLoopEnd, selectedStems, saveSelectedStems, stemSelectionReady,
currentJobId, vocalSplitMode, vocalSplitModeReady, setVocalSplitMode,
- setAutoSectionsResetFn,
+ setAutoSectionsResetFn, onLoopChange,
} from "./state.js";
import { STEM_NAMES, syncStemNamesFromAPI } from "./constants.js";
import { refreshStemChoiceVisuals } from "./stemChoice.js";
@@ -14,7 +14,7 @@ import { initSearch } from "./search.js";
import { wireTransportButtons } from "./transport.js";
import { wireBeatGridUi } from "./beatgridUi.js";
import { togglePlayPause, updateLoopRegionVisual, toggleMetronome, transport, setPlayheadTime, setLoopRange } from "./transport.js";
-import { setSectionsLoopBridge } from "./sections.js";
+import { setSectionsLoopBridge, refreshSectionsAddState } from "./sections.js";
import { wireStemListControls, wireMixerToolbar, laneDragIcon } from "./mixer.js";
import { initCatalog, collectDiagnostics } from "./catalog.js";
import { initNotifications, notifyFailure, dismissFailuresByJobId } from "./notifications.js";
@@ -485,6 +485,7 @@ setSectionsLoopBridge({
getLoop: () => ({ enabled: loopEnabled, start: loopStart, end: loopEnd }),
setLoopRange,
});
+onLoopChange(refreshSectionsAddState);
wireBeatGridUi();
wireFooterControls();
requestAnimationFrame(drawFooterPlaceholder);
diff --git a/static/js/sections.js b/static/js/sections.js
index 62e52b03..72730347 100644
--- a/static/js/sections.js
+++ b/static/js/sections.js
@@ -67,7 +67,11 @@ export function initSections(trackId, sections, duration) {
const addBtn = document.getElementById("sectionsAddBtn");
if (addBtn && !addBtn.dataset.sectionsWired) {
addBtn.dataset.sectionsWired = "1";
- addBtn.addEventListener("click", () => _addSection());
+ addBtn.addEventListener("click", () => {
+ // aria-disabled, not disabled, so the tooltip still says why.
+ if (addBtn.getAttribute("aria-disabled") === "true") return;
+ _addSection();
+ });
}
_wireClearButton();
@@ -97,6 +101,7 @@ export function destroySections() {
if (_container) _container.innerHTML = "";
_container = null;
_refreshClearVisibility();
+ refreshSectionsAddState();
}
// ─── Rendering ────────────────────────────────────────────
@@ -105,6 +110,7 @@ function _render() {
// Before the container guard: the button lives in the header, not the
// ribbon, so its state must stay correct even when the ribbon is absent.
_refreshClearVisibility();
+ refreshSectionsAddState();
if (!_container) return;
_container.innerHTML = "";
@@ -432,6 +438,18 @@ function _newSectionBounds() {
return { start, end };
}
+// Add is unavailable exactly when pressing it would do nothing: no place for a
+// section (#775) and no armed loop that would at least explain itself with the
+// "loop overlaps" notice. Recomputed whenever sections, the song, the loop or
+// the language change.
+export function refreshSectionsAddState() {
+ const btn = document.getElementById("sectionsAddBtn");
+ if (!btn) return;
+ const blocked = !_armedLoop() && _newSectionBounds() === null;
+ btn.setAttribute("aria-disabled", String(blocked));
+ btn.title = blocked ? t("sections.addNoRoom") : t("sections.addAria");
+}
+
function _addSection() {
const bounds = _newSectionBounds();
if (!bounds) {
diff --git a/static/js/state.js b/static/js/state.js
index 046cacc6..8030a3dc 100644
--- a/static/js/state.js
+++ b/static/js/state.js
@@ -198,9 +198,14 @@ export function setCurrentJobId(v) { currentJobId = v; }
export function setForegroundJobId(v) { foregroundJobId = v; }
export function setTrackIndex(v) { trackIndex = v; }
export function setTotalDuration(v) { totalDuration = v; }
-export function setLoopEnabled(v) { loopEnabled = v; }
-export function setLoopStart(v) { loopStart = v; }
-export function setLoopEnd(v) { loopEnd = v; }
+// Whoever needs to react to the loop changing (the Sections Add button depends
+// on whether a loop is armed) subscribes here rather than polling.
+const _loopListeners = [];
+export function onLoopChange(fn) { _loopListeners.push(fn); }
+function _loopChanged() { for (const fn of _loopListeners) fn(); }
+export function setLoopEnabled(v) { loopEnabled = v; _loopChanged(); }
+export function setLoopStart(v) { loopStart = v; _loopChanged(); }
+export function setLoopEnd(v) { loopEnd = v; _loopChanged(); }
export function setAudioContext(v) { audioContext = v; }
export function setMasterVolume(v) { masterVolume = v; }
export let playbackSpeed = 1.0;
diff --git a/tests/e2e/sections-add-disabled.spec.mjs b/tests/e2e/sections-add-disabled.spec.mjs
new file mode 100644
index 00000000..c870d03e
--- /dev/null
+++ b/tests/e2e/sections-add-disabled.spec.mjs
@@ -0,0 +1,96 @@
+// The Sections "Add" button is greyed out exactly when pressing it would do
+// nothing: a song whose sections leave no room for another one (#775).
+//
+// Before, a song with detected sections that fill the track showed an Add
+// button that looked live and silently did nothing.
+import { test, expect } from "@playwright/test";
+import { openStudio, JOB_ID } from "./helpers.mjs";
+
+test.afterEach(async ({ page }) => {
+ await page.request.patch(`/api/jobs/${JOB_ID}/sections`, { data: { sections: [] } });
+});
+
+async function seed(page, sections) {
+ const res = await page.request.patch(`/api/jobs/${JOB_ID}/sections`, { data: { sections } });
+ expect(res.ok(), "seeding sections").toBe(true);
+}
+
+// seed.py writes a 6 second fixture.
+const FULL = [
+ { id: "a", name: "Verse", start: 0, end: 3, color: "#4a7fff" },
+ { id: "b", name: "Chorus", start: 3, end: 6, color: "#ff7a4a" },
+];
+const ROOM = [{ id: "a", name: "Verse", start: 0, end: 2, color: "#4a7fff" }];
+
+const add = (page) => page.locator("#sectionsAddBtn");
+
+test("Add is greyed out, with a reason, when sections fill the song", async ({ page }) => {
+ await seed(page, FULL);
+ await openStudio(page);
+ await expect(page.locator(".section-block")).toHaveCount(2);
+
+ await expect(add(page)).toBeDisabled();
+ await expect(add(page)).toHaveAttribute("title", "No free space for a new section");
+
+ // A press that gets through anyway must still change nothing.
+ await add(page).evaluate((el) => el.click());
+ await expect(page.locator(".section-block")).toHaveCount(2);
+});
+
+test("Add stays enabled, and works, when there is room", async ({ page }) => {
+ await seed(page, ROOM);
+ await openStudio(page);
+ await expect(page.locator(".section-block")).toHaveCount(1);
+
+ await expect(add(page)).toBeEnabled();
+ await expect(add(page)).toHaveAttribute("title", "Add section");
+ await add(page).click();
+ await expect(page.locator(".section-block")).toHaveCount(2);
+});
+
+test("Add is enabled with no sections, and again after clearing a full set", async ({ page }) => {
+ await seed(page, []);
+ await openStudio(page);
+ await expect(add(page)).toBeEnabled();
+ await add(page).click();
+ await expect(page.locator(".section-block")).toHaveCount(1);
+
+ await seed(page, FULL);
+ await openStudio(page);
+ await expect(add(page)).toBeDisabled();
+ await page.locator("#sectionsClearBtn").click();
+ await page.locator("#sectionsClearBtn").click();
+ await expect(page.locator(".section-block")).toHaveCount(0);
+ await expect(add(page)).toBeEnabled();
+});
+
+test("Add updates as a section is deleted", async ({ page }) => {
+ await seed(page, FULL);
+ await openStudio(page);
+ await expect(add(page)).toBeDisabled();
+ await page.locator('.section-block[data-id="b"] .section-del').evaluate((el) => el.click());
+ await expect(page.locator(".section-block")).toHaveCount(1);
+ await expect(add(page)).toBeEnabled();
+});
+
+test("Add follows the loop: a loop in a small gap makes room", async ({ page }) => {
+ // The only gap, 2.5s to 3.1s, is narrower than a default section, so Add has
+ // nowhere to put one until a loop there says where.
+ await seed(page, [
+ { id: "a", name: "Verse", start: 0, end: 2.5, color: "#4a7fff" },
+ { id: "b", name: "Chorus", start: 3.1, end: 6, color: "#ff7a4a" },
+ ]);
+ await openStudio(page);
+ await expect(add(page)).toBeDisabled();
+
+ await page.locator("#t-loop-end").fill("03.100");
+ await page.locator("#t-loop-end").blur();
+ await page.locator("#t-loop-start").fill("02.500");
+ await page.locator("#t-loop-start").blur();
+ await expect(page.locator("#t-loop")).toHaveClass(/active/);
+ await expect(add(page)).toBeEnabled();
+
+ await page.locator("#t-loop").click();
+ await expect(page.locator("#t-loop")).not.toHaveClass(/active/);
+ await expect(add(page)).toBeDisabled();
+});