Skip to content

Let the user put a panel away, and make the lanes actually fit - #500

Merged
thcp merged 5 commits into
mainfrom
feat/collapsible-panels
Aug 30, 2026
Merged

Let the user put a panel away, and make the lanes actually fit#500
thcp merged 5 commits into
mainfrom
feat/collapsible-panels

Conversation

@thcp

@thcp thcp commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Four problems around the mixer, found by pulling on one thread: #480 asked for a
way to reduce the panels around it, and building that exposed three defects the
panels had been hiding.

Addresses #480, which stays open: this is a way to reclaim the space, not a
decision about what the studio shows on first open, and collapsing is all or
nothing per panel where the report asked for one row of it.

Closes #497
Closes #498
Closes #499

Put a panel away when you are not using it (#480, partial)

#480 was not asking for smaller panels. Its reporter said so directly:

every one of those panels shows something useful [...] The issue is that they
are all mandatory at all times. I do not need the presence percentages while I
am setting fader levels, and I do not need the waveform while I am reading the
analysis. Right now the app has no way to say that.

So this is a way to say it. Three toggles collapse the analysis header, the
sections bar and the footer timeline, each handing its height to the mixer:

Mixer Lanes visible Scrolls?
Default 370 px 5 of 6 yes
Analysis off 443 px 6 of 6 no
All three off 573 px 6 of 6 no

One click on Analysis is enough. Together they are worth 202 px.

They collapse to nothing rather than to a stub, which is only possible because
the controls live somewhere else: a stub tall enough to hold its own control
costs most of what collapsing the smaller panels returns. So all three sit in one
row under the composer, sharing the right edge and the exact width of the two
controls above them, from the same --composer-action-w those are sized from.
The topbar does not grow to hold it: it already carried 27 px of slack around a
50 px composer.

Legible means the panel is there, struck through means it is put away. The other
way round inverts what a pressed toggle looks like everywhere else, and makes the
default state the loud one.

This does not decide what the studio shows on first open. Every panel is
still there by default. Whether that default is right is a separate question.

Lanes never fitted the panel (#497)

Found by collapsing a panel and watching the space go nowhere. The lanes were
sized once, at track load, and never again.

Window resizing already showed it, with no toggles involved: on a 1366x768
window the panel is 370 px and each lane 72 px; drag the window to 1200 px tall
and the panel becomes 802 px while the lanes stay at 72. 432 px unused. The
bigger the display, the more of it the waveforms refused to occupy.

Three things had to change for the lanes to follow: the fit runs again from an
observer on the wave panel, .waves-column takes a floor from the stack height
rather than the multitrack's frozen lane height, and the maths allows for the
2 px separators it had been ignoring.

A name and its waveform drifted apart (#498)

The mixer strip and the waveform lanes are two columns of the same rows, and
nothing made them agree. At 1600x768 after a resize from 900: mixer rows 72 px
apart, waveform rows 83 px apart, the last pair 55 px out of line. By the
last stem the label was most of a row from the waveform it names.

They were built from different numbers. The mixer stack is count * --lane-h,
recomputed from the panel; the waveform column took its height from the
multitrack, whose lane height is fixed when its tracks are created. The computed
stack said 432 px and the column was still 498.

Two smaller errors in the same place, both pushing the same way: every mixer row
draws a 2 px bottom border while the stack counted count - 1 separators, and
the stem name sat 7 px above the row's centre because it and its meter are
centred as a pair while the waveform is centred on the line itself.

Zero row drift now across twelve window heights from 720 to 1300, including the
range where lanes overflow rather than fit, which is where the 55 px came from.

Song structure was a preference (#499)

Switched on once it stayed on: for that import, for every import after it, and
for every session until someone remembered. Stored server-side, so it survived a
restart and applied to anyone else pointed at the same StemDeck. The pass costs
minutes of CPU per import.

It is off at startup now, and off again as soon as the studio loads a song. The
button and the setting move together, because the setting is what the runner
reads.

That alone would have introduced a worse bug than it fixed. The runner read
the setting when it reached the sections stage, the last thing the pipeline does,
and a comment said that was deliberate. Correct for a preference, wrong for a
per-import choice: a user who switched it on, started an import and browsed to
another track while waiting would have lost the pass they asked and waited for.
Silently, with no sections and no explanation.

So the decision moved to the moment the user makes it. The job carries the flag,
captured at creation, at all three places a job is created -- URL submit, file
upload, and playlist import, where one read covers the batch.

Verification

83 browser tests pass, five of them new. Backend 845 pass; the 14 failures are
pre-existing and reproduce on main, nine of them because this machine has no
ffmpeg.

Every new guarantee was checked against a build with the fix removed: the three
alignment tests, and both toggle tests.

i18n complete at 474 keys across all nine tables. ruff check, ruff format --check and node --check clean.

Before merging

  • uv.lock untouched, so the desktop in-app updater is unaffected.
  • The Unraid template pin stays at 0.15.2: it moves after a release is
    published with all its assets, not at merge time.

Thales added 5 commits August 30, 2026 17:05
The lanes were sized once, when a track loaded, and never again. Everything the
panel gained after that became empty space under them rather than taller lanes.

Resizing the window was enough to show it. On a 1366x768 window the panel is
370px and each lane 72px; drag the window to 1200px tall and the panel becomes
802px while the lanes stay at 72, so 432px of it goes unused. The bigger the
display, the more of it the waveforms refused to occupy.

Three things had to change for the lanes to actually follow.

_applyLaneHeight runs again on a resize, from an observer on the wave panel. The
panel is flex: 1 inside a fixed-height column, so its height comes from its
parent and never from the lanes: writing lane heights from that callback cannot
feed it its own output.

.waves-column takes a floor from the stack height that function already
computes. Its natural height is the multitrack's, whose lane height is fixed
when the tracks are created, so without a floor the column stayed 432px however
much room it was given and the lanes distributed across the old size.

And the height maths allows for the separators. It divided the whole panel by
the lane count, but the stack is lanes plus the 2px between them, so it
overshot by exactly that. Invisible for as long as the lanes sat at their 70px
floor and overflowed anyway; it surfaces as a 7px scrollbar the moment they fit.

The streaming path keeps its load-time height deliberately. Its lanes are
WaveSurfer canvases, and setOptions({height}) only re-renders the ones that have
audio, so a lane for a stem the user did not extract keeps the old size: 93, 93,
93, 70, 70, 93 on a six-lane job with two empty, against mixer rows all at 95. A
mixer column out of step with its waveforms is worse than unused space, so the
refit is limited to the path where the SVG overlay is what you see.

Closes #497
Six stems separated and two visible: on a 1366x768 laptop the panels around the
mixer took roughly two thirds of the window and the mixer got what was left
(#480). Making them smaller only moved the number, which is why that issue was
left open after the last round trimmed 116px out of them. The reporter had
already said what the actual problem was:

    every one of those panels shows something useful [...] The issue is that
    they are all mandatory at all times. I do not need the presence percentages
    while I am setting fader levels, and I do not need the waveform while I am
    reading the analysis. Right now the app has no way to say that.

So this is a way to say it. Three toggles collapse the analysis header, the
sections bar and the footer timeline, each handing its height straight to the
mixer:

    default        mixer 370px   5 of 6 lanes   scrolls
    analysis off   mixer 443px   6 of 6 lanes   fits
    all three off  mixer 573px   6 of 6 lanes   fits

One click on Analysis is enough. Together they are worth 202px.

They collapse to nothing rather than to a stub, which is only possible because
the controls are somewhere else: a stub tall enough to hold its own control
costs most of what collapsing the smaller panels returns. So all three live in
one row under the composer, sharing the right edge and the exact width of the
two controls above them, from the same --composer-action-w those are sized from.
A longer translation widens all three together instead of leaving the row
ragged. The topbar does not grow: it already carried 27px of slack around a 50px
composer, and the row fits inside it.

Legible means the panel is there, struck through means it is put away. The other
way round -- highlighting the hidden ones -- inverts what a pressed toggle looks
like everywhere else and makes the default state the loud one.

A class on .app and a flag in localStorage each, the same shape as the sidebar
collapse, with no state anywhere else.

This does not decide what the studio shows on first open. Every panel is still
there by default, which is a separate question from whether the user can put one
away.

Closes #480
The mixer strip and the waveform lanes are two columns of the same rows, and
nothing made them agree. Resize the window and they walked apart, worst at the
bottom: at 1600x768 after a resize from 900, mixer rows sat 72px apart, waveform
rows 83px apart, and the last pair was 55px out of line. By the last stem the
label was most of a row away from the waveform it names.

They were built from different numbers. The mixer stack is count * --lane-h,
recomputed from the panel height. The waveform column took its height from the
multitrack, whose lane height is fixed when its tracks are created, so after a
resize the column was still the size it was at load and its rows distributed
across that. The computed stack said 432px; the column was 498.

The column is now that same count * --lane-h. The two agree by construction
rather than by coincidence, and the multitrack is no longer the source of truth
for a height it cannot change after the fact.

Two smaller errors in the same place, both of which only ever pushed the same
way. Every mixer row draws a 2px bottom border while the stack counted
count - 1 separators, so the row is now the shared unit and both columns get the
identical total. And the stem name sat 7px above the row's centre line, because
the name and its meter are stacked and centred as a pair while the waveform
beside them is centred on the line itself; the name centres now and the meter
hangs below it.

Zero row drift across twelve window heights from 720 to 1300, including the
range where the lanes overflow rather than fit, which is where the 55px came
from. Name drift is 1px of rounding. All three new tests were checked against
the unfixed build, which fails them.

Closes #498
…rence

Switched on once, it stayed on: for that import, for every import after it, and
for every session until someone remembered to switch it off. It is stored
server-side, so it survived a restart and applied to anyone else pointed at the
same StemDeck. The pass it enables costs minutes of CPU per import, and a
setting that quietly keeps spending that long after the song it was turned on
for is a cost nobody chose.

It is off at startup now, and off again as soon as the studio loads a song. The
button and the setting move together, because the setting is what the runner
reads: clearing only the button would leave the next import still paying for a
pass nobody asked for.

That alone would have introduced a worse bug than it fixed. The runner read the
setting when it reached the sections stage, which is the last thing the pipeline
does, and there was a comment explaining that this was deliberate: it let a
change apply to the next job without a restart. Correct for a preference, wrong
for a per-import choice. With the toggle clearing itself on the next song, a
user who switched it on, started an import and browsed to another track while
waiting would have lost the pass they asked and waited for, silently: job done,
no sections, no explanation.

So the decision moves to the moment the user makes it. Job carries the flag,
captured at creation from the setting, at all three places a job is created --
URL submit, file upload, and playlist import, where one read covers the batch so
every job in it agrees. The stage reads the job.

The runner tests set the flag on the job rather than patching a setting the
runner no longer reads. Two new ones cover the thing that is easy to regress:
the API captures the setting at submit, and the stage honours the job's flag
even when the live setting says otherwise.

Closes #499
The backend half of that change is tested: the flag is captured at submit, and
the stage honours the job's flag over the live setting. The browser half was
not, and it is the half that regresses quietly -- a button that looks off while
the setting behind it is still on would leave the next import paying for a pass
nobody asked for.

So both assertions check both. Checked against a build with the reset removed,
which fails them.
@thcp
thcp merged commit 5997f92 into main Aug 30, 2026
10 checks passed
@thcp
thcp deleted the feat/collapsible-panels branch August 30, 2026 16:41
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