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>