Skip to content

Make the vocals control honest, keep the Extract row on one line, and link the logo - #679

Merged
thcp merged 9 commits into
mainfrom
fix/vocal-mode-combined-659
Sep 23, 2026
Merged

thcp merged 9 commits into
mainfrom
fix/vocal-mode-combined-659

Conversation

@thcp

@thcp thcp commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

Closes #659
Closes #673
Closes #674
Closes #675
Closes #676
Closes #677
Closes #678

The vocals control (#659, #675)

The vocal mode button is called Combined now, not All. It sat a few pixels from the stem row's All and meant something different. The rename covers all ten language tables. A unit test checks that no two controls in the Extract row share a label in any of them, because before this every table used the same word twice.

Combined and Lead + Backing are now the vocals control, not a setting that waits for the chip. Between them they cover three states: one lit, the other lit, or neither lit, which means no vocals.

  • Pressing the lit mode switches vocals off.
  • Pressing the other mode switches mode and keeps vocals on.
  • Pressing either mode while vocals are off switches them on in that mode.
  • The Vocals chip still works too. It switches vocals on as Combined.
  • The mode is remembered while vocals are off.

The phone's Extract screen now behaves the same way.

Two bugs came out of this:

  • Pressing the last selected chip turned all six stems back on. It now switches that stem off, and the row can be empty.
  • An empty row sent all six stems. The server reads an empty stems list as every stem, so Split is now disabled while nothing is selected. It also stays disabled for the whole of an upload, however the chips change in the meantime. The phone already refused an empty selection with a toast, and still does.

The Extract row stays on one line (#673)

The row wrapped to a second line at 1280 in French and Polish. English fit, which is why it went unnoticed. There were three causes:

  • A leftover rule. An old flex-wrap: wrap rule was more specific than the nowrap the row declares, so it won.
  • The ⋯ button was not budgeted for. The row was measured before the button appeared, so it always kept one chip too many.
  • Margins were not counted when adding up chip widths.

Folding one chip can also shrink the space for the chips that remain, so it now folds, measures again, and repeats until the row fits. None of this had a test. The new spec runs in ten languages at four widths and checks that the resize watcher does not keep firing.

Smaller fixes

The logo also sits in a cell exactly as wide as the rail below it, with the rail width now defined in one place.

Verification

  • 229 e2e pass, JS unit tests pass, i18n coverage clean, uv.lock untouched.
  • Each new guard was checked by removing its fix and watching the test fail.

Thales added 9 commits September 23, 2026 08:38
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.
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.
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.
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.
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.
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.
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.
Two regressions from closing Split on an empty selection, found reviewing the
branch before opening it.

The studio's Split button has two reasons to be disabled and they were
overwriting each other. setSubmitProcessing holds it shut while a submit is in
flight, which for a large upload is the whole upload, and the Extract row
repaints the same button on every chip press. Pressing a chip during an upload
reopened it and allowed a second submit. In the other direction, the end of a
submit reopened it unconditionally, so a row emptied during the upload came
back with Split live and an empty stems list, which the server reads as every
stem. Each side now applies both conditions.

On the phone, the empty-selection case was already handled: startExtraction
refuses it with a toast saying "Pick at least one stem." The disabled attribute
added in 789938e made that worse, not safer. The CTA has no disabled style, so
it looked exactly as live as before, and the press was swallowed with no
reason given. That commit's message says the trap existed on the phone too;
it did not. The attribute is gone and the test now asserts the toast and that
nothing is posted.
A track imported from a file has no thumbnail, so its library row and the
now-playing square both showed the same note icon, and a library of uploads
was a column of identical rows. The extension is the one thing about the file
the row can say, so it says that: WAV, FLAC, MP3.

This was meant to ship in #665 and half of it did. The styling went out,
.thumb-ext and .np-art-placeholder[data-ext], with a comment pairing it with
extLabel() in catalog.js. extLabel() was in no commit on any branch: it was
lost when that work was rebuilt into commits, and the CSS has styled nothing
since.

The extension comes from sourceUrl, which is "local:<filename>" for an upload
and is the field deriveQuality already reads. It is validated rather than just
uppercased, because it goes into markup: no dot, a leading dot, or anything
that is not one to five letters and digits falls back to the note. Artwork
wins when there is any, and the now-playing square is cleared when the studio
empties so it does not keep the last track's extension.
@thcp
thcp merged commit d277875 into main Sep 23, 2026
10 checks passed
@thcp
thcp deleted the fix/vocal-mode-combined-659 branch September 23, 2026 11:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment