Skip to content

The board becomes the widget: real sliders, a playhead, and a line to walk - #50

Merged
Gamah merged 14 commits into
masterfrom
feat/sbox-panel-rewrite
Aug 13, 2026
Merged

The board becomes the widget: real sliders, a playhead, and a line to walk#50
Gamah merged 14 commits into
masterfrom
feat/sbox-panel-rewrite

Conversation

@Gamah

@Gamah Gamah commented Aug 13, 2026

Copy link
Copy Markdown
Owner

The s&box panel was the panel the old vibe format left behind, and it had drifted into a different product from the web widget it came from. It is now drawn against <skafinity-player> as its design, and the parts of that design that are not layout live in UI/SkafinityBoard.cs — every user-visible string, the m:ss formatting, the playlist row's state word, the vibe-field grid — with no s&box type in the file, so the web can be pointed at it later without a rewrite. It is deliberately outside Code/Engine/ for now: a file in that folder is a file in the wasm bundle, and adding one costs an AOT re-stage for code nothing on that side calls yet.

The notched tick strips are gone, replaced by real sliders — though not the ones this started with. See the last section.

The transport is now a transport. Prev/play/next over a seek bar that scrubs, because a SoundStream cannot be rewound and a scrub is therefore a new stream on PCM already in memory. Pause is not a suspend for the same reason: it tears the stream down, keeps how far in it got, and comes back there. A slider reports every mouse-move and there is no let-go event to wait for, so the board holds the drag and tells the transport once it settles — the same one-seek-per-gesture the web gets from listening to change rather than input.

"Now playing" said the wrong song, and the seek bar is why it had to stop. A SoundStream is a FIFO: the crossfade into the next song is written while the current one still has bars to play, and _curN++ happened at push time, so the board named the next song seconds before anyone could hear it. Songs are now queued with the stream time their first sample lands at and promoted when the clock reaches them, which is also where the playhead's position comes from — the two cannot disagree.

Shuffle, and with it the split between a position and a song index. Everything keyed by n — the ledgers, the PCM cache, Prev/Next, the playlist — is keyed by timeline POSITION now. With shuffle off a position is the song index and the line is one station, which is what makes Prev able to walk back fifty songs that nothing remembers. With it on, every position past the first is its own station at song 0, derived from the root tag through SeedCodec.RollTagFor rather than drawn fresh, so a shuffled line is still a line and still reproduces from one string. The seed's genre and vibe get a switch each, because tag:n[:genre][:vibe] gives them a part each and one switch over both could not express half the seeds the engine parses; StationSeed joins CurrentSeed so the two copy buttons can mean the two different things they mean.

And the save stops lying about its channels. It passed channels: 1 for _curRaw, which is the interleaved stereo buffer SoundStream( _sr, MusicGen.Channels ) is fed: every in-game save was stereo PCM under a mono header, i.e. half speed with the channels folded into each other. It now passes MusicGen.Channels, and the playlist's per-row export renders a song outside the cache rather than refusing.

Clears PLAN row 98.


The sliders, and why the board builds its own

Worth reading before the next person reaches for a stock control here.

Every slider was invisible. Not mis-styled — absent, while laying out and accepting input: dragging one moved its value. Three rounds of CSS moved nothing, so a console command dumped the live panel tree, and that ended the guessing:

slidercontrol <SliderControl> .slidercontrol [892 282 727x0]
  div <Panel> .inner                         [892 282 484x0]
    div <Panel> .track                       [892 282 323x0]
      div <Panel> .track-active              [892 282 215x0]

The component is real, its children exist, .track-active even carries the right width for the value — and every height is 0. Nothing sizes those panels: the base addon's stylesheet, which dresses them in a game, does not reach this UI, and a stylesheet here cannot cross into another component's insides to do it instead. Nor can an inline style, which cannot reach a panel we did not create.

Two things ruled out a fix in this repo. A stock SwitchControl — another base component with its own sheet and an unmistakable pill to draw — is equally invisible. And terryball's own settings screen, copied in whole with only its wiring removed, does not draw its sliders here either: a panel known to work where it came from does not work in this project.

So UI/SkafinitySlider.cs owns the whole thing — three panels, every value set as an inline style, no cascade depended on. It also hands back something the stock control never could: the fill and thumb are painted with the runtime accent, so a host's colour finally reaches the sliders. The finding is recorded in CLAUDE.md, where a future session will be standing when it matters, and the open question (is this library-vs-game?) is tracked in Gamah/sbox-public#6 rather than left in the code.

One smaller engine behaviour came out of the same hunt and is worth knowing: Label.Text treats text longer than one character beginning with # as a localisation token, so #24 renders as 24. That is why playlist rows lost their number sign while the row you were hearing — ▶ #24 — kept it. Nothing builds "#" plus a number now.


Verified by review, by parsing every touched C# file with Roslyn, and by the node and engine suites (556 checks). The s&box half cannot be compiled here, so the API surface — SliderControl, DropDown, TimeSince, Label, Panel.Style — was read out of the s&box source rather than recalled; that is what caught the comparison of a TimeSince against a double, which has no operator and would not have built. The panel itself was verified in-game, iteratively, by @Gamah.

🤖 Generated with Claude Code

Gamah added 14 commits August 13, 2026 03:24
… walk

The s&box panel was the panel the old vibe format left behind, and it had drifted
into a different product from the web widget it came from. It is now drawn against
`<skafinity-player>` as its design, and the parts of that design that are not layout
live in `UI/SkafinityBoard.cs` — every user-visible string, the m:ss formatting, the
playlist row's state word, the vibe-field grid — with no s&box type in the file, so
the web can be pointed at it later without a rewrite. It is deliberately outside
`Code/Engine/` for now: a file in that folder is a file in the wasm bundle, and
adding one costs an AOT re-stage for code nothing on that side calls yet.

**The notched tick strips are gone.** s&box does have a slider — `SliderControl`,
with Min/Max/Step and a real thumb — and the claim that it does not was old. Each
knob is one, snapped to the level grid the seed encodes; a choice field is a
`DropDown` rather than a row of cells; the genre strip is a dropdown whose Random
entry is still the way back out of a genre. Theming a control this library did not
write needs one trick, since no inline style reaches another component's inner
panels: `color` inherits and `currentColor` resolves against it, so the wrapper
carries the accent and the scss spends it on the track and the thumb.

**The transport is now a transport.** Prev/play/next over a seek bar that scrubs,
because a `SoundStream` cannot be rewound and a scrub is therefore a new stream on
PCM already in memory. Pause is not a suspend for the same reason: it tears the
stream down, keeps how far in it got, and comes back there. `SliderControl` reports
every mouse-move and has no let-go event, so the board holds the drag and tells the
transport once it settles — the same one-seek-per-gesture the web gets from
listening to `change` rather than `input`.

**"Now playing" said the wrong song, and the seek bar is why it had to stop.** A
`SoundStream` is a FIFO: the crossfade into the next song is written while the
current one still has bars to play, and `_curN++` happened at push time, so the
board named the next song seconds before anyone could hear it. Songs are now queued
with the stream time their first sample lands at and promoted when the clock reaches
them, which is also where the playhead's position comes from — the two cannot
disagree.

**Shuffle, and with it the split between a position and a song index.** Everything
keyed by n — the ledgers, the PCM cache, Prev/Next, the playlist — is keyed by
timeline POSITION now. With shuffle off a position is the song index and the line is
one station, which is what makes Prev able to walk back fifty songs that nothing
remembers. With it on, every position past the first is its own station at song 0,
derived from the root tag through `SeedCodec.RollTagFor` rather than drawn fresh, so
a shuffled line is still a line and still reproduces from one string. The seed's
genre and vibe get a switch each, because `tag:n[:genre][:vibe]` gives them a part
each and one switch over both could not express half the seeds the engine parses;
`StationSeed` joins `CurrentSeed` so the two copy buttons can mean the two different
things they mean.

**And the save stops lying about its channels.** It passed `channels: 1` for
`_curRaw`, which is the interleaved stereo buffer `SoundStream( _sr,
MusicGen.Channels )` is fed: every in-game save was stereo PCM under a mono header,
i.e. half speed with the channels folded into each other. It now passes
`MusicGen.Channels`, and the playlist's per-row export renders a song outside the
cache rather than refusing.

Clears PLAN row 98.

Verified by review, by parsing every touched C# file with Roslyn, and by the node
and engine suites (556 checks). The s&box half cannot be compiled here, so the API
surface — `SliderControl`, `DropDown`, `TimeSince`, `currentColor` — was read out of
the s&box source rather than recalled; that is what caught the comparison of a
`TimeSince` against a `double`, which has no operator and would not have built.
Every slider was invisible — the transport's two included — because the sizing
went on a wrapper div and `SliderControl` itself was left with no width or height.
It brings its own chrome from the base stylesheet but nothing about its own box, so
a control nobody sized is a control at zero height: it draws as nothing, and no
error is raised anywhere. gambit and terryball both had this right already, with the
same `.wslider { width; height; pointer-events: all }` on the control, so the shape
here is theirs.

Three things came with it. The class carries the accent inline now, so the
currentColor rules can reach the track and the thumb — and they use child
combinators, because the base sheet styles `.slidercontrol > .inner > .track` and a
flatter selector would silently lose to it. `ShowValueTooltip="false"`, since the
built-in bubble renders as a bare black box with a hardcoded background and the knob
header already prints the value. And the seek bar takes `width: auto` rather than
100%, because the base sheet marks the control flex-shrink: 0 and a full-width one
between two time labels would push the row wider than the board.

Type and width, per the listening pass: one `$fs-*` scale at the top of the sheet so
the next "a bit smaller" is one edit, everything a step down from where it was, and
the board out from 900 to 1180.
The sliders were sized, laid out and draggable — songs scrubbed — and painted
nothing, which narrows it to the colours rather than the geometry or the base
stylesheet (the TextEntry and DropDown beside them are visibly styled by it). Both
of their painted parts were invisible: the track sat at white 0.07, and the fill and
thumb were `currentColor`, which is a recent engine feature and paints nothing at
all on a build without it.

So the slider's insides join the borders as NEUTRAL white alphas. That is the rule
this stylesheet already had — the theme owns fills, the sheet owns everything a
:hover could fight over — and a control this library did not write belongs on the
sheet's side of it for a harder reason than taste: no inline style reaches another
component's panels, so the accent was never going to get in there. gambit and
terryball both leave the internals alone for the same reason.

Two things the screenshot showed on the way past. A literal `#` next to a razor
expression was swallowed — "now playing 10", and playlist rows with no number sign —
so both labels are built in SkafinityBoard now, where they were always supposed to
be. And the volume sat mid-row: `margin-left: auto` put the tinker button at the far
end of the row below it but did nothing here, so the label beside it grows instead.
Still nothing, which rules out the colours and points at the geometry. Reading the
screenshot again: NOTHING visible on that board comes from a base-addon stylesheet.
The seed box's fill and border are this sheet's `.seed-input`, the dropdown's
background is an inline BtnStyle, and its chevron is an icon panel built in C#. So
there was never evidence the base sheet applies here — and without it SliderControl
has no size anywhere inside it. A track with no height paints nothing however it is
coloured, which is the whole bug: the board sized the CONTROL, so it laid out and
dragged and scrubbed songs, while every part inside it stayed at zero.

So the slider is described here in full — inner, track, active fill, thumb — and
nothing about it is left to a sheet that may not be there. Where that sheet does
apply these rules simply win: each is a class deeper than its `.slidercontrol > …`
equivalent and the block is scoped under the panel's own element besides. The parts
the control sets inline (the fill's width, the thumb's left) are untouched, and the
track keeps a horizontal inset because a thumb centred on 0% needs somewhere to hang.
…s label

Five knobs across the board is not much per cell, and the header made it worse by
pushing the name and the value to opposite ends of one: hard against the right edge,
a value sits closer to the NEXT column's name than to its own, so the drums' tone
and the name of their character knob read as a single caption — "40% BUSY". They go
together at the left now, and where the column header already names the knob the
value simply starts the line instead of floating mid-cell.

The rest is width the layout can afford without losing a column: a wider gutter
between cells and a little more air between rows.
Three fixes from reading a screenshot have not moved the sliders, so the next step
is a fact rather than a fourth guess: `skafinity_ui_dump` walks the board's panel
tree and logs each panel's element name, runtime type, classes and screen rect. It
settles the one question that cannot be answered from the source here — whether
`<SliderControl>` resolved to the component at all. A line reading `slidercontrol
<Panel>` with no children means the tag fell back to a plain panel, which can
neither draw nor be dragged and raises nothing; `<SliderControl>` with an
`.inner > .track` under it means the component is real and the rects say which part
collapsed. TEMPORARY, and marked as such.

The number sign is settled, though, and it was never markup: `Label.Text` treats
text LONGER than one character beginning with `#` as a localisation token, looks the
rest up as a phrase, and renders what comes back — so `#24` became `24`, while
`▶ #24` was left alone for not starting with one. That is why the playlist's rows
lost their hash and the row you were hearing kept it. Nothing here builds "#" plus a
number any more: on the transport the hash ends the words before it, and in a row it
stands alone in a panel of its own, where the length rule leaves it be. The caret
becomes a column of its own while we are there, so every row's number starts at the
same x.
The panel dump settled what three rounds of CSS could not. SliderControl's insides
are all zero height — the volume one carries only its own `.slidercontrol` class,
untouched by anything here, and its track still measures 323x0. A track with no
height paints nothing however it is coloured, which is why every attempt at the
colours moved nothing.

Two walls, both visible in that dump. A STATIC class attribute on a razor component
is dropped: `class="ska-slider vol-slider"` never arrived, while the seek bar's —
whose value is an expression — did, and replaced the control's own class in the
bargain. And a stylesheet does not cross into another component's insides: `.inner`,
`.track` and `.thumb` are built by that control's razor, so every rule written for
them matched nothing. Neither does an inline style, which cannot reach a panel we
did not create. The base stylesheet that dresses those parts in gambit and terryball
is not applying here either, and nothing else on this board shows base-addon styling
to suggest otherwise.

So the board owns its slider: three panels, every value set inline from C#, no
cascade to depend on. That is the right shape for a drop-in library in any case —
it can assume nothing about the stylesheets of the game it lands in. And it hands
back something the stock control could never give: the fill and the thumb are
painted with the runtime accent, so a host's colour finally reaches the sliders.
The board is not a fair place to ask whether SliderControl draws: it has a
stylesheet, a runtime palette, inline styles and five columns of layout around every
slider, and any of those could be the thing in the way. So this asks the question on
its own — a plain panel, its own ScreenPanel, and three sliders that differ only in
what is on them.

  1. bare, no class and no styles at all;
  2. `.wslider` — width, height, pointer-events on the control, which is the whole
     of what gambit and terryball do and the reason theirs worked first shot;
  3. the same inside a box with a visible background, so an invisible slider can be
     told apart from a slider that was never built.

All three write to one value, printed at the top, so a drag says which of them is
live even where nothing is drawn. `skafinity_slider_test` puts the card up and takes
it down again; `skafinity_ui_dump_test` prints its rects in the same format as the
board's dump.

Temporary, and the custom slider is reverted while this settles it.
The value moves and nothing draws, which says the control is built and takes input
while none of its parts is sized — so the question is no longer about the slider. It
is whether a base-addon stylesheet reaches this project at all, and the card can ask
that directly.

A SwitchControl joins it: another razor component from the base addon, with a sheet
of its own that draws something unmistakable — a 48x16 pill with a round knob. If
the pill is there, base component stylesheets apply and the slider's trouble is its
own. If it is as invisible as the sliders, nothing of the base is reaching this UI,
and no markup will draw a control whose every part is sized by a sheet that is not
there. A stock <button> goes beside it, whose chrome comes from a plain class rule
rather than a component's sheet, to separate "no base stylesheets" from "no base
COMPONENT stylesheets".

Dropping a sibling panel in would not add to this: test 2 is already gambit's usage
verbatim, and both siblings are @inherits PanelComponent with no package references,
exactly like this panel. The difference that is left is that they are Type "game"
and this is Type "library".
The switch is invisible too, so this is not about sliders: a second base-addon razor
component, with its own stylesheet and an unmistakable 48x16 pill to draw, renders
nothing here either. (The button probe was badly chosen and says nothing — `.button`
in the base sheet only sets position: relative, so it paints nothing even when it
does apply. My mistake.)

So the panel that is known to work goes in whole. This is terryball's WorldSettings
screen with the WIRING removed and nothing else touched — markup, inline styles and
the <style> block character for character, minus TerryAvatar (the modal is simply
always up), WorldPrefs (fields instead of cookies) and WorldTheme (its colours as
literals). `skafinity_slider_test terryball` stands it up in place of the card; the
same command with no argument brings the card back, and either way only one is ever
on screen.

If its sliders draw here, the skafinity board is at fault and that file is the diff
to read. If they do not, a panel cannot be the difference, and what is left is the
project it is loaded into.
terryball's own settings screen, copied here whole and unchanged but for its wiring,
does not draw its sliders either. That is the end of the question: a panel known to
work where it came from does not work here, so nothing written in this library is
the cause, and no arrangement of markup will fix it. The stock control cannot be
drawn in this project, and the board keeps SkafinitySlider — three panels of its
own, painted inline from C#, which also lets the runtime accent reach the sliders
for the first time.

Out with the diagnosis: both test cards, `skafinity_slider_test`, the panel-tree
dumps. They did their job and they are in the history if it is ever needed again.
What stays is the FINDING, in the one place a future session will be standing when
it matters — do not reach for a stock Sandbox.UI component in this panel, because it
will lay out, accept input, and draw nothing, with no error anywhere.
@Gamah
Gamah merged commit a558a6a into master Aug 13, 2026
1 check passed
@Gamah
Gamah deleted the feat/sbox-panel-rewrite branch August 13, 2026 06:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant