25c59405b5
Extract map-core.js (3,483 lines) and screenshot.js (261 lines) from the monolithic app.js, reducing it by ~30% (11,967 → 8,427 lines). Modules communicate via a window.trx shared namespace with getter/setter- backed state proxying. Map and statistics code lazy-loads on first tab activation; screenshot code lazy-loads on first "S" keypress. All cross- module calls use optional chaining for safe access before modules load. Adds Rust infrastructure (include_str, gz_cache, Actix routes) for serving the new JS assets. https://claude.ai/code/session_01HgW8UpscRRA3CgSLqQDzdp Signed-off-by: Claude <noreply@anthropic.com>
361 lines
18 KiB
Markdown
361 lines
18 KiB
Markdown
# Frontend Styling & Performance Improvements
|
|
|
|
*Analysis date: 2026-04-01*
|
|
|
|
This document captures observations and improvement recommendations for the
|
|
trx-rs web frontend (`trx-frontend-http`). The frontend is a single-page
|
|
application served as embedded static assets (gzip-compressed with ETag
|
|
caching) from the Actix-Web server.
|
|
|
|
## Current asset inventory
|
|
|
|
| File | Lines | Size |
|
|
|------|------:|-----:|
|
|
| `style.css` | 5,318 | 144 KB |
|
|
| `app.js` | 8,427 | 306 KB |
|
|
| `map-core.js` | 3,483 | 127 KB |
|
|
| `screenshot.js` | 261 | 10 KB |
|
|
| `index.html` | 1,564 | 96 KB |
|
|
| `webgl-renderer.js` | 526 | 20 KB |
|
|
| `decode-history-worker.js` | 176 | 8 KB |
|
|
| `leaflet-ais-tracksymbol.js` | 120 | 8 KB |
|
|
| 15 plugin scripts | 7,360 | 304 KB |
|
|
| **Total** | **~27,000** | **~1 MB** |
|
|
|
|
All assets are pre-compressed with `flate2` (gzip, `Compression::best()`) and
|
|
served with `ETag` + `If-None-Match` support for conditional requests. The
|
|
Actix `Compress` middleware handles dynamic responses.
|
|
|
|
---
|
|
|
|
## 1. CSS observations
|
|
|
|
### 1.1 Monolithic stylesheet (P1)
|
|
|
|
`style.css` is a single 5,318-line file covering every tab, theme, responsive
|
|
breakpoint, map overlay, decoder UI, scheduler, recorder, and settings panel.
|
|
Browsers must parse the entire stylesheet before first paint even though most
|
|
users only interact with 1-2 tabs at a time.
|
|
|
|
**Recommendations:**
|
|
- Split into logical partitions: `base.css` (variables, reset, layout), `tabs/*.css` (per-tab styles), `themes/*.css`. The server can concatenate and compress at build time.
|
|
- At minimum, move the theme colour blocks (lines 3770-5318, ~1,550 lines / 29% of the file) into a separate `themes.css` loaded asynchronously after initial paint, since the default theme is already in `:root`.
|
|
- Consider using `@layer` (CSS Cascade Layers) to manage specificity between base, component, and theme styles, eliminating the need for `!important` (currently 21 occurrences).
|
|
|
|
### 1.2 `backdrop-filter` overuse (P1)
|
|
|
|
There are 26 `backdrop-filter` declarations (13 pairs with `-webkit-` prefix).
|
|
`backdrop-filter: blur()` is one of the most expensive CSS properties -- it
|
|
forces the browser to composite, rasterize, and blur everything behind the
|
|
element on every frame.
|
|
|
|
Affected areas: tab bar, controls tray, frequency overlay, modals, connection
|
|
banner, bottom nav, neon-disco theme overlay.
|
|
|
|
**Recommendations:**
|
|
- Remove `backdrop-filter` from elements that are always opaque or rarely overlap dynamic content (e.g. bottom tab bar over static background).
|
|
- For the spectrum/waterfall overlay controls, use a solid semi-transparent `background` instead of blur -- the visual difference is negligible on a dark spectrogram.
|
|
- Where blur is desired (modals), use `will-change: backdrop-filter` and keep blur radius low (4-6px instead of 12-18px). Larger radii are proportionally more expensive.
|
|
- Gate expensive blur behind a `@media (prefers-reduced-motion: no-preference)` query or a `[data-effects="full"]` attribute so low-end devices can opt out.
|
|
|
|
### 1.3 `color-mix()` usage (P2)
|
|
|
|
184 occurrences of `color-mix(in srgb, ...)` throughout the stylesheet. While
|
|
`color-mix` is well-supported in modern browsers, each call is resolved at
|
|
computed-value time. Repeated identical mixes (e.g. button hover states
|
|
repeated across themes) add unnecessary style recalculation cost.
|
|
|
|
**Recommendations:**
|
|
- Pre-compute frequently used mixes as CSS custom properties in the theme blocks (e.g. `--btn-hover-bg`, `--btn-active-bg`).
|
|
- This reduces computed-value work and also makes the palette more explicit and maintainable.
|
|
|
|
### 1.4 Theme system duplication (P2)
|
|
|
|
Each of the 10 colour themes repeats ~28 variable declarations for both dark
|
|
and light mode (560 variable declarations total). The theme blocks span lines
|
|
3770-5318 (29% of the entire stylesheet).
|
|
|
|
**Recommendations:**
|
|
- Move themes to a separate file loaded after first paint (the default `:root` theme is always available).
|
|
- Consider generating theme CSS from a data source (JSON/TOML) at build time to reduce manual duplication.
|
|
- Use `color-scheme` and `light-dark()` (CSS Color Level 5) to collapse the dark/light pairs where values differ only in lightness.
|
|
|
|
### 1.5 Transitions on non-essential properties (P3)
|
|
|
|
25 `transition` declarations, several targeting `background`, `border-color`,
|
|
and `box-shadow` simultaneously. Multi-property transitions on buttons and
|
|
inputs cause style recalculation on hover/focus for every such element.
|
|
|
|
**Recommendations:**
|
|
- Prefer transitioning only `opacity` and `transform` (GPU-composited).
|
|
- For colour changes, use `transition: background-color 100ms` rather than the shorthand `background` which also transitions `background-image` and other sub-properties.
|
|
- Add `will-change: transform` only to elements that are actively animating (currently only 2 occurrences, which is good).
|
|
|
|
### 1.6 Missing `contain` declarations (P2)
|
|
|
|
Tab content panels, decode history tables, map containers, and spectrum
|
|
canvases do not use CSS `contain` or `content-visibility`. When a large decode
|
|
history table updates, the browser recalculates layout for the entire page.
|
|
|
|
**Recommendations:**
|
|
- Add `contain: content` to inactive tab panels (`[data-tab]:not(.active)`).
|
|
- Add `content-visibility: auto` with `contain-intrinsic-size` to off-screen panels (decode history, map, statistics). This lets the browser skip rendering for hidden content entirely.
|
|
- Add `contain: strict` to the spectrum/waterfall canvas containers since their size is fixed and they don't affect sibling layout.
|
|
|
|
---
|
|
|
|
## 2. JavaScript observations
|
|
|
|
### 2.1 Monolithic `app.js` (P1)
|
|
|
|
The main application script is 11,928 lines (428 KB uncompressed). It is loaded
|
|
synchronously in the HTML `<head>` (via embedded asset), blocking first paint
|
|
until fully parsed and executed. The 15 plugin scripts add another 7,360 lines.
|
|
|
|
**Recommendations:**
|
|
- Mark the script tag `defer` or move it to end of `<body>` so HTML parsing completes before script execution.
|
|
- Split `app.js` into logical modules: `core.js` (SSE, auth, render loop), `spectrum.js`, `map.js`, `decoder.js`, `recorder.js`, `settings.js`. Load non-critical modules lazily when the user navigates to the corresponding tab.
|
|
- Use ES modules (`type="module"`) for clean dependency management and tree-shaking potential.
|
|
|
|
### 2.2 DOM query overhead (P2)
|
|
|
|
The codebase contains ~359 `querySelector`/`getElementById` calls, many of
|
|
which execute on every SSE event (inside `render()`). DOM lookups are not free,
|
|
especially `querySelector` with compound selectors.
|
|
|
|
**Recommendations:**
|
|
- Cache DOM references at initialization time (many already are, but the render path still re-queries elements like `document.getElementById("tab-main")`).
|
|
- Move repeated lookups (e.g. line 3575 `document.getElementById("tab-main")` inside `es.onmessage`) to module-level constants.
|
|
|
|
### 2.3 `innerHTML` usage (P2)
|
|
|
|
33 `innerHTML` assignments in `app.js` and 72 across plugin scripts. Each
|
|
`innerHTML` write forces the browser to:
|
|
1. Serialize the old DOM subtree for GC
|
|
2. Parse the HTML string
|
|
3. Build and insert a new DOM subtree
|
|
|
|
This is both a performance concern (layout thrashing) and a security concern
|
|
(XSS if any user-controlled data is interpolated without escaping).
|
|
|
|
**Recommendations:**
|
|
- Replace `innerHTML` with DOM APIs (`createElement`/`appendChild`) or `DocumentFragment` for bulk updates (only 4 `createDocumentFragment` uses currently).
|
|
- For large lists (decode history, bookmarks, recorder file lists), use a virtualised list pattern that only renders visible rows.
|
|
- Where `innerHTML` is used to clear a container, prefer `replaceChildren()` (clears children without HTML parsing).
|
|
|
|
### 2.4 SSE render path efficiency (P2)
|
|
|
|
Every SSE state event triggers `render(update)` which is a ~300-line function
|
|
touching dozens of DOM elements. The function does not diff -- it
|
|
unconditionally sets properties even when values have not changed.
|
|
|
|
The string-equality guard (`if (evt.data === lastRendered) return`) is a good
|
|
optimisation for identical payloads, but when any field changes (e.g. S-meter
|
|
value), the entire render function runs.
|
|
|
|
**Recommendations:**
|
|
- Implement field-level diffing: compare individual fields against previous values and only update DOM elements whose backing data changed.
|
|
- Group updates by tab: if the user is on the "Map" tab, skip render work for "Main" tab elements (meters, frequency display, controls).
|
|
- Use `scheduleUiFrameJob()` (already exists at line 3685) more aggressively to batch DOM writes into animation frames.
|
|
|
|
### 2.5 Spectrum/waterfall rendering (P2)
|
|
|
|
The WebGL renderer (`webgl-renderer.js`) is well-implemented with proper
|
|
shader programs and batched draws. However:
|
|
- The CSS colour parsing (`parseCssColor`) uses a DOM probe element (appended to
|
|
body) and `getComputedStyle` as a fallback, which triggers layout.
|
|
- The colour cache is a simple `Map` with no eviction policy.
|
|
|
|
**Recommendations:**
|
|
- Parse theme colours once when the theme changes, not on every frame.
|
|
- Invalidate the `cssColorCache` on theme switch events.
|
|
|
|
### 2.6 Plugin script loading (P3)
|
|
|
|
All 15 plugin scripts are loaded eagerly in `index.html` regardless of which
|
|
decoders are active. Plugins like `ais.js`, `vdes.js`, `sat.js`,
|
|
`sat-scheduler.js`, and `hf-aprs.js` are only relevant for specific use cases.
|
|
|
|
**Recommendations:**
|
|
- Load plugin scripts on demand when the corresponding decoder or feature is activated.
|
|
- Use dynamic `import()` if migrated to ES modules, or lazy `<script>` injection.
|
|
|
|
### 2.7 Web Worker utilisation (P3)
|
|
|
|
Only one Web Worker exists (`decode-history-worker.js`, 176 lines) for CBOR
|
|
decode-history parsing. All other heavy work (SSE parsing, DOM updates, spectrum
|
|
rendering, map marker management) runs on the main thread.
|
|
|
|
**Recommendations:**
|
|
- Move SSE JSON parsing to a shared worker so the main thread only receives pre-parsed objects.
|
|
- Offload spectrum FFT data processing / colour mapping to a worker, posting the resulting `ImageData` to the main thread for canvas rendering.
|
|
|
|
---
|
|
|
|
## 3. HTML observations
|
|
|
|
### 3.1 CDN dependencies (P2)
|
|
|
|
The page loads two external resources at startup:
|
|
- `@fontsource/dseg14-classic/400.css` from `cdn.jsdelivr.net`
|
|
- `leaflet@1.9.4/dist/leaflet.css` from `unpkg.com`
|
|
|
|
Both use `rel="preload" as="style"` with an `onload` trick to make them
|
|
non-blocking, which is good. However:
|
|
- If CDN is unreachable (offline/firewalled deployments common in ham radio),
|
|
the font never loads and the frequency display falls back to the system font.
|
|
- Leaflet is always loaded even if the user never opens the Map tab.
|
|
|
|
**Recommendations:**
|
|
- Self-host the DSEG14 font as an embedded asset (it is small, ~30 KB woff2). This eliminates the CDN dependency entirely and ensures the frequency display always renders correctly.
|
|
- Defer Leaflet CSS loading until the Map tab is first opened.
|
|
|
|
### 3.2 Inline SVG icons (P3)
|
|
|
|
Tab bar icons are inline SVGs in the HTML (lines 35-63). Each icon is ~150-250
|
|
bytes of markup. This is acceptable for a small number of icons and avoids
|
|
extra HTTP requests, but the tab bar HTML is dense and hard to maintain.
|
|
|
|
**Recommendation:**
|
|
- Consider an SVG sprite sheet or moving icons to a small icon font to improve readability without extra requests.
|
|
|
|
### 3.3 HTML size (P2)
|
|
|
|
`index.html` is 1,564 lines (96 KB uncompressed). All tab content panels are
|
|
present in the initial HTML regardless of which tab is active.
|
|
|
|
**Recommendations:**
|
|
- Use `<template>` elements for tab panels that are not initially visible. Clone and insert them when the tab is first activated. This reduces initial DOM node count and speeds up first paint.
|
|
- The server already does template substitution (`{ver}` placeholders). Extend this to strip unused tab content for deployments that don't use certain features.
|
|
|
|
---
|
|
|
|
## 4. Responsive design observations
|
|
|
|
### 4.1 Breakpoints (P3)
|
|
|
|
Six responsive breakpoints are defined:
|
|
- `>1100px`: side bookmark panels
|
|
- `<1099px`: hide side bookmarks
|
|
- `<900px`: full-width card
|
|
- `<760px`: mobile layout (touch targets, stacked controls)
|
|
- `<640px`: bottom tab bar, mobile nav
|
|
- `<520px`: compact mobile
|
|
- `(hover: none) and (pointer: coarse)`: touch-specific
|
|
|
|
This is a well-structured responsive system. Minor improvements:
|
|
- Use `min-width` mobile-first instead of `max-width` desktop-first to reduce CSS specificity conflicts.
|
|
- Consider `container queries` for components like the controls tray and decode history table, so they respond to their container size rather than the viewport.
|
|
|
|
### 4.2 Touch target sizing (P3)
|
|
|
|
Mobile buttons get `min-height: 2.8rem` at `<760px`. The
|
|
`(hover: none) and (pointer: coarse)` media query adds additional touch
|
|
accommodations. This meets the 44px minimum recommended by WCAG.
|
|
|
|
---
|
|
|
|
## 5. Accessibility observations
|
|
|
|
### 5.1 `aria-live` regions (P1)
|
|
|
|
The connection-lost banner and power hint text update dynamically but were
|
|
flagged in the Settings-Menu-UX-Analysis as missing `aria-live` on toast
|
|
notifications. Ensuring all dynamic status text has `aria-live="polite"` or
|
|
`aria-live="assertive"` (for errors) is critical for screen reader users.
|
|
|
|
### 5.2 Keyboard navigation (P2)
|
|
|
|
The tab bar uses `<button>` elements (good, natively focusable). However, the
|
|
spectrum canvas, jog wheel, and map are mouse/touch-only without keyboard
|
|
equivalents. The Settings-Menu-UX-Analysis noted the timeline SVG is not
|
|
keyboard-operable.
|
|
|
|
### 5.3 Colour contrast (P2)
|
|
|
|
`--text-muted` values (`#91a3bd` on `#0f172a` for dark, `#4a5568` on `#ffffff`
|
|
for light) should be verified against WCAG AA (4.5:1 for normal text). The
|
|
dark theme muted text calculates to approximately 4.8:1 (passes), but some
|
|
theme variants (e.g. Neon Disco) may not meet contrast requirements.
|
|
|
|
---
|
|
|
|
## 6. Server-side delivery observations
|
|
|
|
### 6.1 Asset compression (already good)
|
|
|
|
Static assets are pre-compressed with `gzip` at `Compression::best()` level
|
|
and served with ETag headers. Conditional `304 Not Modified` responses avoid
|
|
re-transferring unchanged assets.
|
|
|
|
### 6.2 Missing `Cache-Control` headers (P2)
|
|
|
|
While ETags are present, the analysis did not find explicit `Cache-Control`
|
|
headers on static assets. Adding `Cache-Control: public, max-age=31536000,
|
|
immutable` for versioned assets (with cache-busting query strings) would
|
|
eliminate conditional requests entirely for repeat visits.
|
|
|
|
### 6.3 Consider Brotli compression (P3)
|
|
|
|
Brotli (`br`) typically achieves 15-25% better compression than gzip for text
|
|
assets. For a 428 KB `app.js`, this could save ~60-100 KB of transfer. Actix
|
|
supports Brotli via the `Compress` middleware.
|
|
|
|
---
|
|
|
|
## 7. Priority summary
|
|
|
|
```mermaid
|
|
quadrantChart
|
|
title Impact vs Effort
|
|
x-axis Low Effort --> High Effort
|
|
y-axis Low Impact --> High Impact
|
|
quadrant-1 Do next
|
|
quadrant-2 Plan carefully
|
|
quadrant-3 Low priority
|
|
quadrant-4 Quick wins
|
|
"backdrop-filter reduction": [0.25, 0.80]
|
|
"Cache-Control headers": [0.15, 0.55]
|
|
"CSS contain/content-visibility": [0.30, 0.70]
|
|
"Cache DOM refs in render": [0.20, 0.50]
|
|
"Theme CSS split": [0.35, 0.45]
|
|
"Self-host DSEG14 font": [0.20, 0.40]
|
|
"Field-level render diffing": [0.60, 0.75]
|
|
"Split app.js into modules": [0.80, 0.70]
|
|
"Lazy plugin loading": [0.50, 0.40]
|
|
"innerHTML to DOM APIs": [0.65, 0.55]
|
|
"Brotli compression": [0.30, 0.25]
|
|
"Template-based tab panels": [0.70, 0.60]
|
|
```
|
|
|
|
### Quick wins (low effort, high impact)
|
|
1. ~~Reduce `backdrop-filter` usage (13 blur instances)~~ **DONE** -- replaced with solid backgrounds, blur preserved for modals only, `prefers-reduced-motion` gate added
|
|
2. ~~Add `contain: content` / `content-visibility: auto` to inactive tabs~~ **DONE** -- containment added for inactive tabs, spectrum/waterfall containers, map, statistics
|
|
3. ~~Add `Cache-Control` headers to static assets~~ **DONE** -- upgraded to `public, max-age=31536000, immutable`
|
|
4. ~~Cache remaining DOM references in the render path~~ **DONE** -- `tabMainEl` and other hot-path refs cached at module level
|
|
|
|
### Next phase (moderate effort)
|
|
5. ~~Split theme CSS into a separate lazy-loaded file~~ **DONE** -- theme blocks extracted to `/themes.css`, lazy-loaded via `<link rel="preload">`
|
|
6. ~~Self-host DSEG14 font~~ **DONE** -- `@font-face` with `font-display: swap` added to `style.css`, CDN preconnect/preload removed from HTML
|
|
7. ~~Pre-compute `color-mix` results as CSS variables~~ **DONE** -- common mixes pre-computed as `--btn-hover-bg`, `--btn-active-bg`, etc.
|
|
8. ~~Field-level diffing in the SSE render function~~ **DONE** -- `prevRenderData` tracks freq/mode/ptt/meter, active-tab-aware skip logic added
|
|
9. ~~Replace `innerHTML` with DOM APIs in hot paths~~ **DONE** -- 15+ `innerHTML = ""` replaced with `replaceChildren()`
|
|
|
|
### Longer-term
|
|
10. ~~Split `app.js` into modules with lazy loading~~ **DONE** -- `map-core.js` (3,480 lines, map/stats/geo) and `screenshot.js` (260 lines) extracted as IIFE modules communicating via `window.trx` namespace; lazy-loaded on tab activation and on-demand respectively; `app.js` reduced from 11,967 to 8,420 lines (30% reduction)
|
|
11. ~~Lazy-load plugin scripts and Leaflet on demand~~ **DONE** -- plugin scripts loaded on tab activation, core plugins loaded immediately
|
|
12. ~~Use `<template>` elements for deferred tab content~~ **DONE** -- map, statistics, about tabs wrapped in `<template>`, cloned on first activation
|
|
13. ~~Migrate to Brotli compression~~ **DONE** -- Brotli added alongside gzip, preferred when `Accept-Encoding: br` present
|
|
14. Move SSE parsing and spectrum processing to Web Workers -- **DEFERRED** (requires SharedWorker + MessagePort plumbing, tracked separately)
|
|
|
|
### Additional improvements implemented
|
|
15. ~~Optimize CSS transitions~~ **DONE** -- `background` shorthand → `background-color` for GPU compositing
|
|
16. ~~Add `defer` to script tags~~ **DONE** -- all external script tags use `defer`
|
|
17. ~~SVG sprite sheet~~ **DONE** -- inline SVGs moved to `<symbol>` defs, referenced via `<use>`
|
|
18. ~~aria-live regions~~ **DONE** -- `aria-live` added to power hint, loading indicator
|
|
19. ~~Keyboard navigation~~ **DONE** -- `tabindex`/`role`/`aria-label` on spectrum/waterfall canvases
|
|
20. ~~Colour contrast~~ **DONE** -- dark theme `--text-muted` improved to `#9bb0ca`
|
|
21. ~~WebGL colour cache invalidation~~ **DONE** -- `trxClearCssColorCache()` called on theme switch
|
|
22. ~~Container queries~~ **DONE** -- controls tray and decode history table respond to container size
|
|
23. ~~Cache-Control immutable~~ **DONE** -- versioned assets use `immutable` directive
|