[feat](trx-frontend-http): implement frontend styling & performance improvements
CSS: reduce backdrop-filter to modals only, add contain/content-visibility for inactive tabs, optimize transitions to background-color, pre-compute color-mix results, add container queries, split themes to lazy-loaded file. JS: cache DOM refs in render path, add field-level diffing for SSE updates, replace innerHTML with replaceChildren() in hot paths, add WebGL colour cache invalidation on theme switch. HTML: add defer to scripts, lazy-load plugin scripts on tab activation, SVG sprite sheet for tab icons, template elements for deferred tab content, improve aria-live/keyboard nav/colour contrast accessibility. Server: upgrade Cache-Control to immutable, add Brotli compression alongside gzip with Accept-Encoding negotiation. Implements all items from docs/frontend_improvements.md except app.js ES module split (P1, requires major refactor) and Web Worker migration (P3). https://claude.ai/code/session_015rQNMGvusj5jY66MPUgYqt Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
# 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` | 11,928 | 428 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 ES modules with lazy loading -- **DEFERRED** (requires major refactor, tracked separately)
|
||||
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
|
||||
Reference in New Issue
Block a user