The bar explained itself in prose: "All bands visible by default" sat
between the chips and the next group, taking width the bar could not
spare and reading as a stray line of text. An "All" chip says the same
thing in a chip's width and gives the selection somewhere to be undone.
Band chips also came up dimmed at the very moment every band was on the
map -- an empty selection is no filter at all, so nothing is dimmed
until something is picked. The path toggles drop their "On"/"Off"
suffix, which cost most of a row and only repeated what their own
highlight already said; state moves to aria-pressed and the tooltip.
The rest is alignment. The rule dividing the buttons from the filters
is drawn on the button block's edge, and a centred block left it
floating as a stub beside a two-row bar; stacked, it lay down the left
of a block that sits underneath. The labels sat at their natural
widths, so each row's first control started somewhere different, and
the two pairs of phase buttons differed in width, so the groups after
them missed each other by four pixels. One gutter for every label, one
width for both pairs, and the search field moved last where it can take
the room the fixed-width groups leave.
The map layout test now covers the chips, the divider's height and the
rows' shared start.
Signed-off-by: Stan Grams <sjg@haxx.space>
Fullscreen and Hide Filters floated in their own block over the map's
top-right corner, separate from the filter bar they sit beside.
Put them at the right-hand end of the bar, behind a separator. What made
this awkward before is that Hide Filters cannot live inside the thing it
hides, so the collapse now applies to the filters alone: the bar keeps its
two controls and shrinks to them at the map's right edge, leaving the whole
map visible and the way back one click away.
Fixes#38
Signed-off-by: Stan Grams <sjg@haxx.space>
The filters were a 30rem column parked in the bottom-right corner, covering
a third of the map they filter. Lay them out horizontally instead: one row
per group -- label beside its control, thin rules between -- across the top
of the map, spanning ~87% of its width at 1600px and wrapping to a second
row as it narrows.
It starts clear of Leaflet's zoom buttons and stops short of the corner
controls, which stay outside it: the button that hides the filters cannot
live inside the thing it hides. The bottom-left band legend keeps its place.
The sentence explaining the two path toggles would have swallowed the bar,
so it moves to their tooltips and is shown inline only in the stacked
narrow-screen layout.
Signed-off-by: Stan Grams <sjg@haxx.space>
renderLocalAprsSymbol() returns nothing when a packet carries no symbol
table or code, so those rows lost the icon's 24px slot and every column
after it -- callsign, type badge, summary -- slid left against the rows
around them. Frames that do carry a symbol then read as indented.
Render an empty slot of the same size instead, so a list mixing position
reports with messages and telemetry still lines up.
Signed-off-by: Stan Grams <sjg@haxx.space>
A message was three stacked lines — time and name, then MMSI and route,
then motion, distance, position and age — so a screen held eight of
them. It is one line now: time, vessel, message type, and what the
message says, opening in place for the MMSI, the channel frequency, the
route, the age, the fix and a jump to the map. Twenty-two fit where
eight did.
What a message says depends on what it is. Position reports give the
fix and the motion; the static and voyage reports that carry no fix give
the callsign and where the vessel is bound. Both fall back to whatever
fields are present rather than showing nothing.
The row vocabulary the APRS list introduced is no longer APRS-specific —
the classes are decode-line and decode-expanded now, shared by both, and
identity sits in fixed columns so the summaries line up down the list
instead of starting wherever the callsign happens to end. The three
summary cards above the list go the way of the APRS ones.
Signed-off-by: Stan Grams <sjg@haxx.space>
A frame was a card five lines tall — timestamp, a meta line, the
information field as it arrived on the air, three buttons, and a Details
panel repeating the four things already on the row — so five frames
filled the panel and the payload was left to be decoded by eye.
A frame is one line now: time, station, type, and what the frame says.
It opens in place for the path, the CRC, the raw field, its bytes and
the actions. Twenty-one frames fit where five did.
And the information field is read rather than echoed. Weather reports
give temperature, wind, humidity and pressure; telemetry gives its
sequence and channels; a message gives its addressee and text; a
position gives the fix, course and speed, and the comment the station
wrote. Anything that cannot be summarised falls back to the raw field,
which is in the expanded view either way.
HF APRS had a copy of the same forty lines of markup, differing by one
badge, so both now build their rows from one function in the shared
module — the CSS is shared between them and this would have broken it
otherwise. Both headers lose their three summary cards for a line of
counts beside the filters, which frees another fifth of the panel.
Signed-off-by: Stan Grams <sjg@haxx.space>
FT8, FT4, FT2 and WSPR size their list against the panel with flex, and
the sidebar layout made the panel a grid item aligned to the start of
its row — sized to its own content. The lists collapsed to their 120px
minimum with several hundred pixels of the page empty underneath. The
panel stretches to the row now and the sidebar keeps its own height.
The marine lists were sized a different way, by formula: 100vh minus a
guess at everything above them. That guess stopped matching the moment
the panel changed shape, so they left a few hundred pixels unused as
well. They fill the panel like the rest now, and so does CW, which had
a 360px ceiling.
HF APRS had no container styling at all — no scroller, no frame, no
height — so its packets ran down the page. It gets what the other
packet lists have.
The smoke test measures each list against its panel and requires it to
scroll on its own.
Signed-off-by: Stan Grams <sjg@haxx.space>
Reloading a second time sometimes showed history the first load did not,
and the safety valve is why: it called one function that both released
the buffered live decodes and tore the history worker down, so any load
where the replay had not finished inside twenty seconds — a large
backlog, a cold cache, a slow link — dropped whatever had not arrived,
without a word. A reload got another go at it, and the second one is
faster because everything is cached by then.
Those are two separate things now. At the timeout the live decodes are
released so the panels are not held back, the replay carries on, and the
progress says so. The fallback's error path retries once and then says
"Decode history unavailable" rather than leaving the operator to guess
whether there was anything to see.
The progress is no longer a scrim. It was fixed to the whole viewport
with a wash over the page — the waterfall, the decode panels, all of it —
for the length of the replay, which is exactly when there is something
worth watching. It is a corner card with a bar: indeterminate while the
payload is on the wire, then filling as N of M messages replay.
None of this was reachable from a test. /decode/history answers in CBOR
and the worker reads the body as CBOR unconditionally, but the fixture
served JSON, so every browser run had been exercising the client's retry
path and never its history path. It encodes CBOR now, including the
64-bit form the millisecond timestamps need, and decode-flow serves 1200
records and holds the client to restoring all of them on the first load,
showing progress while it does, and never covering the page with it.
Signed-off-by: Stan Grams <sjg@haxx.space>
Thirteen decoders in a horizontal strip needed a scroller on anything
but a wide window, and the open one was marked by a single underline
among thirteen. They are a list down the left now, all visible at once,
each keeping the state dot it already carried, with the panel for the
selected one filling the rest of the width.
No script changed: the sub-tab wiring, the aria roles, the decoder
picker and the state-dot observers all work on the same markup, so this
is layout only.
Below 760px the sidebar gives way to the picker that already existed
there. That path needed align-content: the tab panel fills the page
height and a grid stretches its rows to match, which handed the picker a
218px row and left a 189px gap under it.
The tab icon was the signal-strength bars, which is what the S-meter
shows two rows above it; a pulse train says digital modes instead.
Signed-off-by: Stan Grams <sjg@haxx.space>
The row carries three unrelated things — how loud it is, whether there
is any audio at all, and how much is arriving — and only the middle one
was named. Each is a group now: VOLUME over the two sliders, SQL on its
own switch, LEVEL over the meter, with a hairline between each.
The rules are drawn only while the row is one line, measured against the
row rather than the viewport: what fits depends on whether the rig
transmits and whether it has a squelch at all. A rule divides what sits
either side of it, so once a group wraps the wrap is the division and
the rule would just be a mark at the start of a line — which is what it
was at 900px before this. The labels carry the grouping on their own
below that width, and the groups stack whole on a phone.
Signed-off-by: Stan Grams <sjg@haxx.space>
The squelch sat in the audio row on the same gap as everything else, so
it read as a continuation of the volume sliders. It decides whether
there is audio at all, which is not the same kind of control as how loud
it is, and a hairline says so.
The rule belongs to the squelch block, so it leaves with it on a rig
that has none, and it stands down where the row stacks: there the line
break separates them already and a leading rule would just start a line.
Signed-off-by: Stan Grams <sjg@haxx.space>
Clicking SQL turns the squelch on and off. The label and the button
beside it said the same thing twice — one naming the control, the other
reading "On" or "Off" — where the name itself is the obvious target, and
the dot already carries the state: grey when off, green while the gate
passes, amber while it holds.
The pressed state is on the label, so the switch reads the same to a
screen reader as it looks.
Signed-off-by: Stan Grams <sjg@haxx.space>
The threshold is in dB, and since the squelch fix that is the scale the
spectrum axis and the S-meter are labelled in — so the control belongs
on the plot, at the level it gates. A dashed line spans the spectrum at
its threshold with a grip that reads it out, dragged like the bandwidth
edges, green while the signal is above it and amber while it gates.
Arrow keys move it a dB at a time for anyone not using a mouse.
The audio row keeps a compact version: the dB, an indicator lit from the
same meter the DSP compares against, Auto, and an enable toggle that no
longer doubles as the level. The slider ran 0-100% over that dB range,
which gave the operator a number with nothing on screen to relate it to,
and zero meant "disabled", so turning the squelch off to listen threw
the threshold away. Auto now says which level it picked.
Two things the browser could only show once it was on the plot: the grip
landed underneath the split control at the right edge, which swallowed
its pointer, and dragging to the foot of the axis hid the line — and the
grip with it — instead of pinning it where it could be dragged back.
The fixture could not exercise any of this: /audio answered 404, which
hides the audio row and the control inside it, and the status carried no
filter block, which is what tells the client the rig has a squelch at
all. Both now look like an SDR, and the spectrum test drives the line.
Signed-off-by: Stan Grams <sjg@haxx.space>
CI still put the tab strip into the controls, now at 1100px — the
narrowest bar in the app, since the bookmark gutters take 9.5rem a side
above that width and leave 756px against 871px at 900px. With the
controls already in the overflow menu and the tabs already down to
icons, nothing else could give, and what gives by default is the strip:
it is the one item allowed to shrink below its content, so its tabs keep
full width and slide under the controls, out of reach.
The identity block takes the squeeze instead, ellipsised. A clipped
station name is still readable; a destination hidden underneath the
controls is not.
The guard that was supposed to catch this scaled only the tabs and the
controls, not the title and subtitles — which is exactly what runs out
of room — and skipped 900px. It now scales every piece of text in the
bar and checks all four widths. Measured across text scales from 1.0 to
3.0 at each width, the bar keeps its 16px allowance everywhere; before
this, 1.6 and above overlapped at 1100px.
Signed-off-by: Stan Grams <sjg@haxx.space>
The controls every rig has — mode, wheel, tune step, transmit — now sit
as a centred block rather than packed against the left edge.
What the current mode adds moves out from among them: WFM's six controls
stretched the row sideways whenever it was active, pushing the wheel and
the step pickers off centre, and SAM did the same on a smaller scale.
They get a row of their own below a divider, which appears and leaves
with them — an empty one would still take a track and a gap in the tray
and draw its divider under controls it has nothing to do with.
Signed-off-by: Stan Grams <sjg@haxx.space>
Mode was a full-width select: 483px of the row to display "FM". The
modes are three or four characters and there are at most twelve, so they
become a segmented group like the Unit and Step Scale pickers beside
them — a third of the width, and one click instead of two.
The <select> stays as the mode's value. A dozen call sites and several
plugins read #mode.value, so replacing it outright would have reached
much further than a layout change should; it is hidden from sight and
from assistive tech, the buttons write to it, and everything downstream
runs unchanged. Every writer re-syncs the buttons, the plugins through
a new trxCore.syncModePicker.
The row itself was a grid with a track per column, but the WFM, SAM and
transmit columns are hidden on most rigs, so it ended in some 500px of
hole. It packs left now. Same fault one level down: the power buttons
sat in three fixed tracks, so a rig with neither transmit nor lock kept
two empty ones and left its label chip stranded at the far edge.
Unit and Step Scale move out of the frequency row and in beside the
wheel and the +/- they modify, which were some 600px away.
Signed-off-by: Stan Grams <sjg@haxx.space>
CI put the tab strip 9px into the controls at 1440px on a run that
passed locally: its system font is wider, and the bar had no move left
to make. Controls are moved into the overflow menu until the bar fits,
but once all of them were in the menu nothing else gave — the nav may
shrink below its content, so the tabs kept full width and ran under the
controls, leaving the destinations nearest them unclickable. Labels
dropping to icons was the other half of the answer, but it hung off a
max-width:1360px media query and so was unavailable at 1440px.
That class now goes on by measurement, as the last step after the menu
is exhausted, which is the same reasoning the controls' own fit test
already uses: how much fits depends on the rig name and on how wide the
platform draws the labels, not on the viewport. The class is cleared
before measuring so the decision cannot ratchet, and icon widths are
fixed, so it always buys back the labels' width.
Labels now stay put between 1100px and 1360px while they fit, with the
style picker and theme toggle behind the overflow menu instead.
The suite could not have caught this: it passed on the fonts of the
machine that wrote it. The layout section now repeats its fit check
with the bar's text scaled up, which reproduces a wider system font
anywhere — with this fix reverted it fails on macOS too.
Signed-off-by: Stan Grams <sjg@haxx.space>
Tuning across a band edge moved the whole page under the cursor: the
band plan strip is in flow, so a range with no allocations collapsed it
from 18px to nothing and dragged every element below it up (measured at
1600x950: overview top 118 to 100, footer 1026 to 1008). It now keeps
its height whenever a band plan could be drawn at all, and only gives it
back when the feature is off, has no data, or there is no spectrum.
The bookmark rail gets the same treatment for consistency, though it
never moved anything — it is absolutely positioned over the top of the
overview. It stays up and blank rather than vanishing.
Which exposes something the rail was already doing wrong: it covers the
top of the plot, and a bare div still takes pointer events, so whenever
bookmarks were in range that band of the overview could not be dragged
or scrolled. Only the chips are targets now.
tests/spectrum-layout.mjs covers this: it streams spectrum frames, tunes
between a band with bookmarks and allocations and one with neither, and
asserts nothing moves and that the rail lets clicks through.
Signed-off-by: Stan Grams <sjg@haxx.space>
The controls were a column — release, then the step buttons, then the
status line, then the entry on air last — which read bottom-up and left
the entry that is actually transmitting furthest from the buttons that
change it. They now run left to right: step through the entries, hand
the rig back, then the current entry behind a separator.
The separator is a pseudo-element on the current-entry block rather than
an element of its own, because that block is display-toggled whenever
fewer than two entries are active; a standalone rule would be left
hanging with nothing after it.
No ids move, so the enable/disable logic in the scheduler plugin and the
release polling in vchan bind exactly as before. The smoke test asserts
the row's order, which is also what keeps the separator in place.
Signed-off-by: Stan Grams <sjg@haxx.space>
The desktop strip marked the current page with a 2px underline while the
mobile bottom nav already boxed it, so one navigation model looked like
two. The box now sits on both: a transparent 1px border on the base
reserves it, so switching pages moves no neighbours, and hover fills a
fainter version of the same shape. Tools carries it too — that button
is marked active for the destinations the strip hides.
Dropping the mobile rule's border-bottom:none, which only existed to
cancel the old desktop underline, closes the bottom edge its active box
had been missing. The smoke test checks all four edges.
Signed-off-by: Stan Grams <sjg@haxx.space>
The footer floated in space below the content with no rule to close the
page, its two clusters sat on a text baseline that left the source pill
hanging, and the status hint was a plain line of text a size larger than
the attribution beside it.
Now a hairline closes the page the way .tab-bar opens it, the clusters
centre on one line, and the attribution drops the opacity it stacked on
top of --text-muted, which had put it below a readable contrast ratio.
The status hint becomes a pill with a state dot: green when ready, amber
while a command is in flight, red on connection loss. The colour comes
from a data-state attribute, so every hint now goes through setPowerHint
instead of assigning textContent directly. --status-ok carries the
indicator green; .about-status-on picks it up too, which darkens it on
light themes where the old value was barely legible.
Signed-off-by: Stan Grams <sjg@haxx.space>
The map stage broke out of the centred .card column: negative inline
margins cancel the card's centring offset and its side padding, so the
stage reaches both viewport edges at every width without hardcoding
either value. Its rounded corners and left/right borders go with it —
edge to edge, the panel reads as a band rather than a floating card.
The browser smoke test now measures the stage against the viewport, and
checks that the full-bleed width does not push the page sideways.
Signed-off-by: Stan Grams <sjg@haxx.space>
Resolve a table/code pair to a sprite cell in aprs-shared, and use it
from both the packet lists and the map markers, which had each been
printing the raw symbol character in a bordered box.
A table identifier of / or \ selects the primary or alternate sheet
directly. Anything else is an overlay character, which the APRS spec
draws on top of the alternate symbol -- so those stack the overlay sheet
over the alternate one rather than picking a sheet. Codes outside
0x21..0x7E have no cell and keep the old character box.
The sheet URLs stay in the stylesheet so a min-resolution query can swap
in the retina sheets; only the cell offset is computed and set inline.
Map markers share the helper through the plugin chunk, so the map stays
free of any remote symbol fetch.
Verified in a browser against the real stylesheet and sheets: /> is a
car, /_ a WX circle, /& an igate diamond, \n a red triangle, and the
overlays S> and 7# carry their character on the alternate symbol.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
Two faults, both mine, both visible in one screenshot of that page.
#tab-statistics was the only panel with padding of its own, so its title
and content sat 16px inside where every other page begins. Remove it and
the page lines up with the header and with its siblings.
Removing the tab strip's `overflow-x` left it unable to shrink below its
content, so at around 1280px it ran under the controls: the Map tab sat
beneath the audio button and Tools beneath REC. Clipping is safe again —
the menus it anchors are reparented to the body when they open — so the
strip can shrink, and the labels now give way to icons at 1360px rather
than 1180px, before it has to clip anything.
Measured at 1280px: 321px of clearance between the strip and the
controls, and the page title at the same left edge as the header.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
The page titles carry a bottom margin, which is what spaces them from the
content on the plain block panels. #tab-statistics is not one: it is a
flex column with `gap: 1rem`, so the margin landed on top of that gap and
left 28px under the title where every other page had 12px.
Drop the margin on that panel and let its own gap do the spacing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
Recorder stated its name; Statistics, Settings and About did not, so one
page in eight carried a title. The class it used, section-heading, had
no rule behind it either, leaving even that title as a default h2.
Which way to unify follows from the navigation change. The tab strip
highlights the destination you are on, so Radio, Bookmarks, Digital modes
and Map already say where you are and a title would repeat the strip
while costing vertical space the spectrum wants. The four destinations
behind Tools get no such highlight — the strip looks the same on all of
them — so those are exactly the pages that have to name themselves.
Give the three that were missing a heading, and style section-heading so
all four match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
The button rendered as "•••More": the dots span carried no styling at
all, so the glyph sat flush against the label instead of behaving like
the icon it is. Every other tab hides its icon while labels are shown
and swaps to it when they are not; the dots now follow the same rule, so
the button reads "Tools" beside the other labels and becomes the glyph
alone in the icon band.
"More" also said nothing about the destinations behind it. The menu
holds Statistics, Recorder, Settings and About, so name it Tools and give
the button an aria-label that spells that out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
Both header dropdowns were laid out inside the bar rather than over the
page. The navigation menu opened as an 18px sliver positioned above its
own button, and the overflow menu did not appear at all.
Two causes. The tab strip kept `overflow-x: auto` from when it scrolled,
which clips an absolutely positioned descendant — and the strip is what
the navigation menu anchors to. The strip no longer scrolls, since the
occasional destinations moved behind More, so the property and the edge
fade that went with it are both gone.
Anchoring in fixed coordinates at open time addresses the general case:
an absolutely positioned menu is clipped by any scrolling ancestor and
trapped inside whatever stacking context its ancestors create, so it can
be squashed inside the bar or painted underneath page content. Fixed
coordinates answer to the viewport, and the menu flips above its button
near the bottom edge.
Clearing `right` when setting `left` keeps the menus at their natural
width: the stylesheet pins them to the right of their anchor, and leaving
that in place stretched them across the bar — 845px for a four-item list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
Eight destinations sat flat in the tab strip with equal weight, competing
with the controls for the same row and then scrolling out of reach with
only a fade to say so. They are not equal: Radio is where an operator
spends nearly all their time, Bookmarks, Digital modes and Map are
operating surfaces, and Statistics, Recorder, Settings and About are
occasional.
The mobile layout already grouped them exactly that way, behind its More
menu, so the application carried two navigation models. Adopt the mobile
grouping at every width instead of adding a third: four operating tabs
plus More. The strip no longer scrolls at any width, and the menu keeps
its bottom-sheet placement on mobile while anchoring under its button
elsewhere.
Drop the labels between 701 and 1180px so the tabs degrade to their icons
— which every tab already carries — before the strip could ever need to
hide a destination.
Rename Main to Radio: it is the receiver, not a generic first page, and
the name now says what the destination is rather than where it sits.
Freeing that width also let the style picker and theme toggle return to
the bar inline, leaving only the layout picker in the overflow menu.
Navigating to About in the browser smoke test now goes through More, as a
person would.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
The header repeated the active rig's hardware string and mode list beside
the rig picker. With a real SDR that reads
SoapySDR driver=airspyhf,serial=c852eb5dd23539f8 · RX · SDR filters ·
LSB · USB · CW · CWR · AM · +7 modes
which is longer than every other control in the bar combined, and it is
already on the About tab in full, split across its Rig, Active rig,
Connection, Modes and VFO rows.
Remove the element and the builder behind it. Rig switching keeps its
feedback through the existing hint channel rather than by briefly
rewriting a permanent label, and the identity that belongs in a header —
the rig's display name — stays in the left subtitle.
The freed width is not spent: the tab strip now reaches Settings before
it needs to scroll, where it previously faded out during Statistics.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
The page tabs scroll rather than wrap, so the last visible tab was sliced
mid-word ("Se…" for Settings), which reads as a rendering fault instead of
as an invitation to scroll.
Fade the trailing edge with a mask. A colour-matched cover gradient is
the usual trick, but the card is transparent, so a cover would have to
track the page background across both themes and all nine styles; a mask
is colour-agnostic. Only the trailing edge is faded, leaving the first
tab crisp while the strip sits at rest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
The header's height depended on the viewport width, and not even
monotonically: 112px at 1440, 169px at 1100, 131px at 900, 246px at 720.
Both control groups wrapped, so every width produced a different ragged
block — eight page tabs across four rows at 1100px, and action controls
across three. Four different control heights (32, 34, 45 and 54px) sat
in the same row, the 54px one being the rig picker with its summary
stacked underneath, and on narrow viewports the icon buttons stretched to
fill half the row, rendering a play triangle centred in a 249px box.
Lay both groups out as one row that never wraps. Controls are a uniform
2rem and no longer stretch, the rig summary sits inline beside its select,
and the page tabs scroll instead of wrapping. Secondary controls —
layout, style and theme — move into an overflow menu when the bar cannot
hold them, leaving audio, record and the rig picker inline.
Deciding when they no longer fit needs natural widths, not rendered ones:
the nav has min-width 0 and scrolls, so it always shrinks to the leftover
space and always reports scrolling, and the bar reports overflow even when
nothing is clipped. scrollWidth on the scroll container is its
unconstrained content width, which is what the fit test compares against
the space available.
Measured after the change: 72px at 1440, 1280, 1100, 900 and 480, every
control 32px, nothing clipped at any width.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
Introduce a shared design-token layer and apply it to the web
frontend for a more consistent, readable, and accessible feel.
Pure CSS plus one HTML block; no app.js behaviour changes, and the
existing multi-theme (style x light/dark) system is preserved.
- Add spacing, type, radius and motion tokens to :root.
- Fix undefined --surface consumed by .controls-tray, which dropped
the panel background entirely; it now resolves per theme.
- Set body line-height and font smoothing; collapse near-duplicate
font sizes onto canonical scale steps.
- Rebuild the auth gate with classes and replace hardcoded colours
(#ff6b6b, #9aa4b5) with theme variables so it renders correctly in
every theme.
- Add an opacity fade on tab switches, gated by prefers-reduced-motion.
- Extend focus-visible rings to the spectrum canvas, tabindex controls
and links.
- Add a self-theming --accent-text token to lift accent-coloured text
(links, active tab) to WCAG AA without altering brand fills.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>