Compare commits

...
Author SHA1 Message Date
sjgandClaude Opus 5 92697b11c5 [feat](trx-frontend-http): mark Tools active for its destinations
CI / test (pull_request) Successful in 8m9s
CI / test (push) Successful in 7m22s
CI / lint (pull_request) Successful in 2m15s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m16s
CI / frontend (push) Successful in 2m10s
CI / reuse (push) Successful in 2s
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>
2026-08-03 00:23:17 +02:00
sjgandClaude Opus 5 b409c57296 [test](trx-frontend-http): assert header geometry in the browser smoke test
CI / test (push) Successful in 7m27s
CI / frontend (push) Successful in 3m1s
CI / reuse (push) Successful in 3s
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 7m24s
CI / frontend (pull_request) Successful in 2m11s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m15s
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>
2026-08-03 00:18:16 +02:00
sjgandClaude Opus 5 00191c8d7a [fix](trx-frontend-http): serve the Statistics and Bookmarks routes
CI / lint (pull_request) Successful in 2m18s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m17s
CI / test (pull_request) Successful in 8m23s
CI / test (push) Successful in 7m28s
CI / frontend (push) Successful in 2m9s
CI / reuse (push) Successful in 2s
The server answers /, /map, /digital-modes, /recorder, /settings and
/about with the application shell, but never had a route for /statistics
or /bookmarks.  Both fell through to the catch-all asset handler, so
reloading on either one downloaded a file instead of reopening the page.
Only in-app navigation worked, which is why it went unnoticed until
Statistics was reachable from the Tools menu.

Add the two missing shell routes.

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>
2026-08-03 00:03:48 +02:00
sjgandClaude Opus 5 56c363a517 [fix](trx-frontend-http): align the Statistics page and unblock the tab strip
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>
2026-08-02 23:59:58 +02:00
sjgandClaude Opus 5 5ad91b4ab6 [fix](trx-frontend-http): stop doubling the space under the Statistics title
CI / lint (pull_request) Successful in 2m17s
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m20s
CI / test (push) Successful in 7m33s
CI / frontend (push) Successful in 2m12s
CI / reuse (push) Successful in 3s
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>
2026-08-02 23:50:03 +02:00
sjgandClaude Opus 5 1843522b45 [feat](trx-frontend-http): give the Tools destinations page titles
CI / lint (pull_request) Successful in 2m21s
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 2m59s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m21s
CI / test (push) Successful in 7m30s
CI / frontend (push) Successful in 2m10s
CI / reuse (push) Successful in 3s
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>
2026-08-02 23:46:09 +02:00
sjgandClaude Opus 5 41ddecf5a7 [fix](trx-frontend-http): label the overflow tab Tools and hide its glyph
CI / test (pull_request) Successful in 8m17s
CI / lint (push) Successful in 2m18s
CI / lint (pull_request) Successful in 2m16s
CI / frontend (pull_request) Successful in 3m6s
CI / reuse (pull_request) Successful in 3s
CI / test (push) Successful in 7m25s
CI / frontend (push) Successful in 2m11s
CI / reuse (push) Successful in 3s
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>
2026-08-02 23:36:41 +02:00
sjgandClaude Opus 5 b06c37affa [fix](trx-frontend-http): lift the header menus out of the header
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m21s
CI / frontend (pull_request) Successful in 3m5s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m36s
CI / frontend (push) Successful in 2m9s
CI / reuse (push) Successful in 3s
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>
2026-08-02 23:28:39 +02:00
sjgandClaude Opus 5 b4912f5879 [fix](trx-frontend-http): render the header menus above the page
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m23s
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 2m59s
CI / reuse (pull_request) Successful in 3s
CI / frontend (push) Successful in 2m10s
CI / reuse (push) Successful in 3s
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>
2026-08-02 19:18:47 +02:00
sjgandClaude Opus 5 27f2558193 [feat](trx-frontend-http): one navigation model at every width
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 3m0s
CI / reuse (pull_request) Successful in 3s
CI / test (push) Successful in 7m25s
CI / lint (pull_request) Successful in 2m18s
CI / lint (push) Successful in 2m17s
CI / frontend (push) Successful in 2m9s
CI / reuse (push) Successful in 3s
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>
2026-08-02 19:12:14 +02:00
sjgandClaude Opus 5 3a9bf1b7ce [fix](trx-frontend-http): drop the rig description from the top bar
CI / test (pull_request) Successful in 8m22s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 3s
CI / lint (pull_request) Successful in 2m17s
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>
2026-08-02 18:59:28 +02:00
sjgandClaude Opus 5 dd5760c436 [style](trx-frontend-http): fade the scrolled tab strip edge
CI / lint (pull_request) Successful in 2m19s
CI / test (pull_request) Successful in 8m17s
CI / frontend (pull_request) Successful in 3m0s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m33s
CI / frontend (push) Successful in 2m9s
CI / reuse (push) Successful in 3s
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>
2026-08-02 18:48:34 +02:00
sjgandClaude Opus 5 eee3630f04 [feat](trx-frontend-http): compact single-row top bar
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>
2026-08-02 18:45:43 +02:00
8 changed files with 417 additions and 73 deletions
@@ -747,6 +747,95 @@ function elementById(id) {
api.applyLayout(savedLayoutName(), { persist: false }); api.applyLayout(savedLayoutName(), { persist: false });
} }
} }
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
function anchorMenu(button, menu) {
const rect = button.getBoundingClientRect();
if (menu.parentElement !== document.body) document.body.appendChild(menu);
menu.style.position = "fixed";
menu.style.zIndex = "9700";
menu.style.top = "0px";
menu.style.bottom = "auto";
menu.style.right = "auto";
const width = menu.offsetWidth || 200;
const height = menu.offsetHeight || 0;
menu.style.left = `${String(Math.round(Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8)))}px`;
const below = rect.bottom + 8;
if (below + height <= window.innerHeight - 8) {
menu.style.top = `${String(Math.round(below))}px`;
} else {
menu.style.top = "auto";
menu.style.bottom = `${String(Math.round(window.innerHeight - rect.top + 8))}px`;
}
}
function installTopBarOverflow() {
const actions = document.querySelector(".top-bar-actions");
if (!actions || document.getElementById("top-bar-more")) return;
const wrap = document.createElement("div");
wrap.id = "top-bar-more";
wrap.className = "top-bar-more";
const button = document.createElement("button");
button.type = "button";
button.id = "top-bar-more-btn";
button.className = "header-bar-btn top-bar-more-btn";
button.textContent = "⋯";
button.setAttribute("aria-haspopup", "menu");
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-label", "More controls");
button.title = "More controls";
const menu = document.createElement("div");
menu.id = "top-bar-more-menu";
menu.className = "top-bar-more-menu";
menu.setAttribute("role", "menu");
button.setAttribute("aria-controls", menu.id);
wrap.append(button, menu);
actions.appendChild(wrap);
const closeMenu = () => {
menu.classList.remove("is-open");
button.setAttribute("aria-expanded", "false");
};
button.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
button.setAttribute("aria-expanded", String(open));
if (open) anchorMenu(button, menu);
});
document.addEventListener("click", (event) => {
if (!(event.target instanceof Node)) {
closeMenu();
return;
}
if (!wrap.contains(event.target) && !menu.contains(event.target)) closeMenu();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMenu();
});
const barFits = () => {
const bar = actions.closest(".tab-bar");
if (!bar) return true;
const identity = bar.querySelector(".header-main");
const nav = bar.querySelector(".tab-bar-nav");
const gutters = 48;
const needed = (identity?.offsetWidth ?? 0) + (nav?.scrollWidth ?? 0) + actions.scrollWidth + gutters;
return needed <= bar.clientWidth;
};
const reflowOverflow = () => {
overflowOrder.forEach((selector) => {
const element = menu.querySelector(selector);
if (element) actions.insertBefore(element, wrap);
});
wrap.hidden = true;
for (const selector of overflowOrder) {
if (barFits()) break;
const element = actions.querySelector(selector);
if (!element) continue;
wrap.hidden = false;
menu.appendChild(element);
}
wrap.hidden = menu.children.length === 0;
if (wrap.hidden) closeMenu();
};
reflowOverflow();
window.addEventListener("resize", reflowOverflow);
}
function installMobileMore() { function installMobileMore() {
const nav = document.querySelector(".tab-bar-nav"); const nav = document.querySelector(".tab-bar-nav");
if (!nav || document.getElementById("mobile-more-btn")) return; if (!nav || document.getElementById("mobile-more-btn")) return;
@@ -754,7 +843,8 @@ function elementById(id) {
more.id = "mobile-more-btn"; more.id = "mobile-more-btn";
more.className = "tab mobile-more-btn"; more.className = "tab mobile-more-btn";
more.type = "button"; more.type = "button";
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>'; more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">Tools</span>';
more.setAttribute("aria-label", "Tools and settings");
more.setAttribute("aria-haspopup", "menu"); more.setAttribute("aria-haspopup", "menu");
more.setAttribute("aria-expanded", "false"); more.setAttribute("aria-expanded", "false");
const menu = document.createElement("div"); const menu = document.createElement("div");
@@ -786,7 +876,10 @@ function elementById(id) {
more.addEventListener("click", () => { more.addEventListener("click", () => {
const open = menu.classList.toggle("is-open"); const open = menu.classList.toggle("is-open");
more.setAttribute("aria-expanded", String(open)); more.setAttribute("aria-expanded", String(open));
if (open) menu.querySelector('[role="menuitem"]')?.focus(); if (open) {
anchorMenu(more, menu);
menu.querySelector('[role="menuitem"]')?.focus();
}
}); });
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || !menu.contains(event.target) && !more.contains(event.target)) closeMore(); if (!(event.target instanceof Node) || !menu.contains(event.target) && !more.contains(event.target)) closeMore();
@@ -853,6 +946,7 @@ function elementById(id) {
api.init = function init() { api.init = function init() {
ensureLiveRegions(); ensureLiveRegions();
installLayoutControls(); installLayoutControls();
installTopBarOverflow();
installMobileMore(); installMobileMore();
installDecoderPicker(); installDecoderPicker();
installDecoderBadges(); installDecoderBadges();
@@ -1947,7 +2041,6 @@ var signalSplitValueEl = document.getElementById("signal-split-value");
var overviewPeakHoldEl = document.getElementById("overview-peak-hold"); var overviewPeakHoldEl = document.getElementById("overview-peak-hold");
var themeToggleBtn = document.getElementById("theme-toggle"); var themeToggleBtn = document.getElementById("theme-toggle");
var headerRigSwitchSelect = document.getElementById("header-rig-switch-select"); var headerRigSwitchSelect = document.getElementById("header-rig-switch-select");
var headerRigSummary = document.getElementById("header-rig-summary");
var headerStylePickSelect = document.getElementById("header-style-pick-select"); var headerStylePickSelect = document.getElementById("header-style-pick-select");
var rdsPsOverlay = document.getElementById("rds-ps-overlay"); var rdsPsOverlay = document.getElementById("rds-ps-overlay");
var tabMainEl = document.getElementById("tab-main"); var tabMainEl = document.getElementById("tab-main");
@@ -2664,19 +2757,6 @@ function populateRigPicker(selectEl, rigIds, activeRigId, disabled) {
} }
selectEl.disabled = disabled; selectEl.disabled = disabled;
} }
function updateRigIdentitySummary(rigId, pending = false) {
if (!headerRigSummary) return;
const rig = serverRigs.find((entry) => entry?.remote === rigId);
if (!rig) {
headerRigSummary.textContent = pending ? "Switching rigs…" : "No rig details available";
return;
}
const hardware = [rig.manufacturer, rig.model].map((value) => String(value || "").trim()).filter(Boolean).join(" ") || rig.remote;
const modes = Array.isArray(rig.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : [];
const features = [rig.tx ? "TX" : "RX", rig.filter_controls ? "SDR filters" : null, ...modes.slice(0, 5)];
if (modes.length > 5) features.push(`+${modes.length - 5} modes`);
headerRigSummary.textContent = `${pending ? "Switching to " : ""}${hardware} · ${features.filter(Boolean).join(" · ")}`;
}
function updateRigSubtitle(activeRigId) { function updateRigSubtitle(activeRigId) {
if (!rigSubtitle) return; if (!rigSubtitle) return;
const name = activeRigId && lastRigDisplayNames[activeRigId] || activeRigId || "--"; const name = activeRigId && lastRigDisplayNames[activeRigId] || activeRigId || "--";
@@ -2707,7 +2787,6 @@ function applyRigList(activeRigId, rigIds, displayNames = {}) {
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId); updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId);
if (rigListChanged) { if (rigListChanged) {
window.trx.modules.scheduler?.setRig(lastActiveRigId); window.trx.modules.scheduler?.setRig(lastActiveRigId);
@@ -4968,7 +5047,7 @@ async function switchRigFromSelect(selectEl) {
rigSwitchInProgress = true; rigSwitchInProgress = true;
setControlPending(selectEl, true); setControlPending(selectEl, true);
selectEl.closest(".header-rig-switch")?.classList.add("is-switching"); selectEl.closest(".header-rig-switch")?.classList.add("is-switching");
updateRigIdentitySummary(nextRig, true); showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`);
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`); showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`);
try { try {
const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : ""; const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : "";
@@ -4976,7 +5055,6 @@ async function switchRigFromSelect(selectEl) {
lastActiveRigId = nextRig; lastActiveRigId = nextRig;
resetDecoderStateOnRigSwitch(); resetDecoderStateOnRigSwitch();
updateRigSubtitle(lastActiveRigId); updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId);
window.trx.modules.scheduler?.setRig(lastActiveRigId); window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
@@ -4995,7 +5073,6 @@ async function switchRigFromSelect(selectEl) {
} catch (err) { } catch (err) {
console.error("select_rig failed:", err); console.error("select_rig failed:", err);
selectEl.value = prevRig || ""; selectEl.value = prevRig || "";
updateRigIdentitySummary(prevRig);
window.trxUi?.notify("Rig could not be switched", { kind: "error" }); window.trxUi?.notify("Rig could not be switched", { kind: "error" });
} finally { } finally {
rigSwitchInProgress = false; rigSwitchInProgress = false;
@@ -5515,6 +5592,8 @@ function navigateToTab(name, options = {}) {
_activeTab = name; _activeTab = name;
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active"); btn.classList.add("active");
const toolsBtn = document.getElementById("mobile-more-btn");
if (toolsBtn) toolsBtn.classList.toggle("active", getComputedStyle(btn).display === "none");
window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn); window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn);
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const panel = document.getElementById(`tab-${name}`); const panel = document.getElementById(`tab-${name}`);
@@ -46,7 +46,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="tab-bar-nav" aria-label="Primary navigation"> <div class="tab-bar-nav" aria-label="Primary navigation">
<button class="tab active" data-tab="main"> <button class="tab active" data-tab="main">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-home"/></svg> <svg class="tab-icon" aria-hidden="true"><use href="#icon-home"/></svg>
<span class="tab-label">Main</span> <span class="tab-label">Radio</span>
</button> </button>
<button class="tab" data-tab="bookmarks"> <button class="tab" data-tab="bookmarks">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg> <svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
@@ -85,7 +85,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="header-rec-btn" class="header-bar-btn header-rec-btn" type="button" aria-label="Toggle recording" title="Toggle recording">REC</button> <button id="header-rec-btn" class="header-bar-btn header-rec-btn" type="button" aria-label="Toggle recording" title="Toggle recording">REC</button>
<div class="header-rig-switch"> <div class="header-rig-switch">
<select id="header-rig-switch-select" aria-label="Select active rig"></select> <select id="header-rig-switch-select" aria-label="Select active rig"></select>
<span id="header-rig-summary" class="header-rig-summary" aria-live="polite"></span>
</div> </div>
<div class="header-style-pick"> <div class="header-style-pick">
<select id="header-style-pick-select" aria-label="Select UI style"> <select id="header-style-pick-select" aria-label="Select UI style">
@@ -1052,6 +1051,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</template> </template>
</div> </div>
<div id="tab-statistics" class="tab-panel" style="display:none;"> <div id="tab-statistics" class="tab-panel" style="display:none;">
<h2 class="section-heading">Statistics</h2>
<template id="tmpl-statistics"> <template id="tmpl-statistics">
<div class="stats-controls"> <div class="stats-controls">
<div class="stats-control-group"> <div class="stats-control-group">
@@ -1192,6 +1192,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</section> </section>
</div> </div>
<div id="tab-settings" class="tab-panel" style="display:none;"> <div id="tab-settings" class="tab-panel" style="display:none;">
<h2 class="section-heading">Settings</h2>
<div class="sub-tab-bar"> <div class="sub-tab-bar">
<button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button> <button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button>
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button> <button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
@@ -1487,6 +1488,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
<div id="tab-about" class="tab-panel" style="display:none;"> <div id="tab-about" class="tab-panel" style="display:none;">
<h2 class="section-heading">About</h2>
<div id="auth-badge" style="display:none; margin-bottom: 1rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: 0.25rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div> <div id="auth-badge" style="display:none; margin-bottom: 1rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: 0.25rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
<template id="tmpl-about"> <template id="tmpl-about">
<div class="sub-tab-bar"> <div class="sub-tab-bar">
@@ -1229,12 +1229,20 @@ small { color: var(--text-muted); }
gap: 0; gap: 0;
flex-shrink: 0; flex-shrink: 0;
} }
/* One row, always. Controls that do not fit are moved into the overflow menu
* by ui-core rather than wrapping: wrapping made the header's height depend on
* the viewport width in a way that was not even monotonic (112px at 1440,
* 169px at 1100, 131px at 900), which is what made the bar feel unstable. */
.top-bar-actions { .top-bar-actions {
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 0.45rem 0.6rem; gap: 0.4rem;
min-width: 0; min-width: 0;
flex-wrap: wrap; flex-wrap: nowrap;
}
/* Every control in the bar is the same height and none of them stretch. */
.top-bar-actions > * {
flex: 0 0 auto;
} }
.header-bar-btn.header-audio-btn { .header-bar-btn.header-audio-btn {
width: 2rem; width: 2rem;
@@ -1403,21 +1411,13 @@ small { color: var(--text-muted); }
gap: 0.8rem; gap: 0.8rem;
padding: 0.9rem 0.2rem 0; padding: 0.9rem 0.2rem 0;
} }
/* Inline, not stacked: the summary used to sit under the select, making this
* the only 54px control in a bar of 32px ones. */
.header-rig-switch { .header-rig-switch {
display: grid; display: flex;
grid-template-columns: minmax(0, 1fr); align-items: center;
align-items: start; gap: 0.4rem;
gap: 0.35rem; min-width: 0;
}
.header-rig-summary {
display: block;
max-width: 20rem;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.header-rig-switch select { .header-rig-switch select {
min-width: 8rem; min-width: 8rem;
@@ -1531,7 +1531,19 @@ small { color: var(--text-muted); }
align-items: center; align-items: center;
gap: 0.2rem; gap: 0.2rem;
min-width: 0; min-width: 0;
flex-wrap: wrap; /* Never wraps. It must still be able to shrink below its content, or it
* overlaps the controls; the menus it anchors are reparented to the body on
* open, so clipping here no longer reaches them. */
flex-wrap: nowrap;
overflow: hidden;
}
.tab-bar-nav .tab { flex: 0 0 auto; }
/* Icons before scrolling: every tab already carries one, and four icons plus
* More always fit, so the strip never has to hide a destination. */
@media (max-width: 1360px) and (min-width: 701px) {
.tab-bar-nav .tab .tab-label { display: none; }
.tab-bar-nav .tab .tab-icon, .tab-bar-nav .tab .tab-more-icon { display: block; }
.tab-bar-nav .tab { padding: 0.5rem 0.6rem; }
} }
.tab { .tab {
background: transparent; background: transparent;
@@ -1554,6 +1566,7 @@ small { color: var(--text-muted); }
flex-shrink: 0; flex-shrink: 0;
} }
.tab-label { display: block; } .tab-label { display: block; }
.tab-more-icon { display: none; }
.about-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; } .about-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.about-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.5rem; padding: 0; overflow: hidden; } .about-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.5rem; padding: 0; overflow: hidden; }
.about-card-title { display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0.75rem; font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-heading); border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--card-bg) 50%, var(--bg)); } .about-card-title { display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0.75rem; font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-heading); border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--card-bg) 50%, var(--bg)); }
@@ -2964,7 +2977,10 @@ button.is-active {
text-transform: uppercase; text-transform: uppercase;
overflow: visible; overflow: visible;
} }
.operator-layout-picker::before { content: "Layout"; } /* The prefix made the least-used control the widest in the bar; the
* select carries its own aria-label and title instead. */
.operator-layout-picker { padding-left: 0; }
.operator-layout-picker select { max-width: 8rem; }
.operator-layout-picker select { .operator-layout-picker select {
width: auto; width: auto;
min-height: 2rem; min-height: 2rem;
@@ -3060,9 +3076,22 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
body[data-operator-layout="broadcast"] .controls-row { grid-template-columns: 1fr auto; } body[data-operator-layout="broadcast"] .controls-row { grid-template-columns: 1fr auto; }
body[data-operator-layout="broadcast"] #wfm-controls-col { grid-column: 1 / -1; } body[data-operator-layout="broadcast"] #wfm-controls-col { grid-column: 1 / -1; }
} }
.mobile-more-btn, .mobile-more-menu, .decoder-tab-select { display: none; } /* One navigation model at every width. Statistics, Recorder, Settings and
* About are occasional destinations: they live behind More rather than
* competing with the operating tabs for the row and then scrolling out of
* reach. The mobile layout already grouped them this way; the desktop strip
* now matches it, which is why the tab strip no longer needs to scroll. */
.tab-bar-nav .tab[data-tab="statistics"],
.tab-bar-nav .tab[data-tab="recorder"],
.tab-bar-nav .tab[data-tab="settings"],
.tab-bar-nav .tab[data-tab="about"] { display: none; }
.mobile-more-btn { display: flex; flex: 0 0 auto; }
.mobile-more-menu, .decoder-tab-select { display: none; }
.tab-bar-nav { position: relative; }
.mobile-more-menu { .mobile-more-menu {
position: fixed; position: absolute;
top: calc(100% + 0.4rem);
right: 0;
right: max(0.75rem, env(safe-area-inset-right)); right: max(0.75rem, env(safe-area-inset-right));
bottom: calc(5.4rem + env(safe-area-inset-bottom)); bottom: calc(5.4rem + env(safe-area-inset-bottom));
z-index: 80; z-index: 80;
@@ -3251,7 +3280,7 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
color: var(--text); color: var(--text);
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent); box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
} }
.tab-icon { display: block; } .tab-icon, .tab-more-icon { display: block; }
/* Shorten long tab labels to keep bottom nav compact */ /* Shorten long tab labels to keep bottom nav compact */
.tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; } .tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; } .tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; }
@@ -3259,6 +3288,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.tab[data-tab="statistics"], .tab[data-tab="recorder"], .tab[data-tab="statistics"], .tab[data-tab="recorder"],
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; } .tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
.mobile-more-btn { display: flex; } .mobile-more-btn { display: flex; }
.mobile-more-menu {
position: fixed;
top: auto;
right: max(0.75rem, env(safe-area-inset-right));
bottom: calc(5.4rem + env(safe-area-inset-bottom));
}
.mobile-more-btn[aria-expanded="true"] { .mobile-more-btn[aria-expanded="true"] {
color: var(--accent-text); color: var(--accent-text);
background: color-mix(in srgb, var(--accent-green) 10%, transparent); background: color-mix(in srgb, var(--accent-green) 10%, transparent);
@@ -3282,24 +3317,24 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.toast-region { bottom: calc(5.7rem + env(safe-area-inset-bottom)); } .toast-region { bottom: calc(5.7rem + env(safe-area-inset-bottom)); }
.top-bar-actions { .top-bar-actions {
width: 100%; width: 100%;
justify-content: flex-start; justify-content: flex-end;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 0.45rem; gap: 0.4rem;
} }
.operator-layout-picker { max-width: 100%; } .operator-layout-picker { max-width: 100%; }
.operator-layout-picker select { max-width: 10rem; } .operator-layout-picker select { max-width: 10rem; }
.header-rig-switch, .header-rig-switch,
.header-style-pick { .header-style-pick {
flex: 1 1 12rem; flex: 0 0 auto;
min-width: 0; min-width: 0;
} }
.header-rig-switch select, .header-rig-switch select,
.header-style-pick select { .header-style-pick select {
width: 100%; width: auto;
min-width: 0; min-width: 0;
} }
.header-bar-btn { .header-bar-btn {
flex: 1 1 calc(50% - 0.3rem); flex: 0 0 auto;
min-width: 0; min-width: 0;
} }
#tab-main, #tab-main,
@@ -5153,7 +5188,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
padding: 1rem;
} }
.stats-controls { .stats-controls {
display: flex; display: flex;
@@ -5379,3 +5413,51 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
font-size: max(0.75rem, 12px); font-size: max(0.75rem, 12px);
} }
} }
/* --- Top bar overflow menu ------------------------------------------------ */
.top-bar-more { position: relative; }
.top-bar-more[hidden] { display: none; }
.top-bar-more-btn {
width: 2rem;
height: 2rem;
min-height: 2rem;
padding: 0;
font-size: 1rem;
line-height: 1;
}
.top-bar-more-menu {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
z-index: 40;
display: none;
flex-direction: column;
gap: 0.4rem;
min-width: 12rem;
padding: 0.5rem;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: var(--surface);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
}
.top-bar-more-menu.is-open { display: flex; }
.top-bar-more-menu > * { width: 100%; }
.top-bar-more-menu select { width: 100%; max-width: none; }
/* Page title for destinations reached through the Tools menu. The tab strip
* cannot show which of them is active, so each states its own name.
*
* The margin is the spacing on the plain block panels; #tab-statistics is a
* flex column with its own `gap`, where the margin lands on top of that gap
* and doubles the space under the title. */
.section-heading {
margin: 0 0 var(--space-3);
font-size: 1.15rem;
font-weight: 700;
line-height: 1.2;
color: var(--text);
}
#tab-statistics > .section-heading { margin-bottom: 0; }
@@ -724,7 +724,6 @@ const signalSplitValueEl = document.getElementById("signal-split-value");
const overviewPeakHoldEl = document.getElementById("overview-peak-hold") as HTMLInputElement | null; const overviewPeakHoldEl = document.getElementById("overview-peak-hold") as HTMLInputElement | null;
const themeToggleBtn = document.getElementById("theme-toggle") as HTMLButtonElement | null; const themeToggleBtn = document.getElementById("theme-toggle") as HTMLButtonElement | null;
const headerRigSwitchSelect = document.getElementById("header-rig-switch-select") as HTMLSelectElement | null; const headerRigSwitchSelect = document.getElementById("header-rig-switch-select") as HTMLSelectElement | null;
const headerRigSummary = document.getElementById("header-rig-summary");
const headerStylePickSelect = document.getElementById("header-style-pick-select") as HTMLSelectElement | null; const headerStylePickSelect = document.getElementById("header-style-pick-select") as HTMLSelectElement | null;
const rdsPsOverlay = document.getElementById("rds-ps-overlay"); const rdsPsOverlay = document.getElementById("rds-ps-overlay");
const tabMainEl = document.getElementById("tab-main"); const tabMainEl = document.getElementById("tab-main");
@@ -1371,20 +1370,6 @@ function populateRigPicker(selectEl: HTMLSelectElement | null, rigIds: string[],
selectEl.disabled = disabled; selectEl.disabled = disabled;
} }
function updateRigIdentitySummary(rigId: string | null, pending = false) {
if (!headerRigSummary) return;
const rig = serverRigs.find((entry) => entry?.remote === rigId);
if (!rig) {
headerRigSummary.textContent = pending ? "Switching rigs…" : "No rig details available";
return;
}
const hardware = [rig.manufacturer, rig.model].map(value => String(value || "").trim()).filter(Boolean).join(" ") || rig.remote;
const modes = Array.isArray(rig.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : [];
const features = [rig.tx ? "TX" : "RX", rig.filter_controls ? "SDR filters" : null, ...modes.slice(0, 5)];
if (modes.length > 5) features.push(`+${modes.length - 5} modes`);
headerRigSummary.textContent = `${pending ? "Switching to " : ""}${hardware} · ${features.filter(Boolean).join(" · ")}`;
}
function updateRigSubtitle(activeRigId: string | null) { function updateRigSubtitle(activeRigId: string | null) {
if (!rigSubtitle) return; if (!rigSubtitle) return;
const name = (activeRigId && lastRigDisplayNames[activeRigId]) || activeRigId || "--"; const name = (activeRigId && lastRigDisplayNames[activeRigId]) || activeRigId || "--";
@@ -1421,7 +1406,6 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId); updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId);
if (rigListChanged) { if (rigListChanged) {
window.trx.modules.scheduler?.setRig(lastActiveRigId); window.trx.modules.scheduler?.setRig(lastActiveRigId);
@@ -4023,7 +4007,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
rigSwitchInProgress = true; rigSwitchInProgress = true;
setControlPending(selectEl, true); setControlPending(selectEl, true);
selectEl.closest(".header-rig-switch")?.classList.add("is-switching"); selectEl.closest(".header-rig-switch")?.classList.add("is-switching");
updateRigIdentitySummary(nextRig, true); showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`);
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`); showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`);
try { try {
const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : ""; const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : "";
@@ -4031,7 +4015,6 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
lastActiveRigId = nextRig; lastActiveRigId = nextRig;
resetDecoderStateOnRigSwitch(); resetDecoderStateOnRigSwitch();
updateRigSubtitle(lastActiveRigId); updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId);
window.trx.modules.scheduler?.setRig(lastActiveRigId); window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
@@ -4050,7 +4033,6 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
} catch (err) { } catch (err) {
console.error("select_rig failed:", err); console.error("select_rig failed:", err);
selectEl.value = prevRig || ""; selectEl.value = prevRig || "";
updateRigIdentitySummary(prevRig);
window.trxUi?.notify("Rig could not be switched", { kind: "error" }); window.trxUi?.notify("Rig could not be switched", { kind: "error" });
} finally { } finally {
rigSwitchInProgress = false; rigSwitchInProgress = false;
@@ -4618,6 +4600,12 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
_activeTab = name; _activeTab = name;
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active"); btn.classList.add("active");
// A destination the strip hides is reached through Tools, so mark that
// button instead — otherwise the strip looks identical on all four of them.
// Derived from what is actually hidden rather than from a second copy of the
// grouping, which would drift from the one ui-core installs.
const toolsBtn = document.getElementById("mobile-more-btn");
if (toolsBtn) toolsBtn.classList.toggle("active", getComputedStyle(btn).display === "none");
window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn); window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn);
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => p.style.display = "none"); document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => p.style.display = "none");
const panel = document.getElementById(`tab-${name}`); const panel = document.getElementById(`tab-${name}`);
@@ -322,6 +322,125 @@ function elementById<T extends HTMLElement>(id: string): T {
} }
} }
// Secondary controls, in the order they leave the bar when it gets tight.
// Audio, record and the rig picker are the operating controls and stay.
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
// Anchored at paint time in fixed coordinates. An absolutely positioned
// dropdown is clipped by any scrolling ancestor and trapped inside the
// stacking context its ancestors create, so it can end up squashed inside
// the bar or painted underneath page content. Fixed positioning answers to
// the viewport instead, and the menu flips above its button near the bottom
// edge.
function anchorMenu(button: HTMLElement, menu: HTMLElement) {
const rect = button.getBoundingClientRect();
// Reparent to the body. Fixed positioning escapes clipping but not
// stacking: the header sets `z-index: 2`, which makes it a stacking
// context, so any z-index the menu carries is confined below every panel
// that paints above level 2 — the spectrum overlays reach 9600. Only
// leaving that subtree lets the menu sit above the page.
if (menu.parentElement !== document.body) document.body.appendChild(menu);
menu.style.position = "fixed";
menu.style.zIndex = "9700";
menu.style.top = "0px";
menu.style.bottom = "auto";
// The stylesheet pins these menus to the right of their anchor; leaving
// that in place while setting a left would stretch them across the bar.
menu.style.right = "auto";
const width = menu.offsetWidth || 200;
const height = menu.offsetHeight || 0;
menu.style.left = `${String(Math.round(Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8)))}px`;
const below = rect.bottom + 8;
if (below + height <= window.innerHeight - 8) {
menu.style.top = `${String(Math.round(below))}px`;
} else {
menu.style.top = "auto";
menu.style.bottom = `${String(Math.round(window.innerHeight - rect.top + 8))}px`;
}
}
function installTopBarOverflow() {
const actions = document.querySelector<HTMLElement>(".top-bar-actions");
if (!actions || document.getElementById("top-bar-more")) return;
const wrap = document.createElement("div");
wrap.id = "top-bar-more";
wrap.className = "top-bar-more";
const button = document.createElement("button");
button.type = "button";
button.id = "top-bar-more-btn";
button.className = "header-bar-btn top-bar-more-btn";
button.textContent = "⋯";
button.setAttribute("aria-haspopup", "menu");
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-label", "More controls");
button.title = "More controls";
const menu = document.createElement("div");
menu.id = "top-bar-more-menu";
menu.className = "top-bar-more-menu";
menu.setAttribute("role", "menu");
button.setAttribute("aria-controls", menu.id);
wrap.append(button, menu);
actions.appendChild(wrap);
const closeMenu = () => {
menu.classList.remove("is-open");
button.setAttribute("aria-expanded", "false");
};
button.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
button.setAttribute("aria-expanded", String(open));
if (open) anchorMenu(button, menu);
});
document.addEventListener("click", (event) => {
if (!(event.target instanceof Node)) { closeMenu(); return; }
if (!wrap.contains(event.target) && !menu.contains(event.target)) closeMenu();
});
document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMenu(); });
// Measured against the bar, not the actions container: the actions are
// sized by their content, so their own scrollWidth never exceeds their
// clientWidth. A viewport-width threshold is not enough either — how much
// fits depends on the rig name and the translated labels, so a bar that is
// wide enough on one rig clips a control on another.
// `bar.scrollWidth > bar.clientWidth` is true even when nothing is clipped,
// so it cannot be the test. What actually matters is that the controls stay
// inside the bar and the page tabs are not squeezed into a scroller: seeing
// every tab beats keeping the style picker inline.
// Compare natural widths against the space available. Rendered widths
// cannot answer this: the nav has min-width 0 and scrolls, so it always
// shrinks to the leftover space and always reports "scrolling", while the
// bar reports overflow even when nothing is clipped. scrollWidth on a
// scroll container is its unconstrained content width, which is what a fit
// test needs.
const barFits = () => {
const bar = actions.closest<HTMLElement>(".tab-bar");
if (!bar) return true;
const identity = bar.querySelector<HTMLElement>(".header-main");
const nav = bar.querySelector<HTMLElement>(".tab-bar-nav");
const gutters = 48;
const needed = (identity?.offsetWidth ?? 0) + (nav?.scrollWidth ?? 0) + actions.scrollWidth + gutters;
return needed <= bar.clientWidth;
};
const reflowOverflow = () => {
overflowOrder.forEach((selector) => {
const element = menu.querySelector<HTMLElement>(selector);
if (element) actions.insertBefore(element, wrap);
});
wrap.hidden = true;
for (const selector of overflowOrder) {
if (barFits()) break;
const element = actions.querySelector<HTMLElement>(selector);
if (!element) continue;
wrap.hidden = false;
menu.appendChild(element);
}
wrap.hidden = menu.children.length === 0;
if (wrap.hidden) closeMenu();
};
reflowOverflow();
window.addEventListener("resize", reflowOverflow);
}
function installMobileMore() { function installMobileMore() {
const nav = document.querySelector(".tab-bar-nav"); const nav = document.querySelector(".tab-bar-nav");
if (!nav || document.getElementById("mobile-more-btn")) return; if (!nav || document.getElementById("mobile-more-btn")) return;
@@ -329,7 +448,8 @@ function elementById<T extends HTMLElement>(id: string): T {
more.id = "mobile-more-btn"; more.id = "mobile-more-btn";
more.className = "tab mobile-more-btn"; more.className = "tab mobile-more-btn";
more.type = "button"; more.type = "button";
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>'; more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">Tools</span>';
more.setAttribute("aria-label", "Tools and settings");
more.setAttribute("aria-haspopup", "menu"); more.setAttribute("aria-haspopup", "menu");
more.setAttribute("aria-expanded", "false"); more.setAttribute("aria-expanded", "false");
const menu = document.createElement("div"); const menu = document.createElement("div");
@@ -361,7 +481,10 @@ function elementById<T extends HTMLElement>(id: string): T {
more.addEventListener("click", () => { more.addEventListener("click", () => {
const open = menu.classList.toggle("is-open"); const open = menu.classList.toggle("is-open");
more.setAttribute("aria-expanded", String(open)); more.setAttribute("aria-expanded", String(open));
if (open) menu.querySelector<HTMLElement>('[role="menuitem"]')?.focus(); if (open) {
anchorMenu(more, menu);
menu.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
}
}); });
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || (!menu.contains(event.target) && !more.contains(event.target))) closeMore(); if (!(event.target instanceof Node) || (!menu.contains(event.target) && !more.contains(event.target))) closeMore();
@@ -426,6 +549,7 @@ function elementById<T extends HTMLElement>(id: string): T {
api.init = function init() { api.init = function init() {
ensureLiveRegions(); ensureLiveRegions();
installLayoutControls(); installLayoutControls();
installTopBarOverflow();
installMobileMore(); installMobileMore();
installDecoderPicker(); installDecoderPicker();
installDecoderBadges(); installDecoderBadges();
@@ -9,6 +9,9 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { chromium } from "playwright-core"; import { chromium } from "playwright-core";
// page.evaluate callbacks run in the browser, not in this Node process.
/* global document */
const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const webDir = path.resolve(frontendDir, "../assets/web"); const webDir = path.resolve(frontendDir, "../assets/web");
const generatedDir = path.join(webDir, "generated"); const generatedDir = path.join(webDir, "generated");
@@ -184,7 +187,10 @@ try {
await page.locator('.tab[data-tab="main"]').click(); await page.locator('.tab[data-tab="main"]').click();
assert.equal(new URL(page.url()).pathname, "/"); assert.equal(new URL(page.url()).pathname, "/");
await page.locator('.tab[data-tab="about"]').click(); // About is an occasional destination, so it lives behind More at every
// width rather than in the operating tab strip.
await page.locator("#mobile-more-btn").click();
await page.locator('[data-navigate-tab="about"]').click();
await page.locator("#tab-about").waitFor({ state: "visible" }); await page.locator("#tab-about").waitFor({ state: "visible" });
assert.equal(new URL(page.url()).pathname, "/about"); assert.equal(new URL(page.url()).pathname, "/about");
@@ -192,6 +198,55 @@ try {
await page.locator("#tab-main").waitFor({ state: "visible" }); await page.locator("#tab-main").waitFor({ state: "visible" });
assert.equal(new URL(page.url()).pathname, "/"); assert.equal(new URL(page.url()).pathname, "/");
assert.deepEqual(runtimeErrors, []); assert.deepEqual(runtimeErrors, []);
// --- Layout regressions -------------------------------------------------
// Every fault below shipped at some point while the rest of this file
// passed, because nothing here looked at geometry: a header whose height
// tracked the viewport, controls that stretched, a tab strip that ran under
// the controls, and a dropdown that opened underneath the spectrum.
for (const width of [1440, 1280, 1100, 900]) {
await page.setViewportSize({ width, height: 900 });
await page.waitForTimeout(250);
const header = await page.evaluate(() => {
const bar = document.querySelector(".tab-bar");
const nav = document.querySelector(".tab-bar-nav");
const actions = document.querySelector(".top-bar-actions");
const controls = [...actions.children]
.filter((el) => !el.hidden && el.getBoundingClientRect().height > 0)
.map((el) => Math.round(el.getBoundingClientRect().height));
return {
barHeight: Math.round(bar.getBoundingClientRect().height),
// The tabs, not 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.
overlap: Math.round(Math.max(...[...nav.querySelectorAll(".tab")]
.filter((tab) => tab.offsetParent !== null)
.map((tab) => tab.getBoundingClientRect().right))
- actions.getBoundingClientRect().left),
heights: [...new Set(controls)],
pageScrollsSideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
};
});
assert.ok(header.barHeight <= 96, `header is ${header.barHeight}px at ${width}px; it should stay one row`);
assert.ok(header.overlap <= 0, `tab strip overlaps the controls by ${header.overlap}px at ${width}px`);
assert.ok(header.heights.length <= 2, `controls have heights ${header.heights.join(", ")} at ${width}px`);
assert.equal(header.pageScrollsSideways, false, `page scrolls sideways at ${width}px`);
}
// A dropdown must paint over the page, not inside the header: fixed
// positioning escapes clipping but not the header's stacking context.
await page.setViewportSize({ width: 1280, height: 900 });
await page.waitForTimeout(250);
const menu = await page.evaluate(() => {
document.getElementById("mobile-more-btn").click();
const element = document.getElementById("mobile-more-menu");
const rect = element.getBoundingClientRect();
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
return { width: Math.round(rect.width), height: Math.round(rect.height), onTop: element.contains(hit) };
});
assert.ok(menu.height > 40 && menu.width > 80, `menu rendered ${menu.width}x${menu.height}`);
assert.ok(menu.onTop, "menu is painted underneath the page");
} finally { } finally {
await browser.close(); await browser.close();
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
@@ -95,6 +95,18 @@ pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
static_asset_response(&req, "text/html; charset=utf-8", c) static_asset_response(&req, "text/html; charset=utf-8", c)
} }
#[get("/statistics")]
pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/bookmarks")]
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Favicon & logo // Favicon & logo
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -637,6 +637,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::recorder_index) .service(assets::recorder_index)
.service(assets::settings_index) .service(assets::settings_index)
.service(assets::about_index) .service(assets::about_index)
.service(assets::statistics_index)
.service(assets::bookmarks_index)
.service(assets::favicon) .service(assets::favicon)
.service(assets::favicon_png) .service(assets::favicon_png)
.service(assets::logo) .service(assets::logo)