Skip to content

Rework the top bar, and fix three alignment bugs it uncovered - #665

Merged
thcp merged 6 commits into
mainfrom
dev/ui-update
Sep 22, 2026
Merged

thcp merged 6 commits into
mainfrom
dev/ui-update

Conversation

@thcp

@thcp thcp commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #661
Closes #662
Closes #663
Closes #664

Five commits, one per change. Three are bugs that exist on main today and stand on their own; two are the layout work that found them.

The bugs

#661, the empty studio drew seven lanes against six faders, at a different pitch, so a stem's name and the line for its waveform were on different rows by the third one. The placeholder lane was a hardcoded height: 48px while every mixer row is sized from --lane-h, and renderPlaceholderTracks built one per stem plus original, which the mixer hides until a job is loaded. Loading a track was always fine, which is why the existing lane-alignment.spec.mjs never caught it.

#662, the top bar declared vertical padding and then reset it to zero. padding-top and padding-bottom, then padding: 0 16px a few lines later. Invisible while the composer is a fixed 50px in a 77px bar, because align-items: center hid it evenly, and immediately visible the moment anything makes that bar taller.

#663, the first track of a session had its lanes 3px out per row, growing to 15 by the bottom, and every later track was fine. The click-track hint under the footer is display: none until the beat analysis lands, then takes 22px off the wave panel. Lane heights are divided out of that panel and baked into the multitrack as its tracks are built, so on the first track they keep the taller row while the mixer follows the shorter one. The hint holds its line now. Re-measuring afterwards does not work and the issue records why, so nobody repeats it.

The layout

#664, the bar spent three lines on two lines of content. It is a grid now: row one is upload, search, the loaded track, then the two actions; row two is Extract and its chips with the Collapse row sharing that line.

The search and the chips share column one, which is what makes them the same width without either being measured against the other. The previous indent that lined the search up with the first chip was the width of the English word "Extract".

Two blocks moved in the markup rather than being positioned on top of the layout: the now-playing block out of the footer, and the Collapse row out of the composer's stack. Both are grid cells, so they push their neighbours instead of overlapping when a window narrows. What gives way below 1450 and 1320 is measured, not picked.

Three things went with the footer's waveform strip: the strip, the Timeline toggle that governed a region with nothing left in it, and the dashed rectangles the multitrack library draws on empty lanes offering a drag and drop this app does not do.

Also here, and the one thing without an issue behind it: the click-track controls stay on the row when there is no beat grid, greyed and disabled, rather than vanishing and coming back. The cost is stated in that commit: the strip is permanently ~700px wider, so the options fold behind the chevron at a wider window than before.

Since the PR opened: a sixth commit stops the footer controls moving when a track loads. The readouts go from 0:00 to 05:09, one character wider, which grew the Position cluster by 17px and slid Speed, Global key and Click track across by the same amount. They hold the width of mm:ss now. It also gives the click-track hint its own left inset, since it used to inherit one from the footer gutter the now-playing block filled.

Verified

Playwright 189 passing, pytest 1078, ruff check and format, the JS unit tests, node --check over every module, i18n coverage clean, uv.lock untouched. Geometry measured at 1600, 1500, 1400, 1280 and 1100, and the first-track drift measured across three cold sessions with three different tracks.

Two new tests in lane-alignment.spec.mjs, two for the empty state, and panel-toggles.spec.mjs updated for the removed Timeline toggle. The hint-line test fails without its fix.

Thales added 6 commits September 22, 2026 15:03
Open the app and look at the stack before loading anything: seven flat lines
against six faders, drifting further apart with every row, and the last line
with no fader at all.

Two faults, both in the empty state only.

The placeholder lane was a flat 48px while every mixer row is sized from
--lane-h, which is 77px on an empty studio. The two columns ran at different
pitches from the first row, so a stem's name and the line for its waveform were
on different rows by the third one. --lane-h is the row, and a row is a lane
plus its 2px separator, so the lane is the variable less the separator.

And there was one placeholder too many. renderPlaceholderTracks built one per
stem plus "original", but the mixer hides its original row until a job is
loaded: applyStemSelectionFilter is called here with STEM_NAMES, and original
is not in that set. The set the mixer shows is the set to draw, so the two are
the same list now.

Loading a track has always been fine, which is why this survived: the real
lanes and the mixer both come from the same row height once a job exists. It is
the state before that which nothing was checking.
.daw-topbar sets padding-top and padding-bottom, and then a few lines later
sets `padding: 0 16px`, which resets both to zero. A shorthand after longhands
wins, so the bar has never had the vertical padding the comment above it
describes.

Nothing showed it. The composer inside is a fixed 50px in a bar with a 77px
minimum, and align-items: center hid the missing space evenly. It only appears
once that composer is taller than the bar's minimum, and then the box fills the
bar edge to edge and its top border sits against the top of the window.

One declaration instead of three, in the order that does what it says.
The top bar was one row of controls competing for the same horizontal space,
with the Collapse row on a line of its own underneath: three lines of bar for
two lines of content, and on a narrow window the search field and the Extract
chips squeezed each other.

It is a grid now. Row one is the import gesture, left to right: upload, the
search field, the loaded track, then the two things that act on it. Row two is
Extract and its chips, with the Collapse row sharing that line at its right
hand end.

The search and the chips share column one. That is what makes them exactly the
same width, without either being measured against the other: as a flex row the
search grew into whatever was left over and ran past the last chip, and the
fixed indent that lined its left edge up with the first chip was the width of
the English word "Extract" and drifted in every other language.

Two blocks moved in the markup rather than being positioned on top of the
layout. The now-playing block came out of the footer, where it filled the
gutter beside a timeline that is no longer there; the Collapse row came out of
the composer's stack and into the grid. Both are cells now, so they push the
things around them instead of overlapping when a window gets narrow.

What gives way, and in what order, is measured rather than picked. The chips
need 655px at their widest, the toggles 280, Detect structure 164 and Split
stems 140, which is 1299px of composer before the loaded track gets a pixel.
Below roughly 1450 the track card goes first, since the track is named in the
library and the mixer too; below 1320 the Collapse row drops to a line of its
own, which is where it used to live.

Three things went with the footer's waveform strip. The strip itself, which
duplicated the lane waveforms above it; the Timeline toggle, which governed a
region with nothing visible left in it; and the dashed rectangles the
multitrack library draws on empty lanes, which offer a drag and drop this app
does not do.

Detect structure reads as a switch rather than a word to press. Same markup,
same aria-pressed, drawn from two pseudo elements.
On the first track of a session the waveform lanes sat 3px lower than their own
faders, then 6, then 9, all the way down the stack. Every later track was fine,
and going back to the first one fixed it.

The cause is a layout change arriving a second late. The hint under the footer,
"Clicking 127.0 BPM, 77% of beats sit on a drum hit", is text from the beat
analysis. It was display: none until it had something to say, and then took
22px off the wave panel when it did.

Lane heights are divided out of that panel and then baked into the multitrack
as its tracks are built. On the first track that build happens before the hint
arrives, so the lanes keep the taller row while the mixer, sized in CSS from
--lane-h, follows the shorter one. Every later track is built after the hint
already exists, which is exactly why only the first one was ever wrong.

So the line is reserved rather than the change reacted to. The alternative was
re-measuring afterwards, and it does not work: a lane's height reaches the DOM
through the multitrack's own render, and calling setOptions on the wavesurfer
afterwards does not move the row it has already built.

Measured before and after, first track then second: 77 against 74 becomes 74
and 74, and the second stays 86 and 86. Three cold sessions with three
different first tracks, zero drift on every row.

Two tests. One that the first track lines up, and one that the hint occupies
the same height whether or not it has anything to say, which is the property
holding the rest up. The second fails without this change.
Open the app with nothing loaded and half the control strip is missing:
Count-in, the bar selector, Grid and the rate buttons are not there. Load a
track and they appear. It reads as a fault rather than as a state.

They were hidden outright whenever a track had no beat grid, and with no track
at all. The reason is in the code's own comment: the options are worth about
700px, roughly half the strip, so taking them away was how the footer kept
fitting. That made them the one thing in that row behaving differently from
everything else. The metronome button beside them is disabled rather than
removed, and so is every unavailable lane in the mixer.

So they stay and go inert: a class that greys them, and `disabled` on each
control, which is also what a screen reader is told rather than being shown a
live-looking panel that does nothing.

The cost is real and worth stating. The strip is now permanently that 700px
wider, so the footer folds the options behind the chevron at a wider window
than it used to. At 1920 they are inline whether or not a track is loaded; at
1600 they are behind the chevron in both states, where the empty studio used to
look roomy because half the row was absent.
Two things shifted in the footer, both because something beside them changed
size, and both measured rather than eyeballed.

Clicking a track pushed every cluster right of Position across by 17px: Speed
from 851 to 868, Global key 977 to 995, Click track 1139 to 1157. The readouts
go from 0:00 to 05:09 when a duration arrives, which is one character wider,
and Position grew with them. Both readouts now hold the width of mm:ss whatever
they are showing, with tabular figures so the digits do not jog either. The
space is reserved rather than the format zero-padded, because how it reads is
the readout's own business: a short track should still say 5:09, and one over
an hour should still grow.

And the click-track hint sat 18px left of everything else, against the bare
edge of the footer. It had no left inset of its own because it inherited one
from the gutter the now-playing block used to fill. That block is in the
composer now and the gutter went with it, so the hint needs its own, and starts
on the same x as Transport.
@thcp
thcp merged commit a5095ac into main Sep 22, 2026
10 checks passed
@thcp
thcp deleted the dev/ui-update branch September 22, 2026 19:34
thcp pushed a commit that referenced this pull request Sep 23, 2026
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 added a commit that referenced this pull request Sep 23, 2026
… link the logo (#679)

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* Keep Split shut through a submit, and leave the phone's guard alone

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.

* Show an uploaded file's extension where it has no artwork

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.

---------

Co-authored-by: Thales <>
thcp added a commit that referenced this pull request Sep 23, 2026
The seven metadata cards size their text in fixed pixels while the row
they sit in is a seven column grid that tracks the window. Below about
1250px the text is wider than the card holding it, and flex-shrink:0 on
the value meant it did not stop at the card edge: "Natural Minor" at
17px is 133px of monospace in a 135px card, painted over the Dynamic
Range value next to it.

The long labels had the same problem in the other direction. "DYNAMIC
RANGE" and "TEMPO STABILITY" wrapped to a second line, and because the
card centres its content the second line landed on top of the value
rather than below it.

Values, labels, qualifiers, padding and gaps now scale with the viewport
through clamp(), the way the top bar does since #665. Labels are pinned
to one line, and both they and the values clip with an ellipsis as a
last resort rather than escaping the card. The qualifier shrinks four
times as fast as the value beside it, so "Peak -0.3 dB" gives up its
width before the number does.

The presence row below gets the same treatment, which is what stops
"BASS DEPTH" reading as "BA..." at the width where the row above was
already broken.

Measured rather than eyeballed: rendering the panel's real markup
against both stylesheets and checking every span for overflow, wrapping
and clipping shows the old CSS overflowing at 920px and 1000px and
clipping five labels, and the new CSS clean at 420, 600, 880, 920,
1000, 1100, 1250, 1440, 1600 and 1920px.

Closes #688
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant