Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b17b4ac1d | ||
|
|
977f7b709e | ||
|
|
a2838b06a2 | ||
|
|
c6cd661676 | ||
|
|
bf08c7ebc0 | ||
|
|
c7470acc1c | ||
|
|
bcde03de38 | ||
|
|
63d46562c7 | ||
|
|
e575b3b365 | ||
|
|
2121817c04 | ||
|
|
d0bd38d7df | ||
|
|
bf9617e24d | ||
|
|
3c235fce5e |
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
@@ -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`, P0–P3) 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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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,8 +691,8 @@ 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
|
||||
// Control + PID
|
||||
frame.push(1); // SSID=0, last=true
|
||||
// Control + PID
|
||||
frame.push(0x03); // UI frame
|
||||
frame.push(0xF0); // No layer-3 protocol
|
||||
// Info field
|
||||
|
||||
@@ -160,18 +160,14 @@ impl CallsignHashTable {
|
||||
let mut idx = start_idx;
|
||||
|
||||
loop {
|
||||
match &self.entries[idx] {
|
||||
Some(entry) => {
|
||||
let stored = (entry.hash & HASH22_MASK) >> shift;
|
||||
if stored == target {
|
||||
return Some(entry.callsign.clone());
|
||||
}
|
||||
idx = (idx + 1) % CALLSIGN_HASHTABLE_SIZE;
|
||||
if idx == start_idx {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None => return None,
|
||||
let entry = self.entries[idx].as_ref()?;
|
||||
let stored = (entry.hash & HASH22_MASK) >> shift;
|
||||
if stored == target {
|
||||
return Some(entry.callsign.clone());
|
||||
}
|
||||
idx = (idx + 1) % CALLSIGN_HASHTABLE_SIZE;
|
||||
if idx == start_idx {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +64,8 @@ pub fn charn(mut c: i32, table: CharTable) -> char {
|
||||
return EXTRAS[c as usize];
|
||||
}
|
||||
}
|
||||
CharTable::AlphanumSpaceSlash => {
|
||||
if c == 0 {
|
||||
return '/';
|
||||
}
|
||||
CharTable::AlphanumSpaceSlash if c == 0 => {
|
||||
return '/';
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -116,10 +114,8 @@ pub fn nchar(c: char, table: CharTable) -> Option<i32> {
|
||||
'?' => return Some(n + 4),
|
||||
_ => {}
|
||||
},
|
||||
CharTable::AlphanumSpaceSlash => {
|
||||
if c == '/' {
|
||||
return Some(n);
|
||||
}
|
||||
CharTable::AlphanumSpaceSlash if c == '/' => {
|
||||
return Some(n);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -632,32 +632,17 @@ 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);
|
||||
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 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;
|
||||
}
|
||||
let [b0, b1] = block_d.to_be_bytes();
|
||||
self.ps_bytes[segment * 2] = sanitize_text_byte(b0);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1110,36 +1110,40 @@ url = "remote.example.com:4530"
|
||||
|
||||
#[test]
|
||||
fn test_validate_rejects_duplicate_remote_names() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.remotes = vec![
|
||||
RemoteEntry {
|
||||
name: "dup".to_string(),
|
||||
url: "a:4530".to_string(),
|
||||
rig_id: None,
|
||||
auth: RemoteAuthConfig::default(),
|
||||
poll_interval_ms: 750,
|
||||
},
|
||||
RemoteEntry {
|
||||
name: "dup".to_string(),
|
||||
url: "b:4530".to_string(),
|
||||
rig_id: None,
|
||||
auth: RemoteAuthConfig::default(),
|
||||
poll_interval_ms: 750,
|
||||
},
|
||||
];
|
||||
let config = ClientConfig {
|
||||
remotes: vec![
|
||||
RemoteEntry {
|
||||
name: "dup".to_string(),
|
||||
url: "a:4530".to_string(),
|
||||
rig_id: None,
|
||||
auth: RemoteAuthConfig::default(),
|
||||
poll_interval_ms: 750,
|
||||
},
|
||||
RemoteEntry {
|
||||
name: "dup".to_string(),
|
||||
url: "b:4530".to_string(),
|
||||
rig_id: None,
|
||||
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 {
|
||||
name: "".to_string(),
|
||||
url: "a:4530".to_string(),
|
||||
rig_id: None,
|
||||
auth: RemoteAuthConfig::default(),
|
||||
poll_interval_ms: 750,
|
||||
}];
|
||||
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 {
|
||||
name: "hf".to_string(),
|
||||
url: " ".to_string(),
|
||||
rig_id: None,
|
||||
auth: RemoteAuthConfig::default(),
|
||||
poll_interval_ms: 750,
|
||||
}];
|
||||
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 {
|
||||
name: "hf".to_string(),
|
||||
url: "a:4530".to_string(),
|
||||
rig_id: None,
|
||||
auth: RemoteAuthConfig::default(),
|
||||
poll_interval_ms: 0,
|
||||
}];
|
||||
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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user