Compare commits

...
Author SHA1 Message Date
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
sjgandClaude Opus 5 b31790ff48 [fix](trx-frontend-http): keep layout sections togglable
CI / lint (pull_request) Successful in 2m21s
CI / frontend (pull_request) Successful in 3m3s
CI / reuse (pull_request) Successful in 3s
CI / test (pull_request) Successful in 8m32s
CI / lint (push) Successful in 2m30s
CI / test (push) Successful in 8m36s
CI / frontend (push) Successful in 2m13s
CI / reuse (push) Successful in 4s
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>
2026-08-02 17:46:56 +02:00
sjgandClaude Opus 5 2f4973ed70 [chore](trx-rs): allow the sccache bind mount on the CI runner
CI / test (pull_request) Successful in 13m51s
CI / frontend (pull_request) Successful in 5m1s
CI / test (push) Successful in 7m43s
CI / frontend (push) Successful in 2m18s
CI / reuse (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 4m22s
CI / lint (push) Successful in 2m23s
CI / reuse (push) Successful in 1m18s
act_runner validates every bind mount against `valid_volumes`, which
defaults to an empty allowlist, so the `-v /var/cache/sccache:/sccache`
in `container.options` was dropped on every job.  The only trace is one
line in the job log — "[/var/cache/sccache] is not a valid volume, will
be ignored" — after which SCCACHE_DIR points at a path that does not
outlive the container, so the shared compilation cache never persisted.

Allow that one path rather than the `**` wildcard: the runner is the only
thing mounting host directories here, and a narrow allowlist keeps a
workflow from mounting arbitrary host paths into a job container.

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 16:54:16 +02:00
sjgandClaude Opus 5 26b00608b2 [chore](trx-rs): force-pull the SDK image on the CI runner
CI / lint (pull_request) Failing after 3s
CI / test (pull_request) Failing after 2s
CI / frontend (pull_request) Failing after 28s
CI / reuse (pull_request) Successful in 3s
The workflow references the SDK image by the moving `:latest` tag, and
act_runner skips the pull when a local copy of that tag already exists:
the job log reports `docker pull ... forcePull=false` followed by
`Image exists? true`.  Pushing a rebuilt image therefore changes nothing
until someone pulls on the VM by hand, and the run fails as though the
image never gained the tool that was added to the Containerfile —
`sccache` resolving as "No such file or directory" while the pinned
toolchain from an earlier build of the same tag resolves fine.

Set `force_pull: true` so a pushed image is what actually runs, and
document the manual refresh for runners configured before this change.

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 16:49:46 +02:00
sjgandClaude Opus 5 c2455bb08c [chore](trx-rs): build the SDK image natively on x86_64 and arm64
CI / reuse (pull_request) Successful in 3s
CI / lint (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / frontend (pull_request) Has been cancelled
The sccache release asset is per-architecture and the Containerfile
hardcoded the x86_64 triple, so an arm64 build produced an image whose
sccache binary could not execute.  Everything else in the image — the
Debian base, the build dependencies, Node.js and rustup — already
resolves per architecture, so that one URL was what pinned the image to
amd64 and forced Rosetta or qemu on Apple Silicon.

Resolve the triple from `uname -m`, which reflects the build platform
under plain docker/podman build as well as buildx, unlike the
BuildKit-only TARGETARCH.

Document publishing `:latest` as a manifest list built natively on a host
of each architecture, since a single-architecture tag sends the other
side back to emulation, and note that Apple's `container` CLI needs
Rosetta for its BuildKit helper VM regardless of the target.

Pick the act_runner download by architecture for the same reason.

Verified on arm64: the case arm selects
sccache-v0.8.2-aarch64-unknown-linux-musl, and the installed binary
reports `sccache 0.8.2` running natively.

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 12:14:44 +02:00
sjgandClaude Opus 5 22ff1349f3 [chore](trx-rs): run the frontend job in the SDK image
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 2s
CI / frontend (pull_request) Failing after 27s
CI / reuse (pull_request) Successful in 5s
The frontend job was added while CI still targeted host-executor runners,
so it never gained the `container:` key the lint and test jobs use.  On
the Docker executor it lands on a bare job container and fails the same
way the Rust jobs did before this branch: `npm` is missing, the Chromium
install shells out to `sudo apt-get`, and `npm run verify-generated`
regenerates the Rust wire contracts, so it needs `cargo` too.

Run it in the SDK image, which already ships Node.js, Chromium at the
path the browser smoke test defaults to, and the pinned Rust toolchain.
Installing Chromium per run is then redundant.

Drop the job's trailing `reuse lint`.  The SDK image deliberately carries
nothing REUSE-related, and the separate `reuse` job lints the whole
repository with the upstream action, generated assets included.

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 11:33:02 +02:00
sjg 8b72573521 [chore](trx-rs): add sccache compilation cache
Bake sccache into the SDK image and enable it via RUSTC_WRAPPER in CI and
the devcontainer (not repo-wide, so non-SDK builds are unaffected).

- container/Containerfile: install the sccache musl binary.
- ci.yml: RUSTC_WRAPPER=sccache, CARGO_INCREMENTAL=0, SCCACHE_DIR=/sccache,
  cache size cap, plus a `sccache --show-stats` step per job.
- runner-config.example.yaml: bind-mount /var/cache/sccache into job
  containers so the cache persists across runs and is shared between jobs.
- .devcontainer: enable sccache with a named cache volume.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:27:42 +02:00
sjg f64032dcbe [chore](trx-rs): add OpenRC service for act_runner on Alpine
The runner host is Alpine (OpenRC, no systemd). Add an OpenRC init script
for act_runner (supervise-daemon, depends on docker) plus a conf.d
example for running one instance per project, and rewrite the runner
section of the README with Alpine setup steps (apk docker, dedicated user
in the docker group, register, service install).

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:27:42 +02:00
sjg 8fd1761688 [chore](trx-rs): use nested SDK image path trx-rs/sdk
Match the image name that was pushed to the registry
(git.haxx.space/sjg/trx-rs/sdk) across the workflow, devcontainer and
README.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:27:42 +02:00
sjg 7022f20b76 [chore](trx-rs): shared SDK image for CI and developers
Rework container/ from a host-executor act_runner image into a single
"SDK" build image used everywhere: as the CI job container (Docker
executor) and by developers locally / via .devcontainer. It bakes in a
pinned Rust toolchain and all build dependencies, so CI and every
developer share the exact same rustc/clippy.

- container/Containerfile: SDK image (Debian + deps + pinned Rust + Node).
- rust-toolchain.toml: pin the toolchain to match the image; also ends the
  "CI clippy newer than local" version skew.
- .gitea/workflows/ci.yml: lint/test run inside the SDK image via
  `container:`; reuse returns to fsfe/reuse-action (Docker executor runs
  it as a sibling container, so nothing REUSE-related is baked in).
- .devcontainer/devcontainer.json: dev use of the same image.
- container/runner-config.example.yaml: Docker-executor runner config for
  the CI VM, capped for a 2-thread budget.
- Drop the obsolete host-executor entrypoint/config/Quadlet units.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:27:42 +02:00
20 changed files with 742 additions and 365 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"name": "trx-rs SDK",
"image": "git.haxx.space/sjg/trx-rs/sdk:latest",
"workspaceFolder": "/work",
"workspaceMount": "source=${localWorkspaceFolder},target=/work,type=bind",
"mounts": [
"source=trx-rs-sccache,target=/sccache,type=volume"
],
"containerEnv": {
"RUSTC_WRAPPER": "sccache",
"CARGO_INCREMENTAL": "0",
"SCCACHE_DIR": "/sccache"
},
"customizations": {
"vscode": {
"extensions": [
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml"
]
}
}
}
+24 -14
View File
@@ -2,10 +2,11 @@
#
# SPDX-License-Identifier: GPL-2.0-or-later
# CI for the self-hosted, host-executor Podman runners (see container/).
# The runner image bakes in the Rust toolchain and all build dependencies,
# so jobs go straight to cargo — no apt/rustup setup steps (which also
# collided on the dpkg lock when jobs ran concurrently in the same runner).
# CI for the Docker-executor runner (VM). The lint, test and frontend jobs run
# inside the shared trx-rs SDK image (container/Containerfile), which bakes in
# the pinned Rust toolchain, Node.js, Chromium and all build dependencies. The
# reuse job uses the upstream Docker action, which the Docker executor launches
# as a sibling container.
name: CI
@@ -16,28 +17,44 @@ on:
env:
CARGO_TERM_COLOR: always
# sccache: shared compilation cache persisted on the runner host (see the
# -v mount in runner-config.example.yaml). CARGO_INCREMENTAL=0 because
# sccache cannot cache incremental artifacts.
RUSTC_WRAPPER: sccache
CARGO_INCREMENTAL: "0"
SCCACHE_DIR: /sccache
SCCACHE_CACHE_SIZE: "20G"
jobs:
lint:
runs-on: ubuntu-latest
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps:
- uses: actions/checkout@v4
- name: rustfmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: sccache stats
if: always()
run: sccache --show-stats
test:
runs-on: ubuntu-latest
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --workspace --all-targets --locked
- name: Test
run: cargo test --workspace --locked
- name: sccache stats
if: always()
run: sccache --show-stats
frontend:
runs-on: ubuntu-latest
container: git.haxx.space/sjg/trx-rs/sdk:latest
defaults:
run:
working-directory: src/trx-client/trx-frontend/trx-frontend-http/frontend
@@ -58,22 +75,15 @@ jobs:
run: npm run lint
- name: Test
run: npm test
- name: Install browser smoke dependency
run: command -v chromium >/dev/null || (sudo apt-get update && sudo apt-get install -y --no-install-recommends chromium)
# Chromium comes from the SDK image at the path the smoke test defaults
# to, so there is nothing to install here.
- name: Browser smoke test
run: npm run test:browser
- name: Verify generated assets
run: npm run verify-generated
- name: Verify generated-file licensing
working-directory: .
run: reuse lint
reuse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: REUSE compliance
# `reuse` CLI instead of fsfe/reuse-action: the latter is a Docker
# action, which the host-executor runners cannot run. `reuse` is baked
# into the runner image (see container/Containerfile).
run: reuse lint
- uses: fsfe/reuse-action@v5
+2
View File
@@ -12,6 +12,8 @@ path = [
"trx-rs.toml.example",
"docs/**",
"aidocs/**",
"container/**",
".devcontainer/**",
"src/decoders/trx-ftx/README.md",
"src/decoders/trx-wxsat/README.md",
"assets/trx-logo.png",
+45 -38
View File
@@ -2,60 +2,67 @@
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Gitea Actions runner image for trx-rs CI (host-executor / "Pattern B").
# trx-rs SDK / build image.
#
# All build dependencies, the Rust toolchain, Node.js (for JS actions such as
# actions/checkout and actions/cache) and the `reuse` tool are baked in, so CI
# runs skip the per-run apt/rustup install cost. `sudo` is present so the
# existing workflow's `sudo apt-get ...` / rustup steps remain valid — they
# just become fast no-ops because everything is already installed.
# Single source of truth for the build environment. Used two ways:
# * CI — as the job container for the lint/test jobs (Docker executor).
# * Dev — run locally or via .devcontainer for a reproducible toolchain.
#
# Pinning the Rust version here (and in rust-toolchain.toml) means CI and every
# developer share the exact same rustc/clippy, so "works locally, fails in CI"
# cannot happen.
FROM docker.io/library/debian:bookworm-slim
ARG ACT_RUNNER_VERSION=0.2.11
# Keep in sync with rust-toolchain.toml.
ARG RUST_VERSION=1.97.1
ARG NODE_MAJOR=20
ENV DEBIAN_FRONTEND=noninteractive \
RUSTUP_HOME=/opt/rustup \
CARGO_HOME=/opt/cargo \
PATH=/opt/cargo/bin:/usr/local/bin:/usr/bin:/bin
RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin
# Base tooling + trx-rs build dependencies (mirrors .gitea/workflows/ci.yml).
# Build dependencies (mirror README's manual instructions).
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl xz-utils git sudo pipx \
ca-certificates curl git \
build-essential pkg-config cmake clang libclang-dev \
libopus-dev libasound2-dev libsoapysdr-dev chromium \
&& rm -rf /var/lib/apt/lists/*
# Node.js (JS-based actions need node in PATH under the host executor).
# Node.js JS-based actions (actions/checkout, actions/cache) run *inside*
# the job container under the Docker executor, so node must be present.
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# REUSE >= 3 (Debian's packaged reuse is too old for REUSE.toml).
# The [charset-normalizer] extra provides an encoding-detection backend;
# without it (and without libmagic) reuse fails to import at runtime.
RUN PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install 'reuse[charset-normalizer]'
# Rust stable with rustfmt + clippy, installed system-wide.
# Pinned Rust toolchain, installed world-readable so any UID the runner or a
# devcontainer uses can invoke cargo.
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \
--component rustfmt --component clippy \
&& chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME"
| sh -s -- -y --no-modify-path \
--default-toolchain "${RUST_VERSION}" --profile minimal \
--component rustfmt --component clippy \
&& chmod -R a+rwX "$RUSTUP_HOME" "$CARGO_HOME"
# act_runner binary.
RUN arch="$(dpkg --print-architecture)"; \
case "$arch" in amd64) rarch=amd64;; arm64) rarch=arm64;; *) echo "unsupported arch $arch" >&2; exit 1;; esac; \
curl -fsSL -o /usr/local/bin/act_runner \
"https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-linux-${rarch}" \
&& chmod +x /usr/local/bin/act_runner
# sccache — shared compilation cache. Enabled at build time via
# RUSTC_WRAPPER (see the CI workflow and .devcontainer), not repo-wide, so
# non-SDK builds are unaffected. musl build is static and runs anywhere.
#
# The release asset is per-architecture, so resolve it from `uname -m` rather
# than hardcoding one triple: everything else in this image is arch-agnostic,
# and a pinned x86_64 URL is what forces an amd64 build (and Rosetta or qemu)
# on an arm64 host. `uname -m` reflects the build platform under plain
# docker/podman build as well as buildx, unlike the BuildKit-only TARGETARCH.
ARG SCCACHE_VERSION=0.8.2
RUN set -eux; \
case "$(uname -m)" in \
x86_64) sccache_arch=x86_64 ;; \
aarch64|arm64) sccache_arch=aarch64 ;; \
*) echo "unsupported architecture for sccache: $(uname -m)" >&2; exit 1 ;; \
esac; \
sccache_dist="sccache-v${SCCACHE_VERSION}-${sccache_arch}-unknown-linux-musl"; \
curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/${sccache_dist}.tar.gz" \
| tar -xz -C /tmp; \
install -m755 "/tmp/${sccache_dist}/sccache" /usr/local/bin/sccache; \
rm -rf /tmp/sccache-*
# Default config template (seeded into the /data volume on first boot).
COPY config.yaml /etc/act_runner/config.yaml
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# /data holds the .runner registration, cache and workflow workspaces.
VOLUME /data
WORKDIR /data
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
WORKDIR /work
+156 -81
View File
@@ -3,116 +3,191 @@ SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Podman-based Gitea Actions runners
# trx-rs SDK image
Run two independent Gitea Actions runners on one host as rootless Podman
containers managed by systemd (Quadlet) — one per project — instead of two
VMs. Uses the **host executor**: workflow steps run directly inside a
purpose-built runner image that already has the Rust toolchain and all build
dependencies baked in, so CI runs skip the per-run install cost and no
Docker/Podman socket is needed.
## Files
A single container image that is the canonical build environment for trx-rs,
used **both** by CI and by developers. It bakes in the pinned Rust toolchain
(matching `rust-toolchain.toml`) and every build dependency, so the compiler
and `clippy` are identical everywhere — no "works on my machine".
| File | Purpose |
|------|---------|
| `Containerfile` | Runner image: Debian + build deps + clang + Rust + Node + `reuse` + `act_runner`. |
| `entrypoint.sh` | Registers on first boot (if needed), then runs the daemon. |
| `config.yaml` | act_runner config template (seeded into each runner's volume). |
| `trx-rs-runner.container` | Quadlet unit for the trx-rs runner. |
| `project2-runner.container` | Quadlet unit for the second project's runner. |
| `Containerfile` | The SDK image (Debian + build deps + pinned Rust + Node + git). |
| `runner-config.example.yaml` | Example act_runner config for the CI VM (Docker executor). |
## Prerequisites (once per host)
## Build and publish
Rootless Podman with cgroups v2 (default on modern distros). As the unprivileged
user that will own the runners:
Nothing in the image is architecture-specific: the base image, the Debian build
dependencies, Node.js, `rustup` and the `sccache` release all resolve per
architecture, so the same `Containerfile` builds natively on x86_64 and arm64.
Single architecture — the tag then only works on the architecture you built it
on:
```bash
# Survive logout / start on boot without an interactive session.
loginctl enable-linger "$USER"
# from the repo root
podman build -t git.haxx.space/sjg/trx-rs/sdk:latest container
podman login git.haxx.space
podman push git.haxx.space/sjg/trx-rs/sdk:latest
```
No `podman.socket` is required for the host executor.
## 1. Build the image
**Both architectures without emulation.** The CI runner is x86_64 and Apple
Silicon developer machines are arm64, so `:latest` has to be a manifest list —
a single-architecture tag makes the other side fall back to Rosetta or qemu.
Build each half natively on a host of that architecture, then join them:
```bash
cd container
podman build -t trx-rs-ci:latest .
# on an x86_64 host
podman build --platform linux/amd64 -t git.haxx.space/sjg/trx-rs/sdk:latest-amd64 container
podman push git.haxx.space/sjg/trx-rs/sdk:latest-amd64
# on an arm64 host
podman build --platform linux/arm64 -t git.haxx.space/sjg/trx-rs/sdk:latest-arm64 container
podman push git.haxx.space/sjg/trx-rs/sdk:latest-arm64
# from either, once both are pushed
podman manifest create git.haxx.space/sjg/trx-rs/sdk:latest \
git.haxx.space/sjg/trx-rs/sdk:latest-amd64 \
git.haxx.space/sjg/trx-rs/sdk:latest-arm64
podman manifest push --all git.haxx.space/sjg/trx-rs/sdk:latest
```
## 2. Get a registration token
Building both from one machine is a single command
(`podman build --platform linux/amd64,linux/arm64 --manifest ...`), but the
foreign half runs under emulation and is slow — the two-host flow above is
what keeps every build native.
For **each** repo: *Settings → Actions → Runners → Create new Runner* and copy
the token. (Org- or instance-level tokens work too if you prefer wider scope.)
Tag with the Rust version too (e.g. `:1.97.1`) if you want reproducible pins.
Make the package **public** (Gitea → Packages → the image → Settings) so the CI
runner and developers can pull it without credentials. If you keep it private,
add `credentials:` under the workflow's `container:` and log the runner into the
registry.
## 3. Install and start the runners
Pushing a rebuilt image is not enough on its own: `:latest` is a moving tag, and
act_runner reuses whatever it cached the first time unless `force_pull: true` is
set (see `runner-config.example.yaml`). Without it the job log says
`Image exists? true` and the run behaves as though the image were never
rebuilt — a tool added to the `Containerfile` reads as missing from the image.
Either set `force_pull`, or refresh the VM's copy by hand:
```bash
mkdir -p ~/.config/containers/systemd
cp trx-rs-runner.container project2-runner.container ~/.config/containers/systemd/
# Paste each repo's token for the FIRST boot only:
# Environment=GITEA_RUNNER_REGISTRATION_TOKEN=xxxx…
$EDITOR ~/.config/containers/systemd/trx-rs-runner.container
$EDITOR ~/.config/containers/systemd/project2-runner.container
systemctl --user daemon-reload
systemctl --user start trx-rs-runner
systemctl --user start project2-runner
systemctl --user status trx-rs-runner
podman logs -f gitea-runner-trx-rs
docker pull git.haxx.space/sjg/trx-rs/sdk:latest
docker run --rm git.haxx.space/sjg/trx-rs/sdk:latest sccache --version
```
Once each runner shows **online** in the repo's runner list, blank out the
`GITEA_RUNNER_REGISTRATION_TOKEN` line again (the registration is persisted in
the `…-data` volume) and `systemctl --user daemon-reload`.
### macOS note
## Required workflow change: the `reuse` job
Apple's `container` CLI builds through a BuildKit helper VM that is configured
with Rosetta whether or not the target is x86_64, so `container build` fails
with *"Rosetta is not installed"* on a clean machine. That is a property of the
builder, not of this image — `container run` works natively without it. Either
install Rosetta once (`softwareupdate --install-rosetta`, after which an arm64
build still produces a native arm64 image), or build with Podman, whose arm64
BuildKit needs no emulation.
The host executor runs steps directly in the container and therefore **cannot
run Docker-based actions**. The current `reuse` job uses `fsfe/reuse-action@v5`,
which is a Docker action. `reuse` is baked into the image, so replace that job
with a plain command:
## Developer use
```yaml
reuse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: REUSE compliance
run: reuse lint
Reproducible one-off build, no local toolchain needed:
```bash
podman run --rm -it -v "$PWD":/work -w /work \
git.haxx.space/sjg/trx-rs/sdk:latest \
cargo build --release
```
The `lint` and `test` jobs need no changes: their `sudo apt-get …` and rustup
steps still run, but become fast no-ops because the image already has those
packages and the toolchain. (`sudo` is included in the image for exactly this
reason.)
Or open the repo in the image via VS Code / JetBrains "Reopen in Container"
(`.devcontainer/devcontainer.json` points at the same image).
> If you would rather keep Docker-based actions and per-run images, use the
> **Docker executor** instead: drop the `:host` suffix from the label in
> `config.yaml`, enable `systemctl --user --now enable podman.socket`, mount it
> into the container, and set `container.docker_host` to the socket path. That
> trades the baked-in speed for stronger per-job isolation.
Building outside the container? `rust-toolchain.toml` pins the same rustc, so
`rustup` installs the matching toolchain automatically.
## Tuning
## CI use
- **`capacity`** (in `config.yaml`) — concurrent jobs per runner. Rust builds
are heavy; 12 is sensible when two runners share a host.
- **`PodmanArgs=--cpus/--memory`** (in each `.container`) — hard resource caps
so one project cannot starve the other.
- **SELinux** — the `:Z` volume flag is already set; keep it if SELinux is
enforcing.
`.gitea/workflows/ci.yml` runs the `lint`, `test` and `frontend` jobs *inside*
this image via the `container:` key, so they skip all setup and go straight to
`cargo` and `npm`. The frontend job needs three things from the image beyond
Rust: Node.js for the toolchain, Chromium at `/usr/bin/chromium` for the
browser smoke test, and `cargo``npm run verify-generated` regenerates the
Rust wire contracts before checking for drift.
## Committing these files
The `reuse` job stays on the upstream `fsfe/reuse-action` (a Docker action the
Docker executor launches as a sibling container) — nothing REUSE-related is
baked into the SDK, and it lints the whole repository, so no job runs its own
licence check.
If you add this directory to a REUSE-checked repo, register the markdown in
`REUSE.toml` (the other files carry inline SPDX headers):
## Compilation cache (sccache)
```toml
[[annotations]]
path = ["container/**"]
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
SPDX-License-Identifier = "GPL-2.0-or-later"
The SDK image ships [`sccache`](https://github.com/mozilla/sccache). It is
enabled via `RUSTC_WRAPPER=sccache` in CI and the devcontainer (not repo-wide,
so plain `cargo` builds outside the SDK are unaffected).
- **CI** persists the cache on the runner host — create the dir once:
`mkdir -p /var/cache/sccache`. It is bind-mounted into each job container at
`/sccache` (see `runner-config.example.yaml`), so cache survives across runs
and is shared between the lint/test jobs and both projects.
- **Devcontainer** uses a named volume (`trx-rs-sccache`).
- Check effectiveness with `sccache --show-stats` (the CI jobs print it).
`CARGO_INCREMENTAL=0` is set wherever sccache is on, since sccache cannot cache
incremental artifacts.
## CI runner (Alpine / OpenRC)
The runner uses the **Docker executor** (not the host executor): per-job
container isolation and standard `ubuntu-latest` semantics. `act_runner` runs
as an OpenRC service. Files provided:
| File | Purpose |
|------|---------|
| `act_runner.openrc` | OpenRC init script (`supervise-daemon`, depends on docker). |
| `act_runner.confd.example` | Per-instance `conf.d` settings for multi-runner hosts. |
**Cap the thread budget.** In a VM, pin its vCPUs to specific host threads
(libvirt/KVM):
```xml
<vcpu placement='static'>2</vcpu>
<cputune>
<vcpupin vcpu='0' cpuset='4'/>
<vcpupin vcpu='1' cpuset='5'/>
</cputune>
```
On bare metal, the `container.options: "--cpus=2"` and `capacity: 1` in
`runner-config.example.yaml` already bound each runner.
**Set it up:**
```bash
# 1. Docker + a dedicated user with socket access
apk add docker docker-cli
rc-update add docker default && rc-service docker start
adduser -S -D -H -h /var/lib/act_runner act
addgroup act docker
# 2. act_runner binary (static Go build, works on musl)
# Upstream publishes per-architecture builds; pick the host's.
case "$(uname -m)" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac
curl -fsSL -o /usr/local/bin/act_runner \
"https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-${arch}"
chmod +x /usr/local/bin/act_runner
# 3. Config + register one runner per project (scope keeps their jobs apart)
install -Dm644 container/runner-config.example.yaml /etc/act_runner/trx-rs.yaml
install -d -o act /var/lib/act_runner/trx-rs
su act -s /bin/sh -c 'cd /var/lib/act_runner/trx-rs && \
act_runner register --no-interactive \
--instance https://git.haxx.space --token <TOKEN> \
--name trx-rs-ci \
--labels "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"'
# 4. OpenRC service (repeat the symlink+conf.d for the second project)
install -m755 container/act_runner.openrc /etc/init.d/act_runner
ln -s act_runner /etc/init.d/act_runner.trx-rs
install -m644 container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
rc-update add act_runner.trx-rs default
rc-service act_runner.trx-rs start
```
Check it with `rc-service act_runner.trx-rs status` and
`tail -f /var/log/act_runner.trx-rs.log`.
+14
View File
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Per-instance settings for an act_runner OpenRC service.
# Copy to /etc/conf.d/<service-name>, e.g. /etc/conf.d/act_runner.trx-rs
# (the name must match the /etc/init.d/ symlink).
# User that runs the daemon. Must be a member of the `docker` group.
runner_user="act"
# Per-instance state dir (holds the .runner registration) and config file,
# so two runners on one host stay independent.
runner_dir="/var/lib/act_runner/trx-rs"
runner_config="/etc/act_runner/trx-rs.yaml"
+44
View File
@@ -0,0 +1,44 @@
#!/sbin/openrc-run
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
# SPDX-License-Identifier: GPL-2.0-or-later
#
# OpenRC service for a Gitea act_runner (Docker executor) on Alpine.
#
# Install as /etc/init.d/act_runner (chmod +x). Single instance uses
# /etc/act_runner/config.yaml. For one runner per project, symlink this script
# and add a matching conf.d file:
#
# ln -s act_runner /etc/init.d/act_runner.trx-rs
# cp container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
# $EDITOR /etc/conf.d/act_runner.trx-rs # set runner_dir / runner_config
# rc-update add act_runner.trx-rs default
# rc-service act_runner.trx-rs start
description="Gitea Actions runner"
: "${runner_user:=act}"
: "${runner_dir:=/var/lib/act_runner}"
: "${runner_config:=/etc/act_runner/config.yaml}"
command="/usr/local/bin/act_runner"
command_args="daemon --config ${runner_config}"
# No group given, so supplementary groups (incl. docker) are initialised.
command_user="${runner_user}"
directory="${runner_dir}"
supervisor="supervise-daemon"
respawn_delay=5
respawn_max=0
pidfile="/run/${RC_SVCNAME}.pid"
output_log="/var/log/${RC_SVCNAME}.log"
error_log="/var/log/${RC_SVCNAME}.log"
depend() {
need docker
use net dns
}
start_pre() {
checkpath -d -m 0750 -o "${runner_user}" "${runner_dir}"
checkpath -f -m 0640 -o "${runner_user}" "${output_log}"
}
-32
View File
@@ -1,32 +0,0 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# act_runner configuration template. Seeded into /data/config.yaml on first
# boot; edit the copy inside the volume to change settings per runner.
log:
level: info
runner:
# Registration state. Relative to the daemon's working directory (/data).
file: .runner
# Concurrent jobs this runner will pick up. Rust builds are heavy — keep this
# modest, especially if two runners share one host. The trx-rs workflow has
# three parallel jobs (lint, test, reuse); capacity 2 lets two overlap.
capacity: 2
timeout: 3h
# Map the workflow's `runs-on: ubuntu-latest` to the HOST executor, i.e. run
# steps directly inside THIS container (which already has all the toolchain).
# No Docker/Podman socket is required in this mode.
labels:
- "ubuntu-latest:host"
cache:
# Built-in actions cache server (used by actions/cache). Stored in the volume.
enabled: true
dir: "/data/cache"
host:
# Where per-job workspaces are created.
workdir_parent: /data/workflows
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Registers the runner on first boot (if no .runner state exists in /data),
# then runs the act_runner daemon. Idempotent: on subsequent boots it reuses
# the stored registration and ignores the token.
set -euo pipefail
CONFIG_FILE="${CONFIG_FILE:-/data/config.yaml}"
cd /data
# Seed the config from the image's template on first boot so it lives in the
# persistent volume and can be edited there.
if [ ! -f "$CONFIG_FILE" ]; then
cp /etc/act_runner/config.yaml "$CONFIG_FILE"
fi
# runner.file in config.yaml is ".runner" (relative to this CWD => /data/.runner).
if [ ! -f /data/.runner ]; then
if [ -z "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" ]; then
echo "ERROR: no /data/.runner registration and GITEA_RUNNER_REGISTRATION_TOKEN is empty." >&2
echo " Grab a token from the repo's Settings -> Actions -> Runners and set it" >&2
echo " in the Quadlet unit for the first boot only." >&2
exit 1
fi
echo "Registering runner '${GITEA_RUNNER_NAME:-podman}' with ${GITEA_INSTANCE_URL} ..."
act_runner register --no-interactive \
--config "$CONFIG_FILE" \
--instance "${GITEA_INSTANCE_URL:?set GITEA_INSTANCE_URL}" \
--token "$GITEA_RUNNER_REGISTRATION_TOKEN" \
--name "${GITEA_RUNNER_NAME:-podman}" \
--labels "${GITEA_RUNNER_LABELS:-ubuntu-latest:host}"
fi
exec act_runner daemon --config "$CONFIG_FILE"
-40
View File
@@ -1,40 +0,0 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Rootless Podman Quadlet for the SECOND project's Gitea Actions runner,
# co-located on the same host as the trx-rs runner.
#
# It has its own name, its own data volume and its own registration token, so
# the two runners are fully independent. They share the `ubuntu-latest` label,
# but registration SCOPE (which repo each token came from) keeps their jobs
# separate — neither will pick up the other's work.
#
# If project 2 needs different build dependencies, build it its own image from
# an adjusted Containerfile and point Image= at that instead of reusing the
# trx-rs image below.
[Unit]
Description=Gitea Actions runner — project 2
After=network-online.target
Wants=network-online.target
[Container]
Image=localhost/gitea-act-runner:latest
ContainerName=gitea-runner-project2
Volume=gitea-runner-project2-data:/data:Z
Environment=CONFIG_FILE=/data/config.yaml
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
Environment=GITEA_RUNNER_NAME=project2-podman
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
PodmanArgs=--cpus=4.0 --memory=6g
[Service]
Restart=always
TimeoutStartSec=0
[Install]
WantedBy=default.target
+47
View File
@@ -0,0 +1,47 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Example act_runner config for the Docker-executor runner that lives in the
# CI VM. This is NOT the SDK image — it configures the runner that launches
# per-job containers (including the trx-rs SDK image referenced by the
# workflow's `container:` key). Copy to the VM and pass with
# `act_runner daemon --config`.
log:
level: info
runner:
file: .runner
# One concurrent job. With one runner per project on a 2-vCPU VM this keeps
# total CI usage at ~2 threads.
capacity: 1
timeout: 3h
# Docker executor: no ":host" suffix. Maps runs-on labels to base images
# (the workflow overrides these per job via `container:`).
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
cache:
enabled: true
container:
# Cap every job container's CPU so CI stays within the 2-thread budget even
# if capacity is raised later. The -v mount persists the sccache cache on the
# host (create it first: `mkdir -p /var/cache/sccache`), matching SCCACHE_DIR
# in the workflow.
options: "--cpus=2 -v /var/cache/sccache:/sccache"
# act_runner rejects every bind mount unless it is listed here — the default
# is an empty allowlist, so the -v above is dropped with only a
# "[...] is not a valid volume, will be ignored" line in the job log, and
# SCCACHE_DIR then points at a directory that does not outlive the job.
valid_volumes:
- /var/cache/sccache
# Reuse the host VM's Docker network for the built-in cache/artifact server.
network: "host"
# The workflow pulls the SDK image by the moving `:latest` tag. Without this
# the runner logs "Image exists? true" and reuses whatever it cached the
# first time, so pushing a rebuilt image has no effect until someone pulls
# on the VM by hand — which looks like the image is missing a tool it in
# fact has. The extra registry round-trip per job is nothing next to a build.
force_pull: true
-41
View File
@@ -1,41 +0,0 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Rootless Podman Quadlet for the trx-rs Gitea Actions runner.
# Install to ~/.config/containers/systemd/trx-rs-runner.container then:
# systemctl --user daemon-reload
# systemctl --user start trx-rs-runner
#
# First boot only: paste a registration token (repo Settings -> Actions ->
# Runners) into GITEA_RUNNER_REGISTRATION_TOKEN. After the runner appears
# online you can blank it again — the registration is persisted in the volume.
[Unit]
Description=Gitea Actions runner — trx-rs
After=network-online.target
Wants=network-online.target
[Container]
Image=localhost/trx-rs-ci:latest
ContainerName=gitea-runner-trx-rs
# Persistent state: .runner registration, cache, workspaces.
Volume=gitea-runner-trx-rs-data:/data:Z
Environment=CONFIG_FILE=/data/config.yaml
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
Environment=GITEA_RUNNER_NAME=trx-rs-podman
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
# Resource caps so a heavy Rust build here cannot starve the other project's
# runner on the same host. Tune to your box.
PodmanArgs=--cpus=4.0 --memory=6g
[Service]
Restart=always
# A cold Rust build can be slow; don't let systemd consider startup failed.
TimeoutStartSec=0
[Install]
WantedBy=default.target
+11
View File
@@ -0,0 +1,11 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Pins the Rust toolchain for reproducible builds. Keep in sync with the
# SDK image (container/Containerfile, ARG RUST_VERSION). rustup honours this
# automatically for local builds outside the SDK container.
[toolchain]
channel = "1.97.1"
components = ["rustfmt", "clippy"]
@@ -637,6 +637,22 @@ function elementById(id) {
};
const layoutCapabilities = { broadcast: false, digital: false };
let activeRigId = null;
const layoutSections = [
{ id: "advanced-radio-controls", key: "advanced" },
{ id: "audio-controls", key: "audio" },
{ id: "scheduler-controls", key: "scheduler" }
];
const seededSections = /* @__PURE__ */ new Set();
let appliedLayoutName = null;
function seedLayoutSections(layout, layoutChanged) {
layoutSections.forEach(({ id, key }) => {
const section = document.getElementById(id);
if (!section) return;
if (!layoutChanged && seededSections.has(id)) return;
seededSections.add(id);
section.open = layout[key];
});
}
function layoutStorageKey() {
return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout";
}
@@ -690,12 +706,9 @@ function elementById(id) {
const layout = layouts[permittedName];
document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact";
if (options.persist !== false) localStorage.setItem(layoutStorageKey(), permittedName);
const details = document.getElementById("advanced-radio-controls");
if (details) details.open = layout.advanced;
const audioDetails = document.getElementById("audio-controls");
if (audioDetails) audioDetails.open = layout.audio;
const schedulerDetails = document.getElementById("scheduler-controls");
if (schedulerDetails) schedulerDetails.open = layout.scheduler;
const layoutChanged = appliedLayoutName !== permittedName;
appliedLayoutName = permittedName;
seedLayoutSections(layout, layoutChanged);
if (options.navigate && typeof browserWindow.navigateToTab === "function") {
browserWindow.navigateToTab(layout.preferredTab);
}
@@ -734,6 +747,90 @@ function elementById(id) {
api.applyLayout(savedLayoutName(), { persist: false });
}
}
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
function anchorMenu(button, menu) {
const rect = button.getBoundingClientRect();
menu.style.position = "fixed";
menu.style.zIndex = "200";
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) || !wrap.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() {
const nav = document.querySelector(".tab-bar-nav");
if (!nav || document.getElementById("mobile-more-btn")) return;
@@ -773,7 +870,10 @@ function elementById(id) {
more.addEventListener("click", () => {
const open = menu.classList.toggle("is-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) => {
if (!(event.target instanceof Node) || !menu.contains(event.target) && !more.contains(event.target)) closeMore();
@@ -840,6 +940,7 @@ function elementById(id) {
api.init = function init() {
ensureLiveRegions();
installLayoutControls();
installTopBarOverflow();
installMobileMore();
installDecoderPicker();
installDecoderBadges();
@@ -1934,7 +2035,6 @@ var signalSplitValueEl = document.getElementById("signal-split-value");
var overviewPeakHoldEl = document.getElementById("overview-peak-hold");
var themeToggleBtn = document.getElementById("theme-toggle");
var headerRigSwitchSelect = document.getElementById("header-rig-switch-select");
var headerRigSummary = document.getElementById("header-rig-summary");
var headerStylePickSelect = document.getElementById("header-style-pick-select");
var rdsPsOverlay = document.getElementById("rds-ps-overlay");
var tabMainEl = document.getElementById("tab-main");
@@ -2651,19 +2751,6 @@ function populateRigPicker(selectEl, rigIds, activeRigId, 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) {
if (!rigSubtitle) return;
const name = activeRigId && lastRigDisplayNames[activeRigId] || activeRigId || "--";
@@ -2694,7 +2781,6 @@ function applyRigList(activeRigId, rigIds, displayNames = {}) {
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
if (rigListChanged) {
window.trx.modules.scheduler?.setRig(lastActiveRigId);
@@ -4955,7 +5041,7 @@ async function switchRigFromSelect(selectEl) {
rigSwitchInProgress = true;
setControlPending(selectEl, true);
selectEl.closest(".header-rig-switch")?.classList.add("is-switching");
updateRigIdentitySummary(nextRig, true);
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`);
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`);
try {
const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : "";
@@ -4963,7 +5049,6 @@ async function switchRigFromSelect(selectEl) {
lastActiveRigId = nextRig;
resetDecoderStateOnRigSwitch();
updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
@@ -4982,7 +5067,6 @@ async function switchRigFromSelect(selectEl) {
} catch (err) {
console.error("select_rig failed:", err);
selectEl.value = prevRig || "";
updateRigIdentitySummary(prevRig);
window.trxUi?.notify("Rig could not be switched", { kind: "error" });
} finally {
rigSwitchInProgress = false;
@@ -46,7 +46,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="tab-bar-nav" aria-label="Primary navigation">
<button class="tab active" data-tab="main">
<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 class="tab" data-tab="bookmarks">
<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>
<div class="header-rig-switch">
<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 class="header-style-pick">
<select id="header-style-pick-select" aria-label="Select UI style">
@@ -1229,12 +1229,20 @@ small { color: var(--text-muted); }
gap: 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 {
display: flex;
align-items: flex-start;
gap: 0.45rem 0.6rem;
align-items: center;
gap: 0.4rem;
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 {
width: 2rem;
@@ -1403,21 +1411,13 @@ small { color: var(--text-muted); }
gap: 0.8rem;
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 {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 0.35rem;
}
.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;
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.header-rig-switch select {
min-width: 8rem;
@@ -1531,7 +1531,17 @@ small { color: var(--text-muted); }
align-items: center;
gap: 0.2rem;
min-width: 0;
flex-wrap: wrap;
/* 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. */
flex-wrap: nowrap;
}
.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) {
.tab-bar-nav .tab .tab-label { display: none; }
.tab-bar-nav .tab { padding: 0.5rem 0.6rem; }
}
.tab {
background: transparent;
@@ -2964,7 +2974,10 @@ button.is-active {
text-transform: uppercase;
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 {
width: auto;
min-height: 2rem;
@@ -3060,9 +3073,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"] #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 {
position: fixed;
position: absolute;
top: calc(100% + 0.4rem);
right: 0;
right: max(0.75rem, env(safe-area-inset-right));
bottom: calc(5.4rem + env(safe-area-inset-bottom));
z-index: 80;
@@ -3259,6 +3285,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
.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"] {
color: var(--accent-text);
background: color-mix(in srgb, var(--accent-green) 10%, transparent);
@@ -3282,24 +3314,24 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.toast-region { bottom: calc(5.7rem + env(safe-area-inset-bottom)); }
.top-bar-actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.45rem;
justify-content: flex-end;
flex-wrap: nowrap;
gap: 0.4rem;
}
.operator-layout-picker { max-width: 100%; }
.operator-layout-picker select { max-width: 10rem; }
.header-rig-switch,
.header-style-pick {
flex: 1 1 12rem;
flex: 0 0 auto;
min-width: 0;
}
.header-rig-switch select,
.header-style-pick select {
width: 100%;
width: auto;
min-width: 0;
}
.header-bar-btn {
flex: 1 1 calc(50% - 0.3rem);
flex: 0 0 auto;
min-width: 0;
}
#tab-main,
@@ -5379,3 +5411,34 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
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; }
@@ -724,7 +724,6 @@ const signalSplitValueEl = document.getElementById("signal-split-value");
const overviewPeakHoldEl = document.getElementById("overview-peak-hold") as HTMLInputElement | null;
const themeToggleBtn = document.getElementById("theme-toggle") as HTMLButtonElement | 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 rdsPsOverlay = document.getElementById("rds-ps-overlay");
const tabMainEl = document.getElementById("tab-main");
@@ -1371,20 +1370,6 @@ function populateRigPicker(selectEl: HTMLSelectElement | null, rigIds: string[],
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) {
if (!rigSubtitle) return;
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";
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
if (rigListChanged) {
window.trx.modules.scheduler?.setRig(lastActiveRigId);
@@ -4023,7 +4007,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
rigSwitchInProgress = true;
setControlPending(selectEl, true);
selectEl.closest(".header-rig-switch")?.classList.add("is-switching");
updateRigIdentitySummary(nextRig, true);
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`);
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`);
try {
const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : "";
@@ -4031,7 +4015,6 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
lastActiveRigId = nextRig;
resetDecoderStateOnRigSwitch();
updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
@@ -4050,7 +4033,6 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
} catch (err) {
console.error("select_rig failed:", err);
selectEl.value = prevRig || "";
updateRigIdentitySummary(prevRig);
window.trxUi?.notify("Rig could not be switched", { kind: "error" });
} finally {
rigSwitchInProgress = false;
@@ -195,6 +195,31 @@ function elementById<T extends HTMLElement>(id: string): T {
const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: false };
let activeRigId: string | null = null;
// A layout seeds the collapsible sections; it does not hold them there.
// applyLayout runs on every rig-state refresh, so re-applying the disclosure
// state unconditionally would reopen or close sections under the operator
// once a second — the reason a section could not be expanded while a layout
// that collapses it was selected. Sections are therefore written only when
// the layout actually changes, or the first time each one appears in the DOM
// (the advanced controls are built after the first applyLayout call).
const layoutSections: { id: string; key: "advanced" | "audio" | "scheduler" }[] = [
{ id: "advanced-radio-controls", key: "advanced" },
{ id: "audio-controls", key: "audio" },
{ id: "scheduler-controls", key: "scheduler" },
];
const seededSections = new Set<string>();
let appliedLayoutName: LayoutName | null = null;
function seedLayoutSections(layout: OperatorLayout, layoutChanged: boolean) {
layoutSections.forEach(({ id, key }) => {
const section = document.getElementById(id) as HTMLDetailsElement | null;
if (!section) return;
if (!layoutChanged && seededSections.has(id)) return;
seededSections.add(id);
section.open = layout[key];
});
}
function layoutStorageKey() {
return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout";
}
@@ -255,12 +280,9 @@ function elementById<T extends HTMLElement>(id: string): T {
const layout = layouts[permittedName];
document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact";
if (options.persist !== false) localStorage.setItem(layoutStorageKey(), permittedName);
const details = document.getElementById("advanced-radio-controls") as HTMLDetailsElement | null;
if (details) details.open = layout.advanced;
const audioDetails = document.getElementById("audio-controls") as HTMLDetailsElement | null;
if (audioDetails) audioDetails.open = layout.audio;
const schedulerDetails = document.getElementById("scheduler-controls") as HTMLDetailsElement | null;
if (schedulerDetails) schedulerDetails.open = layout.scheduler;
const layoutChanged = appliedLayoutName !== permittedName;
appliedLayoutName = permittedName;
seedLayoutSections(layout, layoutChanged);
if (options.navigate && typeof browserWindow.navigateToTab === "function") {
browserWindow.navigateToTab(layout.preferredTab);
}
@@ -300,6 +322,118 @@ 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();
menu.style.position = "fixed";
menu.style.zIndex = "200";
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) || !wrap.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() {
const nav = document.querySelector(".tab-bar-nav");
if (!nav || document.getElementById("mobile-more-btn")) return;
@@ -339,7 +473,10 @@ function elementById<T extends HTMLElement>(id: string): T {
more.addEventListener("click", () => {
const open = menu.classList.toggle("is-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) => {
if (!(event.target instanceof Node) || (!menu.contains(event.target) && !more.contains(event.target))) closeMore();
@@ -404,6 +541,7 @@ function elementById<T extends HTMLElement>(id: string): T {
api.init = function init() {
ensureLiveRegions();
installLayoutControls();
installTopBarOverflow();
installMobileMore();
installDecoderPicker();
installDecoderBadges();
@@ -184,7 +184,10 @@ try {
await page.locator('.tab[data-tab="main"]').click();
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" });
assert.equal(new URL(page.url()).pathname, "/about");
@@ -143,4 +143,31 @@ ui.applyLayout("digital");
assert.equal(document.body.dataset.operatorLayout, "compact");
assert.equal(localStorage.getItem("trxOperatorLayout:rig-b"), "compact");
// A layout seeds the collapsible sections but must not hold them there:
// applyLayout runs on every rig-state refresh, so an operator who expands a
// section a layout collapses by default had it shut again about once a second.
const schedulerSection = new Element("details", document);
schedulerSection.id = "scheduler-controls";
const audioSection = new Element("details", document);
audioSection.id = "audio-controls";
ui.setActiveRig("rig-c");
ui.applyLayout("compact");
assert.equal(schedulerSection.open, false, "compact seeds the scheduler section closed");
schedulerSection.open = true; // operator expands it
ui.applyLayout("compact"); // the next rig-state refresh
assert.equal(schedulerSection.open, true, "re-applying the same layout leaves sections alone");
ui.setActiveRig("rig-c");
assert.equal(schedulerSection.open, true, "a rig-state refresh leaves sections alone");
// Choosing a different layout is an explicit request for its defaults.
ui.applyLayout("full");
assert.equal(schedulerSection.open, true, "full seeds the scheduler section open");
assert.equal(audioSection.open, true, "full seeds the audio section open");
audioSection.open = false;
ui.applyLayout("compact");
assert.equal(audioSection.open, false, "compact seeds the audio section closed");
assert.equal(schedulerSection.open, false, "switching layout reseeds every section");
console.log("ui-core component tests passed");