/bandplan.json needed the control role. Route access is decided by
suffix for static assets — .js, .css, .png and so on — and ".json" is
not among them, so the band plan matched nothing and fell through to the
catch-all. It is compiled into the binary and identical for every user,
so it is public now, like the rest of them.
Two things followed from that. Read-only sessions never saw a band plan
at all. And since the page asks for it during startup, the request can
land before the session is established: that 401 was swallowed by an
empty catch and never retried, which is why the allocations sometimes
only appeared after a manual reload.
So the client no longer hides the failure, retries once the auth gate
clears — which is exactly when a startup 401 becomes fixable — and
schedules a draw when the data lands, since the strip is painted from
the spectrum draw and a rig sitting between frames would stay blank.
The fixture can now refuse the first request the way the server did, and
the spectrum layout test holds the client to recovering from it.
Signed-off-by: Stan Grams <sjg@haxx.space>
The radio's own settings now come first, then audio, then the scheduler:
Advanced radio controls, Audio controls, Scheduler controls.
The advanced section is not in the markup — ui-core builds it at runtime
and gathers the SDR settings, virtual channel and TX limit rows into it,
appending the result, which put it last however the markup was ordered.
It is inserted ahead of the audio section instead.
The signal readout and the TX meters stay where they are, between the
controls and the sections: they are readouts rather than a section, and
on an SDR the spectrum covers them anyway.
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>
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 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>
Replay required every restored record to carry a string `type`, and
stored records do not have one: an AIS entry holds mmsi, lat, lon,
crc_ok and its decoder's own fields, nothing more. The filter therefore
discarded all of them, and did it silently — the fetch returned its full
payload, the worker decoded it, and no error was logged, so the history
simply never appeared.
That field identifies live SSE frames, which do carry it, which is why
only replay was affected. History arrives already grouped and the
group's kind is delivered alongside the messages, so `type` was never
needed to route them. Require only that a record is an object.
Confirmed against a live server: the first restored group is AIS, and
its records expose their decoder fields with `type` undefined.
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>
Grouping Statistics, Recorder, Settings and About behind Tools left the
tab strip looking identical on all four: the destination's own button
carries the active class, but the strip hides that button, so nothing
was marked. The page titles named the page without saying how you got
there.
Mark the Tools button when the active destination is one the strip hides.
That state is read from the button's computed display rather than from a
second copy of the grouping, so the two cannot drift: whatever ui-core
puts in the menu lights up Tools, and a destination promoted back into
the strip stops doing so with no further change.
Tools already carries the tab class, so the existing active styling
applies unchanged.
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>
Fixed positioning escaped the clipping, but not the stacking: the header
carries `z-index: 2`, which makes it a stacking context, so whatever
z-index a menu inside it carries is confined below level 2. The spectrum
overlays paint as high as 9600, so both dropdowns opened underneath them.
Reparent each menu to the body when it opens. Leaving that subtree is
the only way out of an ancestor's stacking context, and the menus are
already positioned in viewport coordinates, so nothing else about them
changes. The outside-click test now considers the menu as well as its
wrapper, since the two are no longer nested.
Verified by hit testing rather than by inspecting z-index:
elementFromPoint at the open menu's centre returns the menu.
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>
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 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>
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>