Compare commits

..
Author SHA1 Message Date
sjg 4b17b4ac1d [style](trx-ftx): use iterators in LDPC single-index loops
CI / lint (pull_request) Successful in 2m35s
CI / test (pull_request) Successful in 3m22s
CI / reuse (pull_request) Successful in 7s
CI / lint (push) Successful in 2m34s
CI / test (push) Successful in 3m26s
CI / reuse (push) Successful in 7s
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>
2026-07-18 10:43:01 +02:00
sjg 977f7b709e [style](trx-ais): drop redundant u32 cast
CI / lint (pull_request) Failing after 2m28s
CI / test (pull_request) Successful in 3m23s
CI / reuse (pull_request) Successful in 7s
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>
2026-07-18 10:28:55 +02:00
sjg a2838b06a2 [style](trx-ftx): fix clippy question_mark and collapsible_match
CI / lint (pull_request) Failing after 2m22s
CI / test (pull_request) Successful in 3m27s
CI / reuse (pull_request) Successful in 8s
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>
2026-07-18 09:25:12 +02:00
sjg c6cd661676 [style](trx-rs): fix clippy warnings for -D warnings CI
CI / lint (pull_request) Failing after 4m14s
CI / test (pull_request) Successful in 15m22s
CI / reuse (pull_request) Successful in 7s
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>
2026-07-17 23:49:12 +02:00
sjg bf08c7ebc0 [chore](trx-rs): install libclang for soapysdr-sys in CI
CI / lint (pull_request) Failing after 4m7s
CI / reuse (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
soapysdr-sys builds bindings with bindgen, which needs libclang at build
time. Add clang and libclang-dev to the system dependencies so the
soapysdr backend (a default feature) compiles under CI.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 23:30:49 +02:00
sjg c7470acc1c [chore](trx-rs): fix Rust PATH handling in CI
CI / lint (pull_request) Failing after 3m18s
CI / reuse (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
The Set up Rust step wrote the cargo bin dir to GITHUB_PATH, which does
not help within the same step and is not relied upon across steps on the
Gitea act_runner. Prepend $HOME/.cargo/bin to PATH directly in the setup
and each cargo step instead, so rustup and cargo resolve regardless of
GITHUB_PATH support.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 23:25:21 +02:00
sjg bcde03de38 [docs](trx-rs): track improvement areas in aidocs/wip.md
CI / lint (pull_request) Failing after 31s
CI / reuse (pull_request) Successful in 35s
CI / test (pull_request) Failing after 29s
Record the three-tier improvement backlog (infrastructure, robustness,
product) with a per-item progress status column, so the work discovered
in the July 2026 repo scan is tracked. Register aidocs/** in REUSE.toml
alongside docs/**.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 21:21:57 +02:00
sjg 63d46562c7 [chore](trx-rs): add Gitea Actions CI pipeline
Introduce CI running on push to main and pull requests. Three jobs:
lint (rustfmt check + clippy with -D warnings), test (build and test the
workspace with --locked), and REUSE compliance. Installs the required
system libraries (opus, alsa, soapysdr) and caches the cargo registry
and target directory.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 21:21:57 +02:00
sjg e575b3b365 [docs](trx-rs): adopt kernel-style AI attribution trailers
Replace the guidance to credit LLM usage via 'Co-authored-by:' with the
Linux kernel convention. Co-Authored-By and Co-Developed-By are reserved
for human authors (who must also sign off); AI/LLM assistance is
disclosed with an Assisted-By: trailer instead.

Apply the new policy to this commit as a worked example.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 21:10:09 +02:00
sjgandClaude Opus 4.8 2121817c04 [style](trx-frontend-http): refine web UI design system and a11y
Introduce a shared design-token layer and apply it to the web
frontend for a more consistent, readable, and accessible feel.
Pure CSS plus one HTML block; no app.js behaviour changes, and the
existing multi-theme (style x light/dark) system is preserved.

- Add spacing, type, radius and motion tokens to :root.
- Fix undefined --surface consumed by .controls-tray, which dropped
  the panel background entirely; it now resolves per theme.
- Set body line-height and font smoothing; collapse near-duplicate
  font sizes onto canonical scale steps.
- Rebuild the auth gate with classes and replace hardcoded colours
  (#ff6b6b, #9aa4b5) with theme variables so it renders correctly in
  every theme.
- Add an opacity fade on tab switches, gated by prefers-reduced-motion.
- Extend focus-visible rings to the spectrum canvas, tabindex controls
  and links.
- Add a self-theming --accent-text token to lift accent-coloured text
  (links, active tab) to WCAG AA without altering brand fills.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 20:57:43 +02:00
sjgandClaude Opus 4.7 d0bd38d7df [docs](trx-frontend-http): point footer repo link to Gitea
Repoint the web UI footer link to git.haxx.space/sjg/trx-rs, swap the
GitHub octocat mark for a host-neutral git-branch icon, and relabel to
"trx-rs source".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-05-18 00:06:45 +02:00
sjgandClaude Opus 4.7 bf9617e24d [docs](trx-rs): migrate wiki links from GitHub to Gitea
Point README wiki/repo URLs at git.haxx.space/sjg/trx-rs (the new
primary upstream); same /wiki/<Page> path scheme as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-05-18 00:05:29 +02:00
sjgandClaude Opus 4.7 3c235fce5e [chore](trx-rs): REUSE 3.3 compliance, drop GitHub wiki workflow
Add a root REUSE.toml annotating docs, repo metadata, logos, vendored
Leaflet (BSD-2-Clause) and the DSEG14 font (OFL-1.1) instead of
per-file headers; add LICENSES/OFL-1.1.txt. reuse lint: compliant.

Also remove .github/workflows/wiki.yml: the project moved off GitHub
to a self-hosted Gitea, so the GitHub Actions wiki-publishing workflow
no longer runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-05-18 00:05:24 +02:00
24 changed files with 644 additions and 284 deletions
+106
View File
@@ -0,0 +1,106 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential pkg-config cmake clang libclang-dev \
libopus-dev libasound2-dev libsoapysdr-dev
- name: Set up Rust
run: |
export PATH="$HOME/.cargo/bin:$PATH"
if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal
fi
rustup toolchain install stable --profile minimal \
--component rustfmt --component clippy
rustup default stable
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: cargo-${{ runner.os }}-
- name: rustfmt
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo fmt --all -- --check
- name: clippy
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo clippy --workspace --all-targets --all-features -- -D warnings
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential pkg-config cmake clang libclang-dev \
libopus-dev libasound2-dev libsoapysdr-dev
- name: Set up Rust
run: |
export PATH="$HOME/.cargo/bin:$PATH"
if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal
fi
rustup toolchain install stable --profile minimal
rustup default stable
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: cargo-${{ runner.os }}-
- name: Build
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --workspace --all-targets --locked
- name: Test
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo test --workspace --locked
reuse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: REUSE compliance
uses: fsfe/reuse-action@v5
-39
View File
@@ -1,39 +0,0 @@
name: Sync docs to Wiki
on:
push:
branches: [main]
paths:
- 'docs/**'
workflow_dispatch:
permissions:
contents: write
jobs:
wiki:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Checkout wiki
uses: actions/checkout@v4
with:
repository: ${{ github.repository }}.wiki
path: wiki
token: ${{ secrets.GITHUB_TOKEN }}
- name: Sync docs to wiki
run: |
rsync -av --delete --exclude='.git' docs/ wiki/
cd wiki
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
if git diff --cached --quiet; then
echo "No wiki changes to commit."
else
git commit -m "Sync docs from ${GITHUB_SHA::8}"
git push
fi
+21 -1
View File
@@ -18,7 +18,7 @@ When contributing to the project, please follow these guidelines:
- Use a maximum of 80 characters per line.
- Use a blank line between the commit message and the body.
- Sign your commits with `git commit -s`.
- Explicitly mark LLM usage in commit messages with 'Co-authored-by:'.
- Disclose AI/LLM assistance with an `Assisted-By:` trailer (see below).
Use the format below for commit titles:
[<type>](<crate>): <description>
@@ -39,3 +39,23 @@ Allowed types:
- chore: build or maintenance changes
Write isolated commits for each crate.
## Attribution trailers
This project follows the Linux kernel convention for crediting work.
The `Co-Authored-By:` and `Co-Developed-By:` trailers name **people** who
authored the change. They are reserved for humans, and every person named
this way must also add their own `Signed-off-by:` line. Never use these
trailers for tools, assistants, or bots.
When a commit was produced with help from an AI assistant or LLM,
disclose it with an `Assisted-By:` trailer naming the tool (and model,
where relevant). The human committer remains the author of record and
takes responsibility for the change through `Signed-off-by:`.
Example:
Assisted-By: Claude Code (claude-opus-4)
Co-Authored-By: Jane Developer <jane@example.com>
Signed-off-by: Your Name <you@example.com>
+43
View File
@@ -0,0 +1,43 @@
SIL OPEN FONT LICENSE
Version 1.1 - 26 February 2007
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
+5 -5
View File
@@ -64,7 +64,7 @@ brew install soapysdr
```
</details>
See [Build Requirements](https://github.com/sgrams/trx-rs/wiki/User-Manual#build-requirements)
See [Build Requirements](https://git.haxx.space/sjg/trx-rs/wiki/User-Manual#build-requirements)
in the wiki for details on each library.
> **Note:** `cmake` is required even when a system Opus library is installed.
@@ -133,10 +133,10 @@ a unified set of frontends.
| Resource | Description |
|----------|-------------|
| [User Manual](https://github.com/sgrams/trx-rs/wiki/User-Manual) | Configuration, features, and usage |
| [Architecture](https://github.com/sgrams/trx-rs/wiki/Architecture) | System design, crate layout, data flow, and internals |
| [Optimization Guidelines](https://github.com/sgrams/trx-rs/wiki/Optimization-Guidelines) | Performance guidelines for the real-time DSP pipeline |
| [Planned Features](https://github.com/sgrams/trx-rs/wiki/Planned-Features) | Roadmap and design notes |
| [User Manual](https://git.haxx.space/sjg/trx-rs/wiki/User-Manual) | Configuration, features, and usage |
| [Architecture](https://git.haxx.space/sjg/trx-rs/wiki/Architecture) | System design, crate layout, data flow, and internals |
| [Optimization Guidelines](https://git.haxx.space/sjg/trx-rs/wiki/Optimization-Guidelines) | Performance guidelines for the real-time DSP pipeline |
| [Planned Features](https://git.haxx.space/sjg/trx-rs/wiki/Planned-Features) | Roadmap and design notes |
| [Contributing](CONTRIBUTING.md) | Commit conventions, workflow, and code style |
## License
+44
View File
@@ -0,0 +1,44 @@
version = 1
# Project-owned files without an in-file SPDX header (docs, config,
# repo metadata, logos, and bespoke web assets).
[[annotations]]
path = [
".gitattributes",
".gitignore",
"CLAUDE.md",
"CONTRIBUTING.md",
"README.md",
"trx-rs.toml.example",
"docs/**",
"aidocs/**",
"src/decoders/trx-ftx/README.md",
"src/decoders/trx-wxsat/README.md",
"assets/trx-logo.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-favicon.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-logo.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/bandplan.json",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/leaflet-ais-tracksymbol.js",
]
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
SPDX-License-Identifier = "GPL-2.0-or-later"
# Vendored Leaflet 1.9.4 (https://leafletjs.com), distributed under BSD-2-Clause.
[[annotations]]
path = [
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/leaflet.js",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/leaflet.css",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/layers.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/layers-2x.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/marker-icon.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/marker-icon-2x.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/marker-shadow.png",
]
SPDX-FileCopyrightText = "2010-2023 Vladimir Agafonkin, 2010-2011 CloudMade"
SPDX-License-Identifier = "BSD-2-Clause"
# Vendored DSEG14 font (https://github.com/keshikan/DSEG), SIL OFL 1.1.
[[annotations]]
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/dseg14-classic-latin-400-normal.woff2"]
SPDX-FileCopyrightText = "2020 The DSEG Authors (https://github.com/keshikan/DSEG)"
SPDX-License-Identifier = "OFL-1.1"
+89
View File
@@ -0,0 +1,89 @@
# Work In Progress — Project Improvement Areas
Living tracker for engineering-infrastructure and hardening work identified
during a July 2026 repo scan. The architecture-level backlog
(`docs/Improvement-Areas.md`, P0P3) is closed; these items focus on the
tooling, robustness, and product concerns *around* the code.
Status legend: **Done** · **In progress** · **Not started**
| # | Tier | Area | Status |
|----|------|------|--------|
| 1 | 1 — Infrastructure | Gitea Actions CI (fmt, clippy, test, REUSE) | In progress |
| 2 | 1 — Infrastructure | Supply-chain & lint governance (cargo-deny/audit, MSRV, `[workspace.lints]`) | Not started |
| 3 | 1 — Infrastructure | Release & deployment (container image, systemd units, binary releases) | Not started |
| 4 | 2 — Robustness | Panic-resilience audit (harden ~495 unwrap/expect/panic sites) | Not started |
| 5 | 2 — Robustness | `unsafe` SIMD safety scaffolding (`# Safety` docs, scalar↔SIMD equivalence tests) | Not started |
| 6 | 2 — Robustness | DSP performance benchmarks (criterion, guard optimization gains) | Not started |
| 7 | 2 — Robustness | Test-coverage measurement & gap-filling (llvm-cov; CAT backends, server tasks) | Not started |
| 8 | 3 — Product | Frontend modularization & tooling (split `app.js` into ES modules, ESLint, JS tests) | Not started |
| 9 | 3 — Product | Runtime observability (`/health`, Prometheus metrics) | Not started |
| 10 | 3 — Product | Documentation freshness & consolidation (reconcile `docs/` with code) | Not started |
## Tier 1 — Infrastructure gaps
### 1. Gitea Actions CI
No `.gitea/` / `.github/` / Woodpecker workflows exist, despite 768 tests.
Add a workflow running `cargo fmt --check`, `cargo clippy -D warnings`,
`cargo build`/`cargo test`, and REUSE lint on push + pull_request.
System deps: `pkg-config cmake libopus-dev libasound2-dev libsoapysdr-dev`
(the `soapysdr` backend is a default feature).
Notes for the runner: assumes an `act_runner` registered with an
`ubuntu-latest` label and GitHub-action proxying enabled (used by
`actions/checkout`, `actions/cache`, `fsfe/reuse-action`). `clippy` runs
with `-D warnings`; core crates are already clean, but if the first full
`--all-features` run surfaces warnings in a less-travelled crate, fix them
(preferred) or temporarily soften that step.
### 2. Supply-chain & lint governance
No `deny.toml`, `cargo audit`, `rustfmt.toml`/`clippy.toml`, declared MSRV,
or `[workspace.lints]`. Add dependency auditing to CI, pin an MSRV
(`rust-version`), and centralize lint policy in the workspace manifest.
### 3. Release & deployment
No `Dockerfile`, systemd units, packaging, or release automation (only
`script/dummy-server.sh`). Add a container image, example systemd units for
`trx-server`/`trx-client`, and a tag-triggered static-binary release job.
## Tier 2 — Robustness & correctness
### 4. Panic-resilience audit
495 `unwrap()`/`expect()`/`panic!` sites, 11 in the hottest server files
(`audio.rs`, `rig_task.rs`). A panic there can drop a rig task or the
process. Convert hot-path panics to error propagation / graceful
degradation; reserve `expect` for documented invariants.
### 5. `unsafe` SIMD safety scaffolding
13 `unsafe` blocks (AVX2 DSP). Add `# Safety` docs stating invariants,
confirm runtime feature detection is tested, and add property tests
asserting SIMD output matches the scalar fallback across random inputs.
### 6. DSP performance benchmarks
`docs/Optimization-Guidelines.md` documents NCO/polyphase/AVX2 gains, but no
`criterion` benches guard them. Add benches for the demod/resample/FFT hot
paths so regressions surface as numbers.
### 7. Test-coverage measurement & gap-filling
67 of 146 Rust files have no test module. Protocol is well covered; backends
(CAT BCD/ASCII encoding), `listener.rs`, and `config.rs` look thin. Wire up
`cargo-llvm-cov` and target the CAT backends and server tasks first.
## Tier 3 — Product & maintainability
### 8. Frontend modularization & tooling
`app.js` is 8,760 lines and `map-core.js` 3,515, with no modules, linter, or
tests. Keeping vanilla HTML+JS (no framework), split into native ES modules
by concern, add ESLint + Prettier, and add `node:test` unit tests for pure
logic (frequency formatting, unit math, decode parsing).
### 9. Runtime observability
`tracing` is set up, but there is no `/health` endpoint or metrics
instrumentation. Add health/readiness endpoints and Prometheus-format
metrics (decode rates, reconnects, audio underruns, per-rig state).
### 10. Documentation freshness & consolidation
`docs/` (13 files) is mostly dated 2026-03-29 while code moved into July, and
mixes planning artifacts with reference docs. Reconcile against current code,
separate "plans" from "reference," and fold still-true content into the
canonical docs.
+1 -1
View File
@@ -243,7 +243,7 @@ fn parse_frame(frame: RawFrame, channel: &str) -> Option<AisMessage> {
let message_type = get_uint(&bits, 0, 6)? as u8;
let repeat = get_uint(&bits, 6, 2)? as u8;
let mmsi = get_uint(&bits, 8, 30)? as u32;
let mmsi = get_uint(&bits, 8, 30)?;
let mut msg = AisMessage {
rig_id: None,
+4 -4
View File
@@ -638,7 +638,7 @@ mod tests {
for (i, &ch) in b"N0CALL".iter().enumerate() {
addr[i] = ch << 1;
}
addr[6] = (0 << 1) | 1; // SSID=0, last=true
addr[6] = 1; // SSID=0, last=true
let decoded = decode_ax25_address(&addr, 0);
assert_eq!(decoded.call, "N0CALL");
@@ -652,7 +652,7 @@ mod tests {
for (i, &ch) in b"SP2SJG".iter().enumerate() {
addr[i] = ch << 1;
}
addr[6] = (5 << 1) | 0; // SSID=5, last=false
addr[6] = 5 << 1; // SSID=5, last=false
let decoded = decode_ax25_address(&addr, 0);
assert_eq!(decoded.call, "SP2SJG");
@@ -667,7 +667,7 @@ mod tests {
for (i, &ch) in b"W1AW ".iter().enumerate() {
addr[i] = ch << 1;
}
addr[6] = (0 << 1) | 1;
addr[6] = 1;
let decoded = decode_ax25_address(&addr, 0);
assert_eq!(decoded.call, "W1AW");
@@ -691,7 +691,7 @@ mod tests {
for &ch in src_bytes.as_bytes().iter().take(6) {
frame.push(ch << 1);
}
frame.push((0 << 1) | 1); // SSID=0, last=true
frame.push(1); // SSID=0, last=true
// Control + PID
frame.push(0x03); // UI frame
frame.push(0xF0); // No layer-3 protocol
@@ -160,8 +160,7 @@ impl CallsignHashTable {
let mut idx = start_idx;
loop {
match &self.entries[idx] {
Some(entry) => {
let entry = self.entries[idx].as_ref()?;
let stored = (entry.hash & HASH22_MASK) >> shift;
if stored == target {
return Some(entry.callsign.clone());
@@ -171,9 +170,6 @@ impl CallsignHashTable {
return None;
}
}
None => return None,
}
}
}
/// Age all entries and remove those older than `max_age`.
+12 -12
View File
@@ -42,8 +42,8 @@ pub(crate) fn ldpc_check(codeword: &[u8; FTX_LDPC_N]) -> i32 {
for m in 0..FTX_LDPC_M {
let mut x: u8 = 0;
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
for i in 0..num_rows {
x ^= codeword[FTX_LDPC_NM[m][i] as usize - 1];
for &nm in FTX_LDPC_NM[m].iter().take(num_rows) {
x ^= codeword[nm as usize - 1];
}
if x != 0 {
errors += 1;
@@ -81,11 +81,11 @@ pub fn ldpc_decode(
for j in 0..FTX_LDPC_M {
let num_rows = FTX_LDPC_NUM_ROWS[j] as usize;
let m_row = j * FTX_LDPC_N;
for ii1 in 0..num_rows {
let i1 = FTX_LDPC_NM[j][ii1] as usize - 1;
for &nm1 in FTX_LDPC_NM[j].iter().take(num_rows) {
let i1 = nm1 as usize - 1;
let mut a = 1.0f32;
for ii2 in 0..num_rows {
let i2 = FTX_LDPC_NM[j][ii2] as usize - 1;
for &nm2 in FTX_LDPC_NM[j].iter().take(num_rows) {
let i2 = nm2 as usize - 1;
if i2 != i1 {
a *= fast_tanh(-m_matrix[m_row + i2] / 2.0f32);
}
@@ -97,8 +97,8 @@ pub fn ldpc_decode(
// Hard decisions
for i in 0..FTX_LDPC_N {
let mut l = codeword[i];
for j in 0..3 {
l += e_matrix[(FTX_LDPC_MN[i][j] as usize - 1) * FTX_LDPC_N + i];
for &mn in FTX_LDPC_MN[i].iter().take(3) {
l += e_matrix[(mn as usize - 1) * FTX_LDPC_N + i];
}
plain[i] = if l > 0.0 { 1 } else { 0 };
}
@@ -113,12 +113,12 @@ pub fn ldpc_decode(
// Update m[][] from e[][]
for i in 0..FTX_LDPC_N {
for ji1 in 0..3 {
let j1 = FTX_LDPC_MN[i][ji1] as usize - 1;
for (ji1, &mn1) in FTX_LDPC_MN[i].iter().enumerate().take(3) {
let j1 = mn1 as usize - 1;
let mut l = codeword[i];
for ji2 in 0..3 {
for (ji2, &mn2) in FTX_LDPC_MN[i].iter().enumerate().take(3) {
if ji1 != ji2 {
let j2 = FTX_LDPC_MN[i][ji2] as usize - 1;
let j2 = mn2 as usize - 1;
l += e_matrix[j2 * FTX_LDPC_N + i];
}
}
+2 -6
View File
@@ -64,11 +64,9 @@ pub fn charn(mut c: i32, table: CharTable) -> char {
return EXTRAS[c as usize];
}
}
CharTable::AlphanumSpaceSlash => {
if c == 0 {
CharTable::AlphanumSpaceSlash if c == 0 => {
return '/';
}
}
_ => {}
}
@@ -116,11 +114,9 @@ pub fn nchar(c: char, table: CharTable) -> Option<i32> {
'?' => return Some(n + 4),
_ => {}
},
CharTable::AlphanumSpaceSlash => {
if c == '/' {
CharTable::AlphanumSpaceSlash if c == '/' => {
return Some(n);
}
}
_ => {}
}
+12 -27
View File
@@ -632,33 +632,18 @@ impl Candidate {
}
let segment = usize::from((block_b & 0x0003) as u8);
let di = ((block_b >> 2) & 0x1) != 0;
match segment {
0 => {
if self.state.dynamic_pty != Some(di) {
self.state.dynamic_pty = Some(di);
let di_flag = Some(di);
let slot = match segment {
0 => &mut self.state.dynamic_pty,
1 => &mut self.state.compressed,
2 => &mut self.state.artificial_head,
3 => &mut self.state.stereo,
_ => unreachable!("segment is masked to two bits"),
};
if *slot != di_flag {
*slot = di_flag;
changed = true;
}
}
1 => {
if self.state.compressed != Some(di) {
self.state.compressed = Some(di);
changed = true;
}
}
2 => {
if self.state.artificial_head != Some(di) {
self.state.artificial_head = Some(di);
changed = true;
}
}
3 => {
if self.state.stereo != Some(di) {
self.state.stereo = Some(di);
changed = true;
}
}
_ => {}
}
let [b0, b1] = block_d.to_be_bytes();
self.ps_bytes[segment * 2] = sanitize_text_byte(b0);
self.ps_bytes[segment * 2 + 1] = sanitize_text_byte(b1);
@@ -1458,9 +1443,9 @@ mod tests {
}
// BPSK modulate onto the 57 kHz subcarrier.
for t in 0..n {
for (t, sample) in shaped.iter_mut().enumerate().take(n) {
let phase = TAU * RDS_SUBCARRIER_HZ * t as f32 / sample_rate;
shaped[t] *= phase.cos();
*sample *= phase.cos();
}
shaped
}
+1 -3
View File
@@ -134,9 +134,7 @@ mod tests {
.flat_map(|&b| (0..8).rev().map(move |i| (b >> i) & 1))
.collect();
// Append wrong CRC
for _ in 0..16 {
bits.push(0);
}
bits.resize(bits.len() + 16, 0);
assert!(!check_crc16(&bits));
}
+2 -2
View File
@@ -346,8 +346,8 @@ mod tests {
write_bits(&mut bits, 12, 32, 123456); // source_id
write_bits(&mut bits, 44, 11, 20); // data_count = 20
// Fill some payload
for i in 55..75 {
bits[i] = (i % 2) as u8;
for (i, bit) in bits.iter_mut().enumerate().take(75).skip(55) {
*bit = (i % 2) as u8;
}
append_crc(&mut bits);
+2 -2
View File
@@ -374,8 +374,8 @@ mod tests {
let (y, m, d, h, mi, _) = unix_to_utc(1775055000);
assert_eq!(y, 2026);
// Just verify reasonable values without asserting exact date.
assert!(m >= 1 && m <= 12);
assert!(d >= 1 && d <= 31);
assert!((1..=12).contains(&m));
assert!((1..=31).contains(&d));
assert!(h < 24);
assert!(mi < 60);
}
+2 -4
View File
@@ -161,10 +161,8 @@ mod tests {
for line_idx in 0..20 {
let mut line = vec![1.0f32; spl];
for j in pulse_start..pulse_start + pw {
if j < spl {
line[j] = 0.0;
}
for slot in line.iter_mut().skip(pulse_start).take(pw) {
*slot = 0.0;
}
let result = det.process(&line);
if let Some(offset) = result {
+3 -3
View File
@@ -483,7 +483,7 @@ mod tests {
let c4 = idx27(b'T');
let c5 = idx27(b' ');
let n1 = ((c0 * 36 + c1) * 10 + c2) * 27u32.pow(3) + c3 * 27u32.pow(2) + c4 * 27 + c5;
let m1 = (179 - 10 * 5 - 2) * 180 + 10 * 13 + 0; // FN20
let m1 = (179 - 10 * 5 - 2) * 180 + 10 * 13; // FN20 (final term is 0)
let power_code = 37u32;
let mut input_bits = [0u8; NBITS];
@@ -530,8 +530,8 @@ mod tests {
fn interleave_deinterleave_roundtrip() {
// Create a sequence of distinguishable values
let mut original = [0u8; NSYMS];
for i in 0..NSYMS {
original[i] = (i % 256) as u8;
for (i, slot) in original.iter_mut().enumerate() {
*slot = (i % 256) as u8;
}
let interleaved = interleave(&original);
+53 -53
View File
@@ -356,59 +356,6 @@ async fn run_single_rig_audio_client(
}
}
#[cfg(test)]
mod tests {
use super::{resolve_audio_addr, AudioConnectConfig};
use std::collections::HashMap;
#[test]
fn resolve_audio_addr_prefers_fixed_url() {
let mut rig_connect = HashMap::new();
rig_connect.insert(
"home-hf".to_string(),
AudioConnectConfig::fixed("audio.example.com:4700".to_string()),
);
let addr = resolve_audio_addr(
"home-hf",
Some(4531),
&rig_connect,
&AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
);
assert_eq!(addr, "audio.example.com:4700");
}
#[test]
fn resolve_audio_addr_uses_advertised_port_with_remote_host() {
let mut rig_connect = HashMap::new();
rig_connect.insert(
"home-hf".to_string(),
AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
);
let addr = resolve_audio_addr(
"home-hf",
Some(4600),
&rig_connect,
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
);
assert_eq!(addr, "control.example.com:4600");
}
#[test]
fn resolve_audio_addr_falls_back_to_default_port() {
let rig_connect = HashMap::new();
let addr = resolve_audio_addr(
"home-hf",
None,
&rig_connect,
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
);
assert_eq!(addr, "fallback.example.com:4531");
}
}
/// Handle a single TCP connection for one rig. Similar to `handle_audio_connection`
/// but publishes to per-rig channels directly and mirrors to global when selected.
#[allow(clippy::too_many_arguments)]
@@ -767,3 +714,56 @@ async fn handle_single_rig_connection(
Ok(())
}
#[cfg(test)]
mod tests {
use super::{resolve_audio_addr, AudioConnectConfig};
use std::collections::HashMap;
#[test]
fn resolve_audio_addr_prefers_fixed_url() {
let mut rig_connect = HashMap::new();
rig_connect.insert(
"home-hf".to_string(),
AudioConnectConfig::fixed("audio.example.com:4700".to_string()),
);
let addr = resolve_audio_addr(
"home-hf",
Some(4531),
&rig_connect,
&AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
);
assert_eq!(addr, "audio.example.com:4700");
}
#[test]
fn resolve_audio_addr_uses_advertised_port_with_remote_host() {
let mut rig_connect = HashMap::new();
rig_connect.insert(
"home-hf".to_string(),
AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
);
let addr = resolve_audio_addr(
"home-hf",
Some(4600),
&rig_connect,
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
);
assert_eq!(addr, "control.example.com:4600");
}
#[test]
fn resolve_audio_addr_falls_back_to_default_port() {
let rig_connect = HashMap::new();
let addr = resolve_audio_addr(
"home-hf",
None,
&rig_connect,
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
);
assert_eq!(addr, "fallback.example.com:4531");
}
}
+20 -12
View File
@@ -1110,8 +1110,8 @@ url = "remote.example.com:4530"
#[test]
fn test_validate_rejects_duplicate_remote_names() {
let mut config = ClientConfig::default();
config.remotes = vec![
let config = ClientConfig {
remotes: vec![
RemoteEntry {
name: "dup".to_string(),
url: "a:4530".to_string(),
@@ -1126,20 +1126,24 @@ url = "remote.example.com:4530"
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
},
];
],
..Default::default()
};
assert!(config.validate().unwrap_err().contains("duplicate name"));
}
#[test]
fn test_validate_rejects_empty_remote_name() {
let mut config = ClientConfig::default();
config.remotes = vec![RemoteEntry {
let config = ClientConfig {
remotes: vec![RemoteEntry {
name: "".to_string(),
url: "a:4530".to_string(),
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
}];
}],
..Default::default()
};
assert!(config
.validate()
.unwrap_err()
@@ -1148,14 +1152,16 @@ url = "remote.example.com:4530"
#[test]
fn test_validate_rejects_empty_remote_url() {
let mut config = ClientConfig::default();
config.remotes = vec![RemoteEntry {
let config = ClientConfig {
remotes: vec![RemoteEntry {
name: "hf".to_string(),
url: " ".to_string(),
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
}];
}],
..Default::default()
};
assert!(config
.validate()
.unwrap_err()
@@ -1164,14 +1170,16 @@ url = "remote.example.com:4530"
#[test]
fn test_validate_rejects_zero_remote_poll_interval() {
let mut config = ClientConfig::default();
config.remotes = vec![RemoteEntry {
let config = ClientConfig {
remotes: vec![RemoteEntry {
name: "hf".to_string(),
url: "a:4530".to_string(),
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 0,
}];
}],
..Default::default()
};
assert!(config
.validate()
.unwrap_err()
+1 -1
View File
@@ -1678,7 +1678,7 @@ mod tests {
#[test]
fn global_target_for_snapshot_skips_other_server_selection() {
let snapshot = sample_snapshot();
let rigs = vec![RigEntry {
let rigs = [RigEntry {
rig_id: "hf".to_string(),
display_name: Some("Gdansk HF".to_string()),
state: snapshot,
@@ -104,24 +104,24 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
<!-- Auth gate (hidden by default, shown if auth is required) -->
<div id="auth-gate" style="display:none; max-width: 30rem; margin: 0 auto 0.9rem; padding: 1.25rem 0 1.5rem; text-align: center;">
<div style="margin-bottom: 1.5rem;">
<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;">Access Required</div>
<div style="color: var(--text-muted);">Enter passphrase to continue</div>
<div id="auth-gate" class="auth-gate" style="display:none;">
<div class="auth-gate-head">
<div class="auth-gate-title">Access Required</div>
<div class="auth-gate-sub">Enter passphrase to continue</div>
</div>
<form id="auth-form" style="margin: 1rem 0 0.35rem;">
<input type="password" id="auth-passphrase" placeholder="Passphrase" autocomplete="off" style="width: 100%; padding: 0.65rem 0.75rem; margin-bottom: 1rem; border: 1px solid var(--border-light); border-radius: 0.45rem; background: var(--input-bg); color: var(--text); font-size: 1rem; box-sizing: border-box;" />
<button type="submit" style="width: 100%; padding: 0.65rem 0.75rem; background: var(--accent-green); color: #fff; border: none; border-radius: 0.45rem; font-weight: 700; cursor: pointer; font-size: 1rem; box-sizing: border-box;">Login</button>
<form id="auth-form" class="auth-form">
<input type="password" id="auth-passphrase" class="auth-input" placeholder="Passphrase" autocomplete="off" />
<button type="submit" class="auth-submit">Login</button>
</form>
<button id="auth-guest-btn" type="button" style="width: 100%; padding: 0.65rem 0.75rem; background: var(--btn-bg); color: var(--text); border: 1px solid var(--border-light); border-radius: 0.45rem; font-weight: 600; cursor: pointer; font-size: 1rem; box-sizing: border-box; margin-top: 0; display: none;">Continue as Guest</button>
<div id="auth-error" style="color: #ff6b6b; font-size: 0.9rem; margin-top: 1rem; display: none;"></div>
<div id="auth-role" style="margin-top: 1rem; color: var(--text-muted); font-size: 0.85rem; display: none;"></div>
<button id="auth-guest-btn" type="button" class="auth-guest" style="display: none;">Continue as Guest</button>
<div id="auth-error" class="auth-error" style="display: none;"></div>
<div id="auth-role" class="auth-role" style="display: none;"></div>
</div>
<div id="tab-main" class="tab-panel">
<div id="server-lost-banner" aria-live="assertive"><span class="banner-dot"></span>trx-server connection lost — waiting for reconnect</div>
<div id="loading" role="status" aria-live="polite" style="text-align:center; padding:2rem 0;">
<div id="loading-title" style="margin-bottom:0.4rem; font-size:1.1rem; font-weight:600;">Initializing (rig)…</div>
<div id="loading-sub" style="color:#9aa4b5;"></div>
<div id="loading-sub" style="color:var(--text-muted);"></div>
</div>
<div id="content" style="display:none;">
<div class="signal-visual-block">
@@ -1581,7 +1581,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
<div class="footer">
<div class="copyright">
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · <span class="gh-link-wrap"><a class="gh-link" href="https://github.com/sgrams/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs on GitHub"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0.2a8 8 0 0 0-2.53 15.59c0.4 0.07 0.55-0.17 0.55-0.39l-0.01-1.37c-2.23 0.49-2.7-0.95-2.7-0.95-0.36-0.91-0.89-1.15-0.89-1.15-0.73-0.49 0.06-0.48 0.06-0.48 0.8 0.06 1.22 0.82 1.22 0.82 0.72 1.22 1.88 0.87 2.34 0.67 0.07-0.51 0.28-0.86 0.5-1.06-1.78-0.2-3.64-0.89-3.64-3.95 0-0.87 0.31-1.58 0.81-2.14-0.08-0.2-0.35-1.02 0.08-2.12 0 0 0.67-0.21 2.2 0.82a7.56 7.56 0 0 1 4.01 0c1.53-1.03 2.2-0.82 2.2-0.82 0.43 1.1 0.16 1.92 0.08 2.12 0.51 0.56 0.81 1.27 0.81 2.14 0 3.07-1.87 3.75-3.66 3.95 0.29 0.25 0.54 0.73 0.54 1.48l-0.01 2.2c0 0.22 0.14 0.47 0.55 0.39A8 8 0 0 0 8 0.2Z"></path></svg><span>trx-rs on GitHub</span></a></span><span id="copyright-year"></span>
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · <span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span><span id="copyright-year"></span>
</div>
<div class="hint" id="power-hint" aria-live="polite">Connecting…</div>
</div>
@@ -47,6 +47,50 @@ SPDX-License-Identifier: GPL-2.0-or-later
--border-hover: color-mix(in srgb, var(--border-light) 80%, var(--text) 20%);
--accent-green-hover: color-mix(in srgb, var(--accent-green) 85%, #fff 15%);
--accent-green-active: color-mix(in srgb, var(--accent-green) 75%, #fff 25%);
/* Accent tuned for text/links: mixes toward the theme's text colour,
which is always the higher-contrast direction (lighter on dark
themes, darker on light). Use for accent-coloured text; keep
--accent-green for fills, indicators and meters. */
--accent-text: color-mix(in srgb, var(--accent-green) 82%, var(--text) 18%);
/* ── Design tokens ──────────────────────────────────────────────────
Derived tokens use color-mix over themed variables, so they
re-resolve per active [data-style]/[data-theme] automatically. */
/* Elevated surface, one step lighter than --card-bg. Consumed by
.controls-tray and other raised panels. */
--surface: color-mix(in srgb, var(--card-bg) 90%, var(--text) 7%);
/* Type scale */
--fs-xs: 0.75rem;
--fs-sm: 0.85rem;
--fs-base: 1rem;
--fs-md: 1.125rem;
--fs-lg: 1.25rem;
--fs-xl: 1.5rem;
--fs-2xl: 2rem;
/* Spacing scale (4px grid) */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
/* Corner radii */
--radius-sm: 6px;
--radius-md: 0.5rem;
--radius-lg: 1rem;
--radius-pill: 999px;
/* Motion */
--dur-fast: 100ms;
--dur-base: 180ms;
--dur-slow: 300ms;
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
}
[data-theme="light"] {
@@ -89,6 +133,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: var(--fs-base);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: 0;
min-height: 100vh;
min-height: 100dvh;
@@ -120,6 +168,57 @@ body {
);
}
}
/* ── Auth gate ────────────────────────────────────────────────────────
Class-based; #display toggling stays inline (driven by app.js). */
.auth-gate {
max-width: 30rem;
margin: 0 auto 0.9rem;
padding: var(--space-5) 0 var(--space-6);
text-align: center;
}
.auth-gate-head { margin-bottom: var(--space-6); }
.auth-gate-title { font-size: var(--fs-md); font-weight: 600; margin-bottom: var(--space-2); }
.auth-gate-sub { color: var(--text-muted); }
.auth-form { margin: var(--space-4) 0 0.35rem; }
.auth-input {
width: 100%;
padding: 0.65rem 0.75rem;
margin-bottom: var(--space-4);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: var(--input-bg);
color: var(--text);
font-size: var(--fs-base);
box-sizing: border-box;
}
.auth-submit,
.auth-guest {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: var(--radius-md);
font-size: var(--fs-base);
cursor: pointer;
box-sizing: border-box;
transition: background-color var(--dur-fast) var(--ease-standard);
}
.auth-submit {
background: var(--accent-green);
color: #fff;
border: none;
font-weight: 700;
}
.auth-submit:hover:not(:disabled) { background: var(--accent-green-hover); }
.auth-guest {
background: var(--btn-bg);
color: var(--text);
border: 1px solid var(--border-light);
font-weight: 600;
margin-top: var(--space-4);
}
.auth-guest:hover:not(:disabled) { background: var(--btn-hover-bg); }
.auth-error { color: var(--accent-red); font-size: var(--fs-sm); margin-top: var(--space-4); }
.auth-role { margin-top: var(--space-4); color: var(--text-muted); font-size: var(--fs-sm); }
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
#tab-main .label > span {
display: inline-block;
@@ -220,7 +319,7 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
align-items: stretch;
gap: 0.22rem;
color: var(--text-muted);
font-size: 0.72rem;
font-size: 0.75rem;
line-height: 1.1;
white-space: nowrap;
}
@@ -408,7 +507,7 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
border-radius: 0;
height: 100%;
padding: 0 0.65rem;
font-size: 0.92rem;
font-size: 0.9rem;
background: var(--input-bg);
color: var(--text-muted);
cursor: pointer;
@@ -486,7 +585,7 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
}
.scheduler-cycle-status {
color: var(--text-muted);
font-size: 0.74rem;
font-size: 0.75rem;
text-align: left;
opacity: 0.88;
}
@@ -1357,7 +1456,7 @@ small { color: var(--text-muted); }
}
.header-logo { height: 4.6em; width: auto; flex-shrink: 0; filter: drop-shadow(0 4px 12px rgba(0,0,0,0.35)); }
.subtitle { color: var(--text-muted); font-size: 0.95rem; }
.subtitle a { color: var(--accent-green); text-decoration: none; }
.subtitle a { color: var(--accent-text); text-decoration: none; }
.subtitle a:hover { text-decoration: underline; }
.band-tag { display: inline-block; padding: 2px 6px; border-radius: 6px; background: var(--btn-bg); color: var(--text); font-size: 0.82rem; border: 1px solid var(--border-light); margin-left: 6px; }
.signal { display: flex; gap: 0.6rem; align-items: center; }
@@ -1433,7 +1532,7 @@ small { color: var(--text-muted); }
font-size: 0.95rem;
height: auto;
}
.tab.active { border-bottom-color: var(--accent-green); color: var(--accent-green); font-weight: 600; }
.tab.active { border-bottom-color: var(--accent-green); color: var(--accent-text); font-weight: 600; }
.tab:hover:not(.active) { color: var(--text); }
/* Tab icons — hidden on desktop, shown on mobile bottom nav */
.tab-icon {
@@ -1458,7 +1557,7 @@ small { color: var(--text-muted); }
.footer { display: flex; justify-content: space-between; align-items: baseline; margin-top: auto; padding-top: 1rem; flex-shrink: 0; }
.full-row { grid-column: 1 / -1; }
.copyright { color: var(--text-muted); font-size: 0.75rem; opacity: 0.7; }
.copyright a { color: var(--accent-green); text-decoration: none; }
.copyright a { color: var(--accent-text); text-decoration: none; }
.copyright a:hover { text-decoration: underline; }
.gh-link-wrap {
display: inline-flex;
@@ -1499,7 +1598,7 @@ small { color: var(--text-muted); }
white-space: nowrap;
}
.vol-pct {
font-size: 0.72rem;
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1;
}
@@ -1602,7 +1701,7 @@ small { color: var(--text-muted); }
}
.shortcut-table td {
padding: 0.32rem 0.4rem;
font-size: 0.88rem;
font-size: 0.85rem;
color: var(--text);
border-bottom: 1px solid color-mix(in srgb, var(--border-light) 40%, transparent);
}
@@ -1632,7 +1731,7 @@ small { color: var(--text-muted); }
.shortcut-overlay-hint {
margin-top: 0.7rem;
text-align: center;
font-size: 0.76rem;
font-size: 0.75rem;
color: var(--text-muted);
}
@@ -1706,7 +1805,7 @@ small { color: var(--text-muted); }
text-align: center;
}
.decode-history-overlay-title {
font-size: 0.98rem;
font-size: 1rem;
font-weight: 800;
color: var(--text-heading);
}
@@ -1756,7 +1855,7 @@ small { color: var(--text-muted); }
gap: 0.75rem;
}
.map-qso-summary-title {
font-size: 0.88rem;
font-size: 0.85rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -1841,7 +1940,7 @@ button.map-qso-card:focus-visible {
border-radius: 999px;
background: color-mix(in srgb, var(--accent-green) 16%, transparent);
color: var(--accent-green);
font-size: 0.76rem;
font-size: 0.75rem;
font-weight: 800;
}
.map-qso-card-distance {
@@ -1858,7 +1957,7 @@ button.map-qso-card:focus-visible {
min-width: 0;
}
.map-qso-card-pair {
font-size: 0.92rem;
font-size: 0.9rem;
font-weight: 700;
color: var(--text);
white-space: nowrap;
@@ -1948,7 +2047,7 @@ button.map-qso-card:focus-visible {
border: 1px solid color-mix(in srgb, var(--border-light) 74%, transparent);
background: color-mix(in srgb, var(--card-bg) 82%, transparent);
color: var(--text);
font-size: 0.76rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
@@ -1967,7 +2066,7 @@ button.map-qso-card:focus-visible {
border: 1px solid color-mix(in srgb, var(--border-light) 74%, transparent);
background: color-mix(in srgb, var(--card-bg) 82%, transparent);
color: var(--text);
font-size: 0.76rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
@@ -2000,7 +2099,7 @@ button.map-qso-card:focus-visible {
display: none;
}
.map-band-legend-title {
font-size: 0.72rem;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
@@ -2027,7 +2126,7 @@ button.map-qso-card:focus-visible {
}
.map-band-legend-text {
min-width: 0;
font-size: 0.76rem;
font-size: 0.75rem;
font-weight: 700;
color: var(--text);
white-space: nowrap;
@@ -2084,7 +2183,7 @@ body.map-fake-fullscreen-active {
}
.aprs-summary-label {
color: var(--text-muted);
font-size: 0.72rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
@@ -2142,7 +2241,7 @@ body.map-fake-fullscreen-active {
}
.ais-summary-label {
color: var(--text-muted);
font-size: 0.72rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
@@ -2260,7 +2359,7 @@ body.map-fake-fullscreen-active {
}
.aprs-meta-text {
color: var(--text-muted);
font-size: 0.76rem;
font-size: 0.75rem;
}
.aprs-inline-btn {
display: inline-flex;
@@ -2273,7 +2372,7 @@ body.map-fake-fullscreen-active {
background: var(--filter-bg);
color: var(--filter-fg);
font: inherit;
font-size: 0.74rem;
font-size: 0.75rem;
cursor: pointer;
}
.aprs-inline-btn:hover {
@@ -2286,7 +2385,7 @@ body.map-fake-fullscreen-active {
.aprs-details summary {
cursor: pointer;
color: var(--accent-green);
font-size: 0.76rem;
font-size: 0.75rem;
user-select: none;
}
.aprs-details[open] summary {
@@ -2364,7 +2463,7 @@ body.map-fake-fullscreen-active {
}
.ais-meta-text {
color: var(--text-muted);
font-size: 0.76rem;
font-size: 0.75rem;
}
.ais-pos-link {
color: var(--accent-red);
@@ -2406,7 +2505,7 @@ body.map-fake-fullscreen-active {
}
.vdes-raw {
color: var(--text-muted);
font-size: 0.76rem;
font-size: 0.75rem;
word-break: break-all;
}
.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; }
@@ -2465,18 +2564,18 @@ body.map-fake-fullscreen-active {
}
.decode-locator-tip-meta {
margin-top: 0.12rem;
font-size: 0.74rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.decode-locator-tip-rx {
margin-top: 0.12rem;
font-size: 0.72rem;
font-size: 0.75rem;
color: var(--accent-yellow);
opacity: 0.85;
}
.decode-locator-tip-note {
margin-top: 0.2rem;
font-size: 0.76rem;
font-size: 0.75rem;
line-height: 1.3;
color: var(--text);
word-break: break-word;
@@ -2503,7 +2602,7 @@ body.map-fake-fullscreen-active {
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
color: color-mix(in srgb, var(--accent-green) 72%, var(--accent-yellow));
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22);
font-size: 0.72rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.03em;
white-space: nowrap;
@@ -2576,7 +2675,7 @@ body.map-fake-fullscreen-active {
flex: 0 0 auto;
min-width: 4.4rem;
padding-top: 0.35rem;
font-size: 0.72rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
@@ -2604,7 +2703,7 @@ body.map-fake-fullscreen-active {
border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent);
background: color-mix(in srgb, var(--input-bg) 92%, transparent);
color: var(--text-muted);
font-size: 0.76rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
@@ -2621,7 +2720,7 @@ body.map-fake-fullscreen-active {
color: var(--accent-green);
}
.map-locator-empty {
font-size: 0.74rem;
font-size: 0.75rem;
color: var(--text-muted);
opacity: 0.8;
align-self: flex-start;
@@ -2655,7 +2754,7 @@ body.map-fake-fullscreen-active {
background: color-mix(in srgb, var(--input-bg) 96%, transparent);
}
.map-locator-chip-text {
font-size: 0.77rem;
font-size: 0.78rem;
font-weight: 600;
}
.map-search-input {
@@ -2712,7 +2811,7 @@ body.map-fake-fullscreen-active {
.rds-raw-header { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; margin-bottom: 0.3rem; }
.rds-raw-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 0.3rem; }
.rds-raw-header .rds-raw-label { margin-bottom: 0; }
#rds-raw-copy-btn { height: 1.65rem; padding: 0 0.55rem; font-size: 0.72rem; }
#rds-raw-copy-btn { height: 1.65rem; padding: 0 0.55rem; font-size: 0.75rem; }
.rds-raw { background: var(--input-bg); border: 1px solid var(--border-light); border-radius: 6px; padding: 0.5rem 0.6rem; font-size: 0.8rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; color: var(--text); white-space: pre; overflow-x: auto; margin: 0; }
.cw-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
@@ -2733,7 +2832,9 @@ body.map-fake-fullscreen-active {
.cw-config input[type="number"][readonly] { opacity: 0.6; }
.cw-config input[type="number"]:disabled { opacity: 0.58; }
button:focus-visible, input:focus-visible, select:focus-visible {
button:focus-visible, input:focus-visible, select:focus-visible,
textarea:focus-visible, a:focus-visible,
canvas:focus-visible, [tabindex]:focus-visible {
outline: 2px solid var(--accent-green);
outline-offset: 2px;
}
@@ -2844,7 +2945,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
}
/* Collapse verbose subtitles — the About tab has this info */
.tab-bar .subtitle { display: none; }
.tab-bar .title { font-size: 0.92rem; }
.tab-bar .title { font-size: 0.9rem; }
.tab-bar-nav {
position: fixed;
left: calc(0.6rem + env(safe-area-inset-left));
@@ -3506,7 +3607,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
will-change: transform;
cursor: pointer;
font-weight: 600;
font-size: 0.66rem;
font-size: 0.65rem;
background: var(--bm-cat-bg, var(--accent-yellow));
color: var(--bm-cat-fg, #1a202c);
border: 1px solid var(--bm-cat-bg, var(--accent-yellow));
@@ -3598,7 +3699,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
}
.spectrum-bookmark-chip-side .spectrum-bookmark-name {
width: 100%;
font-size: 0.64rem;
font-size: 0.65rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
@@ -3662,7 +3763,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
height: 1.5rem;
min-height: 0;
padding: 0 8px;
font-size: 0.73rem;
font-size: 0.75rem;
}
#spectrum-level-row {
display: flex;
@@ -3709,7 +3810,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
height: 1.5rem;
min-height: 0;
padding: 0 8px;
font-size: 0.73rem;
font-size: 0.75rem;
}
#spectrum-gamma-label {
display: flex;
@@ -3724,7 +3825,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
cursor: pointer;
}
#spectrum-gamma-value {
font-size: 0.72rem;
font-size: 0.75rem;
min-width: 1.6rem;
text-align: right;
}
@@ -3882,7 +3983,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.bm-label input,
.bm-label select {
font-size: 0.88rem;
font-size: 0.85rem;
padding: 0.35rem 0.5rem;
}
@@ -3938,7 +4039,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
align-items: center;
gap: 0.35rem;
color: var(--text);
font-size: 0.84rem;
font-size: 0.85rem;
}
.bm-decoder-check input {
@@ -4048,7 +4149,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.bm-page-summary,
.bm-page-indicator {
color: var(--text-muted);
font-size: 0.88rem;
font-size: 0.85rem;
}
.bm-page-controls {
display: flex;
@@ -4266,7 +4367,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
color: var(--text);
border-radius: var(--radius);
padding: 0.55rem 1rem;
font-size: 0.88rem;
font-size: 0.85rem;
}
#sch-sat-form-cancel:hover {
background: var(--border);
@@ -4478,7 +4579,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.sch-log-action {
font-weight: 600;
text-transform: uppercase;
font-size: 0.72rem;
font-size: 0.75rem;
}
.sch-log-bm {
color: var(--accent);
@@ -4687,7 +4788,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
}
.bgd-checklist-meta {
margin-left: 0;
font-size: 0.72rem;
font-size: 0.75rem;
}
}
/* ── SAT panel ──────────────────────────────────────────────────────── */
@@ -4756,7 +4857,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
gap: 0.25rem;
}
.stats-control-label {
font-size: 0.72rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
@@ -4796,7 +4897,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
line-height: 1.1;
}
.stats-counter-label {
font-size: 0.74rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
@@ -4819,7 +4920,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
gap: 0.18rem;
}
.stats-section-title {
font-size: 0.88rem;
font-size: 0.85rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -4884,7 +4985,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
#tab-statistics { padding: 0.75rem; }
.stats-counters { grid-template-columns: repeat(2, 1fr); }
.stats-counter-value { font-size: 1.3rem; }
.stats-bar-label { min-width: 4rem; font-size: 0.72rem; }
.stats-bar-label { min-width: 4rem; font-size: 0.75rem; }
.stats-section { padding: 0.8rem 0.85rem 0.9rem; }
}
@@ -4945,3 +5046,18 @@ button:focus-visible, input:focus-visible, select:focus-visible {
-webkit-backdrop-filter: blur(12px);
}
}
/* ── Panel-switch fade ────────────────────────────────────────────────
Panels are shown by clearing inline display (app.js), so this fires on
every tab/sub-tab change. Opacity-only to avoid transforming canvases
and the Leaflet map. Gated so reduced-motion users get instant switches. */
@media (prefers-reduced-motion: no-preference) {
.tab-panel,
.sub-tab-panel {
animation: trx-fade-in var(--dur-base) var(--ease-out);
}
}
@keyframes trx-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@@ -1596,7 +1596,7 @@ mod tests {
let t = i as f32 / fs;
adj_phase += adj_mod_index * adj_composite[i];
let adj = Complex::from_polar(0.5, adj_phase + TAU * adj_freq_offset * t);
*s = *s + adj;
*s += adj;
}
let mut decoder = WfmStereoDecoder::new(
@@ -1673,7 +1673,7 @@ mod tests {
// Mix at 70 % of the main signal's amplitude — strong enough to
// overcome the FM capture effect and visibly degrade pilot coherence.
for (s, intf) in iq.iter_mut().zip(intf_iq.iter()) {
*s = *s + intf * 0.7;
*s += intf * 0.7;
}
let mut decoder = WfmStereoDecoder::new(