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>
browser-smoke.mjs carried its static server inline, which made it the
only browser test that could exist: a second one would have had to copy
180 lines of routes to change a single capability flag. The server
moves to tests/web-fixture.mjs behind startWebFixture(), with the rig's
spectrum support, bookmarks and band plan as options.
Serving a rig with a spectrum matters because that is where the layout
actually lives — the panel, the strips above it and the waterfall are
all gated on filter_controls, and the existing fixture reports a
CAT-only rig, so none of it has ever been rendered under test.
No change to what the smoke test checks.
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>
navigateToTab marked the Tools button by asking whether the destination
tab was displayed, which is the right question at the wrong moment: the
first route navigation runs while the card is still behind the loading
state, where every tab computes to display:none. Refreshing or deep
linking to any page therefore lit Tools alongside the real destination,
and nothing re-evaluated it once the page appeared.
Membership of the Tools menu answers the same question without needing
anything laid out, and still reads the grouping ui-core installs rather
than a second copy of it.
The smoke fixture now serves the SPA shell for route paths the way the
server's per-tab index handlers do, so a deep link no longer 404s and
the case is testable at all; two of them are asserted.
Signed-off-by: Stan Grams <sjg@haxx.space>
The windowed map was capped at 75% of the viewport height and at a
width-derived aspect ratio, which left a dead band under it: 69px at
1600x950, and on a 420px-wide phone a 270px map on an 800px screen.
Neither cap was doing useful work now that the stage spans the full
width, so the map fills the column down to the footer instead.
Growing into the footer needs a bound: once the column is tall enough to
push the footer below the fold, using its position would push it further
on every pass, so the bottom edge is clamped to the viewport. Growth
then consumes the column's spare height and settles in one pass.
Also drops three mapIsFullscreen() branches in the windowed path that
could never be taken — the fullscreen case returns above them.
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>
The fixture answered /decoders with an empty list, which hid most of the
application from the only test that runs it in a browser. The decoder
sub-tabs, their panels, the decode toggles and the bookmark decoder
checkboxes are all built from that registry, so the run exercised three
of thirteen sub-tabs and none of the decoder UI. Finding this needed
route interception, because nothing in the suite could see it.
Serve eleven decoders covering the modes the real registry spans. The
run now builds 13 sub-tabs and 11 bookmark decoder checkboxes — the same
checkboxes whose construction a recent fix changed without any test
reaching them — and still reports no runtime errors.
It also makes an existing fault observable: at 1100px the decoder
sub-tab bar hides 195px of itself with no scrollbar or fade, the same
silent truncation the top strip had. No assertion for it here, since
that would fail until the truncation is fixed.
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>
Several layout faults shipped while every gate passed, because nothing
looked at geometry: a header whose height tracked the viewport, controls
at four different heights, a tab strip that ran under the controls, and a
dropdown that opened underneath the page.
Assert the invariants behind those at four widths — the header stays one
row, the tabs do not reach the controls, the controls share a height, the
page does not scroll sideways — and that the menu renders with real
dimensions and wins a hit test at its own centre.
The overlap check measures the tabs rather than the strip: with the strip
allowed to overflow, its box shrinks while its content paints across the
controls, so the container's own rect never registers the collision.
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>
A layout seeds the collapsible sections; it should not hold them there.
applyLayout writes the disclosure state of the advanced, audio and
scheduler sections, and it runs far more often than a layout change:
render() calls applyRigList() for every SSE frame carrying `remotes`,
which calls setActiveRig() unconditionally, which re-applies the layout.
An operator who expanded a section that the selected layout collapses by
default therefore had it shut again within about a second, which read as
the section being locked by the layout — most visibly the scheduler under
Compact.
Write the section state only when the layout actually changes, or the
first time each section appears in the DOM, since the advanced controls
are constructed after the first applyLayout call. Switching layout still
reseeds every section, so choosing a layout keeps meaning "give me these
defaults".
Verified in Chromium: with Compact selected, activating the scheduler
summary opens the section and it survives both a rig-state refresh and a
repeated applyLayout, while selecting Full still reseeds it.
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 bookmark fix addressed one instance of a defect the TypeScript
migration left across the feature entries. app.js stopped being a
classic script, so its top-level declarations are no longer shared
globals, but the converted entries kept reading them as window
properties that nothing publishes.
Restore the broken behavior:
- ais, aprs, hf-aprs read serverLat, serverLon and haversineKm as
undefined, so every positioned packet rendered an empty distance.
- ais, aprs, hf-aprs, cw, sat, vdes, wefax, wspr called an undefined
postPath, so clear-history and decoder toggles threw.
- scheduler read authRole as undefined, so the lazy-load path never
self-initialized and the Settings tab opened an inert scheduler.
- background-decode read authEnabled as undefined, so control gating
fell back to role-only.
- vchan read fifteen application values and services as undefined:
mode and bandwidth sync, the out-of-band hint, RX audio restart, and
the frequency field all silently no-opped on a virtual channel.
- vchan wrapped window.refreshFreqDisplay, capturing an undefined
original exactly as it did for setRigFrequency, so leaving a channel
never restored the application's own frequency display.
- _audioChannelOverride was a const that nothing could assign, so RX
audio always subscribed to the primary channel.
- ftx-family read fmtTime, a helper legacy ft8.js owned locally, so
decode bar timestamps rendered empty.
Declare the contract once in plugins/host.ts and import it from the
feature entries, rather than restoring globals that
docs/frontend-architecture.md excludes. trx.state gains jogUnit,
rxActive and audioChannelOverride, and makes lastModeName writable;
trx.core gains the tuning, RDS, WFM, jog and RX audio services the
entries need. vchan interception moves to an interceptFreqDisplay
service method that refreshFreqDisplay calls, matching the frequency,
mode and bandwidth interception it already registers.
Reading registry-built elements through a strict lookup is the same
defect as in bookmarks: renderTimelineNeedle guards its result, but
schedulerEl throws, so the now-initializing scheduler crashed on the
timeline needle group that its own SVG creates.
Feature tests move onto a shared host fixture, and entries that now
import a common module are bundled through bundleEntry like the other
shared-module entries. Covers scheduler self-initialization and the
distance path that the bare window reads broke.
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 TypeScript migration turned app.js from a classic script into an ES
module, so its top-level declarations stopped being shared globals.
bookmarks.ts was converted verbatim and kept reading them as window
properties, which app.ts no longer publishes.
Every bookmark interaction read undefined: the Add Bookmark and Select
All buttons stayed hidden because the auth check saw no authEnabled or
authRole, per-rig scopes were missing from the scope picker and the move
target, decoder checkboxes were never built, and Tune threw on
bridge.postPath before issuing a single request.
Extend the typed window.trx host contract instead of restoring globals,
as docs/frontend-architecture.md closes the standalone window property
list. trx.state publishes authEnabled; trx.core publishes
setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency,
syncBandwidthInput, scheduleSpectrumDraw, and onDecoderRegistryReady.
Replace the vchan setRigFrequency wrapper with an interceptFrequency
service method, matching interceptMode and interceptBandwidth. The
wrapper captured an undefined original and silently dropped every tune;
routing interception through setRigFrequency also restores virtual
channel redirection for the application's own tuning.
Read registry-built elements through bmOptionalEl, since bmEl throws and
the decoder checkboxes and decode toggle buttons are legitimately absent
until the registry arrives.
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>