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>
The header's height depended on the viewport width, and not even
monotonically: 112px at 1440, 169px at 1100, 131px at 900, 246px at 720.
Both control groups wrapped, so every width produced a different ragged
block — eight page tabs across four rows at 1100px, and action controls
across three. Four different control heights (32, 34, 45 and 54px) sat
in the same row, the 54px one being the rig picker with its summary
stacked underneath, and on narrow viewports the icon buttons stretched to
fill half the row, rendering a play triangle centred in a 249px box.
Lay both groups out as one row that never wraps. Controls are a uniform
2rem and no longer stretch, the rig summary sits inline beside its select,
and the page tabs scroll instead of wrapping. Secondary controls —
layout, style and theme — move into an overflow menu when the bar cannot
hold them, leaving audio, record and the rig picker inline.
Deciding when they no longer fit needs natural widths, not rendered ones:
the nav has min-width 0 and scrolls, so it always shrinks to the leftover
space and always reports scrolling, and the bar reports overflow even when
nothing is clipped. scrollWidth on the scroll container is its
unconstrained content width, which is what the fit test compares against
the space available.
Measured after the change: 72px at 1440, 1280, 1100, 900 and 480, every
control 32px, nothing clipped at any width.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
A layout seeds the collapsible sections; it should not hold them there.
applyLayout writes the disclosure state of the advanced, audio and
scheduler sections, and it runs far more often than a layout change:
render() calls applyRigList() for every SSE frame carrying `remotes`,
which calls setActiveRig() unconditionally, which re-applies the layout.
An operator who expanded a section that the selected layout collapses by
default therefore had it shut again within about a second, which read as
the section being locked by the layout — most visibly the scheduler under
Compact.
Write the section state only when the layout actually changes, or the
first time each section appears in the DOM, since the advanced controls
are constructed after the first applyLayout call. Switching layout still
reseeds every section, so choosing a layout keeps meaning "give me these
defaults".
Verified in Chromium: with Compact selected, activating the scheduler
summary opens the section and it survives both a rig-state refresh and a
repeated applyLayout, while selecting Full still reseeds it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
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>
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>
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>
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>
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>
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>
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>
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>
The bookmark fix addressed one instance of a defect the TypeScript
migration left across the feature entries. app.js stopped being a
classic script, so its top-level declarations are no longer shared
globals, but the converted entries kept reading them as window
properties that nothing publishes.
Restore the broken behavior:
- ais, aprs, hf-aprs read serverLat, serverLon and haversineKm as
undefined, so every positioned packet rendered an empty distance.
- ais, aprs, hf-aprs, cw, sat, vdes, wefax, wspr called an undefined
postPath, so clear-history and decoder toggles threw.
- scheduler read authRole as undefined, so the lazy-load path never
self-initialized and the Settings tab opened an inert scheduler.
- background-decode read authEnabled as undefined, so control gating
fell back to role-only.
- vchan read fifteen application values and services as undefined:
mode and bandwidth sync, the out-of-band hint, RX audio restart, and
the frequency field all silently no-opped on a virtual channel.
- vchan wrapped window.refreshFreqDisplay, capturing an undefined
original exactly as it did for setRigFrequency, so leaving a channel
never restored the application's own frequency display.
- _audioChannelOverride was a const that nothing could assign, so RX
audio always subscribed to the primary channel.
- ftx-family read fmtTime, a helper legacy ft8.js owned locally, so
decode bar timestamps rendered empty.
Declare the contract once in plugins/host.ts and import it from the
feature entries, rather than restoring globals that
docs/frontend-architecture.md excludes. trx.state gains jogUnit,
rxActive and audioChannelOverride, and makes lastModeName writable;
trx.core gains the tuning, RDS, WFM, jog and RX audio services the
entries need. vchan interception moves to an interceptFreqDisplay
service method that refreshFreqDisplay calls, matching the frequency,
mode and bandwidth interception it already registers.
Reading registry-built elements through a strict lookup is the same
defect as in bookmarks: renderTimelineNeedle guards its result, but
schedulerEl throws, so the now-initializing scheduler crashed on the
timeline needle group that its own SVG creates.
Feature tests move onto a shared host fixture, and entries that now
import a common module are bundled through bundleEntry like the other
shared-module entries. Covers scheduler self-initialization and the
distance path that the bare window reads broke.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
The TypeScript migration turned app.js from a classic script into an ES
module, so its top-level declarations stopped being shared globals.
bookmarks.ts was converted verbatim and kept reading them as window
properties, which app.ts no longer publishes.
Every bookmark interaction read undefined: the Add Bookmark and Select
All buttons stayed hidden because the auth check saw no authEnabled or
authRole, per-rig scopes were missing from the scope picker and the move
target, decoder checkboxes were never built, and Tune threw on
bridge.postPath before issuing a single request.
Extend the typed window.trx host contract instead of restoring globals,
as docs/frontend-architecture.md closes the standalone window property
list. trx.state publishes authEnabled; trx.core publishes
setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency,
syncBandwidthInput, scheduleSpectrumDraw, and onDecoderRegistryReady.
Replace the vchan setRigFrequency wrapper with an interceptFrequency
service method, matching interceptMode and interceptBandwidth. The
wrapper captured an undefined original and silently dropped every tune;
routing interception through setRigFrequency also restores virtual
channel redirection for the application's own tuning.
Read registry-built elements through bmOptionalEl, since bmEl throws and
the decoder checkboxes and decode toggle buttons are legitimately absent
until the registry arrives.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
The runner image's `reuse` failed to import (NoEncodingModuleError): it
needs an encoding-detection backend, which the bare `reuse` install does
not provide and which libmagic/`file` is not present to satisfy. Install
`reuse[charset-normalizer]` so the reuse job can run in the image.
Rebuild the runner image and recreate the containers to pick this up.
Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
The host-executor runners run jobs inside one container that already has
the Rust toolchain and all build dependencies baked in, so the per-job
`apt-get install` and rustup steps were redundant. Worse, with two jobs
running concurrently in the same runner they collided on the dpkg lock
("Could not get lock /var/lib/dpkg/lock-frontend").
Remove the system-dependency, rustup and cache steps; jobs now run cargo
directly. The cargo registry persists in the long-lived runner container.
Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
The host-executor runners have no Docker daemon, so fsfe/reuse-action
(a Docker action) fails with "Cannot connect to the Docker daemon". Call
the reuse CLI directly; it is baked into the runner image.
Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
Add container/: a rootless Podman + systemd (Quadlet) setup to run
per-project Gitea Actions runners on one host instead of VMs. Uses the
host executor with a purpose-built image that bakes in the Rust toolchain
and all build dependencies (opus, alsa, soapysdr, clang), so CI runs skip
the per-run install and cold soapysdr-sys build.
Includes the runner Containerfile, first-boot registration entrypoint,
act_runner config template, two Quadlet units (trx-rs + a second project),
and a README covering build, registration, the required host-executor
workflow tweak, and tuning.
Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
clippy needless_range_loop (rust 1.97) flagged the loops in ldpc_check
and ldpc_decode that use a range only to index one array. Replace them
with iterator/enumerate forms. The belief-propagation loops that index
several arrays by the same variable are left as-is (not flagged).
Behaviour is unchanged; the transformations are index-for-index
equivalent. Verified the lib compiles and is clippy-clean; the crate's
LDPC tests run in the CI test job (they need a dev-dependency not
available in the local offline sandbox).
Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
get_uint returns Option<u32>, so `? as u32` is an unnecessary same-type
cast flagged by clippy under -D warnings (rust 1.97).
Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
CI runs a newer clippy (1.97) than was available locally, which flagged
three lints in trx-ftx not caught earlier:
- question_mark: replace the Some/None match in CallsignHashTable::lookup
with `self.entries[idx].as_ref()?`
- collapsible_match: fold the nested `if` in text.rs char/nchar into match
guards on the AlphanumSpaceSlash arm
Behaviour is unchanged; verified clean with nightly clippy (1.93).
Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
The CI lint job runs clippy with -D warnings, which surfaced a set of
existing warnings across decoders, the client, and the soapysdr backend.
Resolve them so the workspace is clean under the enforced lint level:
- collapsible_match / identity_op / needless_range_loop / same_item_push
in trx-rds, trx-wspr, trx-vdes, trx-wefax, trx-aprs (mostly tests)
- field_reassign_with_default -> struct-update syntax in trx-client config
tests
- assign_op_pattern, useless vec!, and test-module ordering picked up by
cargo clippy --fix in trx-client and the soapysdr WFM tests
No behaviour changes; all affected crates' tests pass.
Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>