Compare commits

..
Author SHA1 Message Date
sjgandClaude Opus 5 863a6d8fd4 [fix](trx-frontend-http): restore decode history from the stored records
CI / lint (pull_request) Successful in 2m21s
CI / test (pull_request) Successful in 8m19s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 7m26s
CI / frontend (push) Successful in 2m11s
CI / reuse (push) Successful in 2s
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>
2026-08-03 18:39:13 +02:00
sjgandClaude Opus 5 b252717f6b [test](trx-frontend-http): serve a realistic decoder registry to the smoke test
CI / lint (pull_request) Successful in 2m18s
CI / test (pull_request) Successful in 7m25s
CI / frontend (pull_request) Successful in 2m10s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m26s
CI / frontend (push) Successful in 2m12s
CI / reuse (push) Successful in 3s
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>
2026-08-03 17:46:52 +02:00
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
7 changed files with 126 additions and 8 deletions
@@ -5592,6 +5592,8 @@ function navigateToTab(name, options = {}) {
_activeTab = name;
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("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);
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const panel = document.getElementById(`tab-${name}`);
@@ -7422,7 +7424,7 @@ function connectDecode() {
return;
}
if (data.type === "group") {
const messages = Array.isArray(data.messages) ? data.messages.filter((message) => isRecord2(message) && typeof message.type === "string") : [];
const messages = Array.isArray(data.messages) ? data.messages.filter((message) => isRecord2(message)) : [];
enqueueDecodeHistoryGroup(typeof data.kind === "string" ? data.kind : "", messages);
return;
}
@@ -1051,6 +1051,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</template>
</div>
<div id="tab-statistics" class="tab-panel" style="display:none;">
<h2 class="section-heading">Statistics</h2>
<template id="tmpl-statistics">
<div class="stats-controls">
<div class="stats-control-group">
@@ -1191,6 +1192,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</section>
</div>
<div id="tab-settings" class="tab-panel" style="display:none;">
<h2 class="section-heading">Settings</h2>
<div class="sub-tab-bar">
<button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button>
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
@@ -1486,6 +1488,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
<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>
<template id="tmpl-about">
<div class="sub-tab-bar">
@@ -1531,15 +1531,16 @@ small { color: var(--text-muted); }
align-items: center;
gap: 0.2rem;
min-width: 0;
/* Never wraps and never scrolls: the occasional destinations live behind
* More, so four tabs plus More always fit. overflow-x here would clip the
* dropdown this element anchors. */
/* 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: 1180px) and (min-width: 701px) {
@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; }
@@ -5187,7 +5188,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.stats-controls {
display: flex;
@@ -5444,3 +5444,20 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.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; }
@@ -4600,6 +4600,12 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
_activeTab = name;
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("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);
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => p.style.display = "none");
const panel = document.getElementById(`tab-${name}`);
@@ -6360,7 +6366,12 @@ function connectDecode() {
}
if (data.type === "group") {
const messages = Array.isArray(data.messages)
? data.messages.filter((message): message is DecodeMessage => isRecord(message) && typeof message.type === "string")
// Stored records carry only decoder fields — an AIS entry has mmsi,
// lat, lon and so on, and no `type`. That field identifies live SSE
// frames; history is already grouped, and the group's kind is
// delivered alongside these messages, so requiring it here discarded
// every restored record silently.
? data.messages.filter((message): message is DecodeMessage => isRecord(message))
: [];
enqueueDecodeHistoryGroup(typeof data.kind === "string" ? data.kind : "", messages);
return;
@@ -9,6 +9,9 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
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 webDir = path.resolve(frontendDir, "../assets/web");
const generatedDir = path.join(webDir, "generated");
@@ -27,9 +30,28 @@ const rigItems = ["rig-a", "rig-b"].map((remote) => ({
const rigsResponse = { rigs: rigItems, active_remote: "rig-a" };
const selectedRigs = [];
// A realistic decoder registry. Serving an empty one hid most of the
// application from this test: the decoder sub-tabs, their panels, the decode
// toggles and the bookmark decoder checkboxes are all built from it, so with
// no decoders only three of thirteen sub-tabs existed and none of the decoder
// UI was ever constructed.
const decoderRegistry = [
{ id: "ft8", label: "FT8", activation: "toggle", active_modes: ["USB"] },
{ id: "ft4", label: "FT4", activation: "toggle", active_modes: ["USB"] },
{ id: "ft2", label: "FT2", activation: "toggle", active_modes: ["USB"] },
{ id: "wspr", label: "WSPR", activation: "toggle", active_modes: ["USB"] },
{ id: "cw", label: "CW", activation: "toggle", active_modes: ["CW", "CWR"] },
{ id: "sat", label: "SAT", activation: "toggle", active_modes: ["FM"] },
{ id: "wefax", label: "WEFAX", activation: "toggle", active_modes: ["USB"] },
{ id: "ais", label: "AIS", activation: "toggle", active_modes: ["FM"] },
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
].map((decoder) => ({ ...decoder, background_decode: false, bookmark_selectable: true }));
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/decoders", []],
["/decoders", decoderRegistry],
["/rigs", rigsResponse],
["/status", {
info: {
@@ -195,6 +217,55 @@ try {
await page.locator("#tab-main").waitFor({ state: "visible" });
assert.equal(new URL(page.url()).pathname, "/");
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 {
await browser.close();
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)
}
#[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
// ---------------------------------------------------------------------------
@@ -637,6 +637,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::recorder_index)
.service(assets::settings_index)
.service(assets::about_index)
.service(assets::statistics_index)
.service(assets::bookmarks_index)
.service(assets::favicon)
.service(assets::favicon_png)
.service(assets::logo)