Repository navigation
Rework the top bar, and fix three alignment bugs it uncovered - #665
Merged
Merged
Conversation
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
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 <>
This was referenced Sep 23, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: 48pxwhile every mixer row is sized from--lane-h, andrenderPlaceholderTracksbuilt one per stem plusoriginal, which the mixer hides until a job is loaded. Loading a track was always fine, which is why the existinglane-alignment.spec.mjsnever caught it.#662, the top bar declared vertical padding and then reset it to zero.
padding-topandpadding-bottom, thenpadding: 0 16pxa few lines later. Invisible while the composer is a fixed 50px in a 77px bar, becausealign-items: centerhid 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: noneuntil 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:00to05: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 ofmm:ssnow. 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 --checkover every module, i18n coverage clean,uv.lockuntouched. 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, andpanel-toggles.spec.mjsupdated for the removed Timeline toggle. The hint-line test fails without its fix.