From d29ef5d85e4eed43f0fe995c09f6d4f75299e026 Mon Sep 17 00:00:00 2001 From: JuliDi Date: Sat, 10 Oct 2026 11:12:01 +0200 Subject: [PATCH 1/4] Generate TypeScript WS bindings from Rust types with ts-rs Replace hand-written types in ws.ts with bindings.ts generated by ts-rs. Add just gen-types/check-types recipes and a CI job that fails when the committed bindings are stale. The old WsCommand type was already missing OtaUpload. --- .github/workflows/ci.yml | 22 +++++ software/pc/.cargo/config.toml | 3 + software/pc/Cargo.lock | 32 +++++++ software/pc/Cargo.toml | 3 + software/pc/frontend/.prettierignore | 3 + software/pc/frontend/eslint.config.js | 2 +- software/pc/frontend/src/lib/bindings.ts | 92 +++++++++++++++++++ software/pc/frontend/src/lib/ws.ts | 109 ++--------------------- software/pc/justfile | 8 ++ software/pc/src/lib.rs | 27 ++++-- 10 files changed, 193 insertions(+), 108 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 software/pc/.cargo/config.toml create mode 100644 software/pc/frontend/src/lib/bindings.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b59c47e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + ts-bindings: + name: TypeScript bindings up to date + runs-on: ubuntu-latest + defaults: + run: + working-directory: software/pc + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: software/pc + - uses: taiki-e/install-action@just + - run: just check-types diff --git a/software/pc/.cargo/config.toml b/software/pc/.cargo/config.toml new file mode 100644 index 0000000..03a01c2 --- /dev/null +++ b/software/pc/.cargo/config.toml @@ -0,0 +1,3 @@ +[env] +# Output directory for ts-rs generated TypeScript bindings +TS_RS_EXPORT_DIR = { value = "frontend/src/lib", relative = true } diff --git a/software/pc/Cargo.lock b/software/pc/Cargo.lock index fd79393..9c2072c 100644 --- a/software/pc/Cargo.lock +++ b/software/pc/Cargo.lock @@ -157,6 +157,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "ts-rs", ] [[package]] @@ -1176,6 +1177,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1388,6 +1398,28 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "termcolor", +] + [[package]] name = "tungstenite" version = "0.28.0" diff --git a/software/pc/Cargo.toml b/software/pc/Cargo.toml index a38b6dc..7d86ba5 100644 --- a/software/pc/Cargo.toml +++ b/software/pc/Cargo.toml @@ -48,5 +48,8 @@ heapless = "0.9.2" # SHA-256 for OTA firmware verification sha2 = "0.10" +# Generate TypeScript bindings for WebSocket types (see `just gen-types`) +ts-rs = "12" + [dev-dependencies] tokio = { version = "1", features = ["full", "test-util"] } diff --git a/software/pc/frontend/.prettierignore b/software/pc/frontend/.prettierignore index b1cefd3..c615336 100644 --- a/software/pc/frontend/.prettierignore +++ b/software/pc/frontend/.prettierignore @@ -10,3 +10,6 @@ static/** pnpm-lock.yaml package-lock.json yarn.lock + +# Generated by ts-rs (just gen-types) +src/lib/bindings.ts diff --git a/software/pc/frontend/eslint.config.js b/software/pc/frontend/eslint.config.js index 9aa25d1..7399f66 100644 --- a/software/pc/frontend/eslint.config.js +++ b/software/pc/frontend/eslint.config.js @@ -43,6 +43,6 @@ export default ts.config( } }, { - ignores: ['.svelte-kit/**', 'build/**', 'node_modules/**'] + ignores: ['.svelte-kit/**', 'build/**', 'node_modules/**', 'src/lib/bindings.ts'] } ); diff --git a/software/pc/frontend/src/lib/bindings.ts b/software/pc/frontend/src/lib/bindings.ts new file mode 100644 index 0000000..99ee7fe --- /dev/null +++ b/software/pc/frontend/src/lib/bindings.ts @@ -0,0 +1,92 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A single measurement data point. + */ +export type DataPoint = { +/** + * Turntable angle in degrees. + */ +angle_deg: number, +/** + * Averaged RSSI in dBm (negative; closer to 0 = stronger). + */ +rssi_dbm: number, +/** + * Number of raw RSSI samples averaged into this reading. + */ +sample_count: number, }; + +/** + * Serial port information returned by the list_ports endpoint. + */ +export type PortInfo = { +/** + * OS device path (e.g. `/dev/ttyUSB0` or `COM3`). + */ +name: string, +/** + * Human-readable description from the USB descriptor. + */ +description: string, }; + +/** + * Sweep configuration sent from UI to backend. + */ +export type SweepConfig = { start_deg: number, stop_deg: number, step_deg: number, samples_per_angle: number, }; + +/** + * Current system status broadcast to all WebSocket clients on state changes. + */ +export type SystemStatus = { +/** + * Whether an antenna pattern sweep is in progress. + */ +sweeping: boolean, +/** + * Whether the TX ESP32 is paired via ESP-NOW. + */ +tx_connected: boolean, +/** + * Whether the RX ESP32 is paired via ESP-NOW. + */ +rx_connected: boolean, +/** + * Whether the turntable ESP32 is paired via ESP-NOW. + */ +turntable_connected: boolean, +/** + * Whether the PC is connected to the Bridge over serial/TCP. + */ +serial_connected: boolean, +/** + * Number of data points stored on the backend. + */ +data_points: number, +/** + * Active RF measurement backend name (e.g. "espnow", "dect-nr+"). + */ +backend_name: string, +/** + * Carrier frequency in MHz, if applicable. + */ +backend_freq_mhz: number | null, +/** + * Motor-to-turntable gear ratio (motor_deg = turntable_deg × ratio). + */ +transmission_ratio: number, }; + +/** + * TX configuration. + */ +export type TxConfig = { channel: number, tx_power_dbm: number, packet_rate_hz: number, }; + +/** + * Commands received from the frontend over WebSocket. + */ +export type WsCommand = { "type": "StartSweep" } & SweepConfig | { "type": "ConfigureTx" } & TxConfig | { "type": "Stop" } | { "type": "QueryStatus" } | { "type": "ListPorts" } | { "type": "Connect", port: string, } | { "type": "Disconnect" } | { "type": "ExportCsv" } | { "type": "ReturnHome" } | { "type": "Jog", delta_deg: number, } | { "type": "OtaUpload", target: string, firmware_path: string, } | { "type": "SetTransmissionRatio", ratio: number, }; + +/** + * Events pushed to the frontend over WebSocket. + */ +export type WsEvent = { "type": "DataPoint" } & DataPoint | { "type": "SweepComplete" } | { "type": "HomeComplete" } | { "type": "JogComplete", angle_deg: number, } | { "type": "Status" } & SystemStatus | { "type": "OtaProgress", chunks_sent: number, total_chunks: number, } | { "type": "OtaFinished" } | { "type": "Error", message: string, } | { "type": "Log", message: string, }; diff --git a/software/pc/frontend/src/lib/ws.ts b/software/pc/frontend/src/lib/ws.ts index b219845..99f7e9a 100644 --- a/software/pc/frontend/src/lib/ws.ts +++ b/software/pc/frontend/src/lib/ws.ts @@ -1,105 +1,14 @@ /** WebSocket client for communicating with the Axum backend. */ -/** A single RSSI measurement at a given turntable angle. */ -export type DataPoint = { - /** Turntable angle in degrees. */ - angle_deg: number; - /** Averaged RSSI reading in dBm (negative values; closer to 0 = stronger). */ - rssi_dbm: number; - /** Number of raw RSSI samples averaged into this reading. */ - sample_count: number; -}; - -/** Snapshot of the system's connection and sweep state, sent periodically by the backend. */ -export type SystemStatus = { - /** Whether a sweep is currently in progress. */ - sweeping: boolean; - /** Whether the TX (transmitter) ESP32 is paired via ESP-NOW. */ - tx_connected: boolean; - /** Whether the RX (receiver) ESP32 is paired via ESP-NOW. */ - rx_connected: boolean; - /** Whether the turntable ESP32 is paired via ESP-NOW. */ - turntable_connected: boolean; - /** Whether the PC is connected to the Bridge over serial. */ - serial_connected: boolean; - /** Total data points collected in the current session. */ - data_points: number; - /** Active RF measurement backend name (e.g. "espnow", "dect-nr+"). */ - backend_name: string; - /** Carrier frequency in MHz, if applicable. */ - backend_freq_mhz: number | null; - /** Motor-to-turntable gear ratio (motor_deg = turntable_deg × ratio). */ - transmission_ratio: number; -}; - -/** A serial port available on the host machine. */ -export type PortInfo = { - /** OS device path (e.g. `/dev/ttyUSB0` or `COM3`). */ - name: string; - /** Human-readable description from the USB descriptor. */ - description: string; -}; - -/** - * Server-to-client WebSocket events. - * - * The backend pushes these as JSON over the `/ws` endpoint. - */ -export type WsEvent = - | { - type: 'DataPoint'; - angle_deg: number; - rssi_dbm: number; - sample_count: number; - } - | { type: 'SweepComplete' } - | { type: 'HomeComplete' } - | { type: 'JogComplete'; angle_deg: number } - | { - type: 'Status'; - sweeping: boolean; - tx_connected: boolean; - rx_connected: boolean; - turntable_connected: boolean; - serial_connected: boolean; - data_points: number; - backend_name: string; - backend_freq_mhz: number | null; - transmission_ratio: number; - } - | { type: 'OtaProgress'; chunks_sent: number; total_chunks: number } - | { type: 'OtaFinished' } - | { type: 'Error'; message: string } - | { type: 'Log'; message: string }; - -/** - * Client-to-server WebSocket commands. - * - * Sent as JSON to the `/ws` endpoint to control the measurement system. - */ -export type WsCommand = - | { - type: 'StartSweep'; - start_deg: number; - stop_deg: number; - step_deg: number; - samples_per_angle: number; - } - | { - type: 'ConfigureTx'; - channel: number; - tx_power_dbm: number; - packet_rate_hz: number; - } - | { type: 'Stop' } - | { type: 'QueryStatus' } - | { type: 'ListPorts' } - | { type: 'Connect'; port: string } - | { type: 'Disconnect' } - | { type: 'ExportCsv' } - | { type: 'ReturnHome' } - | { type: 'Jog'; delta_deg: number } - | { type: 'SetTransmissionRatio'; ratio: number }; +// Types are generated from the Rust definitions in src/lib.rs (`just gen-types`). +import type { WsCommand, WsEvent } from './bindings'; +export type { + DataPoint, + PortInfo, + SystemStatus, + WsCommand, + WsEvent +} from './bindings'; export function createWsConnection( onEvent: (event: WsEvent) => void, diff --git a/software/pc/justfile b/software/pc/justfile index 06b3cd0..fc7fffa 100644 --- a/software/pc/justfile +++ b/software/pc/justfile @@ -37,6 +37,14 @@ check: test: cargo test --lib +# Generate TypeScript bindings (frontend/src/lib/bindings.ts) from Rust types +gen-types: + cargo test --lib export_bindings + +# Fail if committed TypeScript bindings are out of date +check-types: gen-types + git diff --exit-code frontend/src/lib/bindings.ts + # Run integration tests (requires sim feature) test-integration: cargo test --test integration --features sim diff --git a/software/pc/src/lib.rs b/software/pc/src/lib.rs index 1e24b40..d940aee 100644 --- a/software/pc/src/lib.rs +++ b/software/pc/src/lib.rs @@ -10,17 +10,23 @@ pub mod sim; pub mod sweep; use serde::{Deserialize, Serialize}; +use ts_rs::TS; /// A single measurement data point. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings.ts")] pub struct DataPoint { + /// Turntable angle in degrees. pub angle_deg: f32, + /// Averaged RSSI in dBm (negative; closer to 0 = stronger). pub rssi_dbm: f32, + /// Number of raw RSSI samples averaged into this reading. pub sample_count: u16, } /// Sweep configuration sent from UI to backend. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings.ts")] pub struct SweepConfig { pub start_deg: f32, pub stop_deg: f32, @@ -29,7 +35,8 @@ pub struct SweepConfig { } /// TX configuration. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings.ts")] pub struct TxConfig { pub channel: u8, pub tx_power_dbm: i8, @@ -45,7 +52,8 @@ pub struct TxConfig { pub const DEFAULT_TRANSMISSION_RATIO: f32 = 3.0; /// Current system status broadcast to all WebSocket clients on state changes. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings.ts")] pub struct SystemStatus { /// Whether an antenna pattern sweep is in progress. pub sweeping: bool, @@ -68,14 +76,18 @@ pub struct SystemStatus { } /// Serial port information returned by the list_ports endpoint. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings.ts")] pub struct PortInfo { + /// OS device path (e.g. `/dev/ttyUSB0` or `COM3`). pub name: String, + /// Human-readable description from the USB descriptor. pub description: String, } /// Events pushed to the frontend over WebSocket. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings.ts")] #[serde(tag = "type")] pub enum WsEvent { /// A new measurement data point arrived during a sweep. @@ -99,7 +111,8 @@ pub enum WsEvent { } /// Commands received from the frontend over WebSocket. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings.ts")] #[serde(tag = "type")] pub enum WsCommand { /// Start a new antenna pattern sweep with the given configuration. From 55e6943bbc0c9fa2e997d9e3038bcb2f679d6be3 Mon Sep 17 00:00:00 2001 From: JuliDi Date: Sat, 10 Oct 2026 11:12:02 +0200 Subject: [PATCH 2/4] Allow esbuild install script for pnpm 11 --- software/pc/frontend/pnpm-workspace.yaml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 software/pc/frontend/pnpm-workspace.yaml diff --git a/software/pc/frontend/pnpm-workspace.yaml b/software/pc/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/software/pc/frontend/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true From 90a00ad1f81d46b94e4d172cba14903080673211 Mon Sep 17 00:00:00 2001 From: JuliDi Date: Sat, 10 Oct 2026 11:58:17 +0200 Subject: [PATCH 3/4] Format +page.svelte with prettier --- software/pc/frontend/src/routes/+page.svelte | 140 ++++++++++++++----- 1 file changed, 108 insertions(+), 32 deletions(-) diff --git a/software/pc/frontend/src/routes/+page.svelte b/software/pc/frontend/src/routes/+page.svelte index 3cf5edc..cf3e271 100644 --- a/software/pc/frontend/src/routes/+page.svelte +++ b/software/pc/frontend/src/routes/+page.svelte @@ -81,7 +81,8 @@ /** Client-side mirror of SweepConfig::validate(). */ let configError: string | null = $derived.by(() => { if (stepDeg <= 0) return 'Step size must be positive'; - if (startDeg >= stopDeg) return 'Start angle must be less than stop angle'; + if (startDeg >= stopDeg) + return 'Start angle must be less than stop angle'; if (samplesPerAngle < 1) return 'Samples per angle must be at least 1'; return null; }); @@ -154,8 +155,12 @@ addLog(`Turntable at ${event.angle_deg.toFixed(1)}°`); break; case 'OtaProgress': { - const pct = Math.floor(event.chunks_sent / event.total_chunks * 100); - addLog(`OTA progress: ${pct}% (${event.chunks_sent}/${event.total_chunks} chunks)`); + const pct = Math.floor( + (event.chunks_sent / event.total_chunks) * 100 + ); + addLog( + `OTA progress: ${pct}% (${event.chunks_sent}/${event.total_chunks} chunks)` + ); break; } case 'OtaFinished': @@ -273,10 +278,16 @@ if (activeData.length > 0) { const activeColor = COLORS[(nextId - 1) % COLORS.length]; traces.push( - buildTrace(activeData, floor, `Sweep ${nextId} (active)`, activeColor, { - lineWidth: 2, - markerSize: 4 - }) + buildTrace( + activeData, + floor, + `Sweep ${nextId} (active)`, + activeColor, + { + lineWidth: 2, + markerSize: 4 + } + ) ); } @@ -381,7 +392,7 @@ setter: (v: number) => void, min: number, max: number, - invert = false, + invert = false ) { e.preventDefault(); const target = e.currentTarget as HTMLElement; @@ -391,7 +402,12 @@ function onMove(ev: PointerEvent) { const delta = (axis === 'x' ? ev.clientX : ev.clientY) - startPos; - setter(Math.min(max, Math.max(min, startVal + (invert ? -delta : delta)))); + setter( + Math.min( + max, + Math.max(min, startVal + (invert ? -delta : delta)) + ) + ); } function onUp() { target.removeEventListener('pointermove', onMove); @@ -407,8 +423,12 @@ ws = createWsConnection( handleEvent, - () => { wsConnected = true; }, - () => { wsConnected = false; } + () => { + wsConnected = true; + }, + () => { + wsConnected = false; + } ); await fetchPorts(); @@ -423,7 +443,9 @@

Beambench

{#if !wsConnected} -
Server connection lost — reconnecting…
+
+ Server connection lost — reconnecting… +
{/if} {#if errorMessage} @@ -481,7 +503,8 @@ {#if status.backend_name}

- Backend: {status.backend_name}{#if status.backend_freq_mhz} + Backend: {status.backend_name}{#if status.backend_freq_mhz}  ({status.backend_freq_mhz} MHz){/if}

{/if} @@ -491,31 +514,49 @@

Turntable

Position: {turntableAngle.toFixed(1)}° - {#if jogging} (moving...){/if} + {#if jogging} + (moving...){/if}

@@ -523,7 +564,11 @@ @@ -541,15 +586,20 @@

Active: 1:{status.transmission_ratio} - — e.g. 10° turntable = {(10 * status.transmission_ratio).toFixed(1)}° motor + — e.g. 10° turntable = {( + 10 * status.transmission_ratio + ).toFixed(1)}° motor

@@ -589,7 +639,10 @@
-
startResize(e, 'x', () => sidebarWidth, (v) => { sidebarWidth = v; }, 200, 500)} - >
+ onpointerdown={(e) => + startResize( + e, + 'x', + () => sidebarWidth, + (v) => { + sidebarWidth = v; + }, + 200, + 500 + )}> + -
+
startResize(e, 'y', () => logHeight, (v) => { logHeight = v; }, 60, 600, true)} - >
+ onpointerdown={(e) => + startResize( + e, + 'y', + () => logHeight, + (v) => { + logHeight = v; + }, + 60, + 600, + true + )}> +

Log

@@ -1062,5 +1139,4 @@ color: #ffa726; font-style: italic; } - From 33566a1aab7b450822df985ad0d8e217d7a71241 Mon Sep 17 00:00:00 2001 From: JuliDi Date: Sat, 10 Oct 2026 11:58:17 +0200 Subject: [PATCH 4/4] Add light mode via prefers-color-scheme Move all colors into CSS tokens in app.css (dark default, light under the media query). Plotly reads the tokens at render time and redraws on theme change. Trace palette is theme-aware via --trace-N. --- software/pc/frontend/src/app.css | 68 ++++++++ .../pc/frontend/src/routes/+layout.svelte | 1 + software/pc/frontend/src/routes/+page.svelte | 155 ++++++++++-------- 3 files changed, 157 insertions(+), 67 deletions(-) create mode 100644 software/pc/frontend/src/app.css diff --git a/software/pc/frontend/src/app.css b/software/pc/frontend/src/app.css new file mode 100644 index 0000000..8184eda --- /dev/null +++ b/software/pc/frontend/src/app.css @@ -0,0 +1,68 @@ +/* Theme tokens. Dark is the default; light follows the OS preference. */ +:root { + color-scheme: dark; + + --bg: #111; + --surface: #1a1a1a; + --surface-2: #222; + --surface-hover: #333; + --border: #444; + --border-strong: #555; + --border-focus: #777; + + --text: #eee; + --text-muted: #aaa; + --text-dim: #888; + + --plot-grid: #2a2a2a; + --plot-axis: #333; + + --danger: #e53935; + --danger-banner: #d32f2f; + --danger-hover: #2a1010; + --warning: #e65100; + --success: #4caf50; + --jog: #ffa726; + + --trace-0: #fff; + --trace-1: #4fc3f7; + --trace-2: #ff8a65; + --trace-3: #81c784; + --trace-4: #ce93d8; + --trace-5: #fff176; +} + +@media (prefers-color-scheme: light) { + :root { + color-scheme: light; + + --bg: #f4f4f5; + --surface: #fff; + --surface-2: #fafafa; + --surface-hover: #ebebeb; + --border: #d4d4d8; + --border-strong: #bdbdc2; + --border-focus: #8e8e93; + + --text: #1c1c1e; + --text-muted: #52525b; + --text-dim: #8e8e93; + + --plot-grid: #e4e4e7; + --plot-axis: #c8c8cc; + + --danger: #d32f2f; + --danger-banner: #d32f2f; + --danger-hover: #fdecea; + --warning: #e65100; + --success: #2e7d32; + --jog: #ef6c00; + + --trace-0: #212121; + --trace-1: #0288d1; + --trace-2: #e64a19; + --trace-3: #2e7d32; + --trace-4: #7b1fa2; + --trace-5: #f9a825; + } +} diff --git a/software/pc/frontend/src/routes/+layout.svelte b/software/pc/frontend/src/routes/+layout.svelte index 02a579e..69be405 100644 --- a/software/pc/frontend/src/routes/+layout.svelte +++ b/software/pc/frontend/src/routes/+layout.svelte @@ -1,4 +1,5 @@ @@ -696,7 +716,8 @@ toggleMeasurement(m.id)} /> + style="background: var(--trace-{m.colorIdx})" + > {m.name}