Merge pull request #197 from ipnet-mesh/chore/update-radio-info-display

feat: replace flat radio config list with icon tile grid on homepage
This commit is contained in:
JingleManSweep
2026-05-06 13:36:26 +01:00
committed by GitHub
5 changed files with 354 additions and 17 deletions
@@ -0,0 +1,260 @@
# Plan: Radio Info Tile Display — Homepage Network Info Panel Overhaul
**Date**: 2026-05-06
**Status**: Draft
---
## Summary
Replace the flat label:value list in the **Network Info** panel on the home page (`/`) with a **3×2 grid of compact tiles**. Each tile represents one radio configuration parameter and displays an icon, a label, and the value. The design uses subtle colour, rounded borders, and a clear visual hierarchy (icon → label → value).
---
## Current State
### UI
The Network Info panel at `home.js:204215` renders radio config parameters as a simple two-column flex list inside a `card bg-base-100 shadow-xl`:
```
┌──────────────────────────────┐
Network Info │
│ │
│ Profile: EU/UK Narrow │
│ Frequency: 869.618MHz │
│ Bandwidth: 62.5kHz │
│ Spreading Factor: 8 │
│ Coding Rate: 8 │
│ TX Power: 22dBm │
└──────────────────────────────┘
```
The `renderRadioConfig()` function at `home.js:1128` maps the 6 `RadioConfig` fields to a flat array of label+value pairs, rendered with `<div class="flex justify-between">` rows.
### Data Source
Radio config is injected server-side via `window.__APP_CONFIG__.network_radio_config` (not an API endpoint). The `RadioConfig` Pydantic model (`common/schemas/network.py`) parses a comma-delimited string into:
- `profile` (str) — e.g. `"EU/UK Narrow"`
- `frequency` (str) — e.g. `"869.618MHz"`
- `bandwidth` (str) — e.g. `"62.5kHz"`
- `spreading_factor` (int) — e.g. `8`
- `coding_rate` (int) — e.g. `8`
- `tx_power` (str) — e.g. `"22dBm"`
Any field can be `None`/`null` (partial configs are supported).
### Key Files
| File | Role |
|------|------|
| `src/meshcore_hub/web/static/js/spa/pages/home.js` | Home page render; `renderRadioConfig()` at lines 1128; panel layout at lines 204215 |
| `src/meshcore_hub/web/static/css/app.css` | Custom styles; color palette at lines 2129; panel utilities at lines 88113 |
| `src/meshcore_hub/web/static/js/spa/components.js` | Shared component imports; `t()`, `html`, `litRender`, `pageColors` |
| `src/meshcore_hub/web/static/js/spa/icons.js` | SVG icon functions (`iconAntenna`, `iconChart`, etc.) |
| `src/meshcore_hub/web/static/locales/en.json` | i18n keys: `links.profile`, `home.frequency`, `home.bandwidth`, `home.spreading_factor`, `home.coding_rate`, `home.tx_power`, `home.network_info` |
| `src/meshcore_hub/common/schemas/network.py` | `RadioConfig` Pydantic model (data structure validation) |
| `src/meshcore_hub/web/app.py` | Server-side config injection into `__APP_CONFIG__` (line 237272) |
---
## Target State
### UI
Replace the flat label:value list with a **3-column × 2-row grid** of compact tiles. Each tile has this structure:
```
┌──────────────────────┐
│ │
│ [icon] │ ← higher-contrast colour
│ │
│ Label │ ← medium-contrast font (e.g. "Frequency")
│ 869.618MHz │ ← white (dark theme) / black (light theme)
│ │
└──────────────────────┘
```
**6 tiles**: Profile, Frequency, Bandwidth, Spreading Factor, Coding Rate, TX Power.
**Grid layout**:
```
┌────────────┬────────────┬────────────┐
│ Profile │ Frequency │ Bandwidth │
│ EU/UK... │ 869.618MHz │ 62.5kHz │
├────────────┼────────────┼────────────┤
│ Spreading │ Coding │ TX Power │
│ Factor 8 │ Rate 8 │ 22dBm │
└────────────┴────────────┴────────────┘
```
**Responsive behaviour**: On small screens (`md` breakpoint and below), collapse to 2 columns. No single-column fallback — 2 columns minimum ensures tiles remain compact even on phones.
**Missing fields**: If a radio parameter is `null`/`undefined`, the entire tile for that parameter is hidden (same as current behaviour where empty values are filtered out).
### Visual Design
| Element | Dark Theme | Light Theme |
|---------|-----------|-------------|
| **Tile background** | `transparent` (no fill) | Same |
| **Tile border** | `border border-base-content/10 rounded-box` | Same |
| **Icon colour** | `var(--color-radio)` — new cyan accent (`oklch(0.75 0.15 210)`) | `var(--color-radio)` — darker variant (`oklch(0.55 0.15 210)`) |
| **Label** | `opacity-70` on `text-base-content` (medium contrast) | Same |
| **Value** | `text-base-content` (white in dark, black in light) with `font-semibold` | Same |
Tiles use a transparent background — no fill, no `panel-glow`. The rounded border alone provides visual separation, keeping tiles compact and lightweight.
### Icon Selection
Create new SVG icon functions in `icons.js` for radio-specific concepts, OR reuse existing Heroicons:
| Parameter | Icon Concept | Source |
|-----------|-------------|--------|
| Profile | Cog/gear (settings profile) | Existing `iconSettings` or new cog variant |
| Frequency | Waveform/sine wave | New SVG — sine wave icon |
| Bandwidth | Sliders/adjust | New SVG — horizontal sliders |
| Spreading Factor | Arrows expanding outward | New SVG — expand arrows |
| Coding Rate | Shield with check | New SVG — shield-check |
| TX Power | Bolt/power | New SVG — bolt |
Each new icon follows the existing pattern: a function exporting a `lit-html` template with a `cls` parameter for CSS sizing, using Heroicons outline style (24×24 viewBox, `stroke="currentColor"` with `stroke-width="2"`, `stroke-linecap="round"`, `stroke-linejoin="round"`).
---
## Implementation Plan
### 1. Add Radio Parameter Icons (`icons.js`)
Add 56 new icon functions in `icons.js`. Where an adequate existing icon exists, reuse it. For the rest, create new Heroicons-style SVG icons.
Icons needed (at minimum 5 — `iconSettings` already exists for Profile):
- `iconFrequency` — waveform/sine (new)
- `iconBandwidth` — horizontal sliders (new)
- `iconSpreadingFactor` — expand arrows (new)
- `iconCodingRate` — shield-check (new)
- `iconTxPower` — bolt/lightning (new)
### 2. Rewrite `renderRadioConfig()` → `renderRadioTiles()` (`home.js`)
Replace the current `renderRadioConfig()` function (lines 1128) with a new `renderRadioTiles()` function that:
1. Maps each parameter to a tile definition: `{ icon, label, value }`
2. Filters out tiles with no value
3. Renders a CSS Grid (3 columns, responsive) of tiles
4. Each tile is a `<div>` with transparent background, rounded border, centered content
**Template structure (per tile)**:
```js
html`
<div class="flex flex-col items-center justify-center gap-1.5 p-3
border border-base-content/10 rounded-box
text-center">
<span class="radio-tile-icon w-6 h-6">
${icon('w-full h-full')}
</span>
<span class="text-xs opacity-70 leading-tight">${label}</span>
<span class="text-sm font-semibold leading-tight">${String(value)}</span>
</div>`
```
**Grid wrapper**: `grid grid-cols-2 md:grid-cols-3 gap-3`
**Panel container**: Replace `space-y-2` with the grid wrapper.
### 3. Update Icon Imports (`home.js`)
Add the new icon imports to the existing import block at line 69:
```js
import {
iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconMembers, iconMap,
iconPage, iconInfo, iconChart, iconGlobe, iconGithub,
iconSettings, iconFrequency, iconBandwidth, iconSpreadingFactor, iconCodingRate, iconTxPower,
} from '../icons.js';
```
### 4. Add Radio Tile CSS (`app.css`)
Add a new CSS rule for the radio tile icon colour:
```css
:root {
--color-radio: oklch(0.75 0.15 210); /* cyan — radio tile icons */
}
[data-theme="light"] {
--color-radio: oklch(0.55 0.15 210);
}
.radio-tile-icon {
color: var(--color-radio);
}
```
This adds a new `--color-radio` CSS custom property (cyan) with light-mode variant, following the existing palette convention in `app.css:2140`. The `.radio-tile-icon` class applies it to tile icons.
### 5. i18n Updates (`en.json`)
The existing i18n keys are reused as-is:
- `links.profile` → label "Profile"
- `home.frequency` → label "Frequency"
- `home.bandwidth` → label "Bandwidth"
- `home.spreading_factor` → label "Spreading Factor"
- `home.coding_rate` → label "Coding Rate"
- `home.tx_power` → label "TX Power"
No new i18n keys required.
---
## Files Changed — Summary
| File | Change |
|------|--------|
| `src/meshcore_hub/web/static/js/spa/icons.js` | Add 5 new radio-specific SVG icon functions (`iconFrequency`, `iconBandwidth`, `iconSpreadingFactor`, `iconCodingRate`, `iconTxPower`) |
| `src/meshcore_hub/web/static/js/spa/pages/home.js` | Replace `renderRadioConfig()` with `renderRadioTiles()`; update panel template at lines 204215 to use grid layout; add new icon imports to existing import block at lines 69 |
| `src/meshcore_hub/web/static/css/app.css` | Add `--color-radio` cyan CSS custom property (with light-mode variant) to colour palette section; add `.radio-tile-icon` class |
---
## Testing
### Visual Verification
1. Start the app with a radio config string: `NETWORK_RADIO_CONFIG="EU/UK Narrow,869.618MHz,62.5kHz,8,8,22dBm"`
2. Navigate to `/` (home page)
3. Verify:
- 6 tiles display in a 3×2 grid on desktop
- Tiles collapse to 2-column on tablet/mobile (no single-column fallback)
- Each tile has an icon (coloured), label (medium contrast), and value (high contrast)
- Tiles align properly at different viewport widths
4. Test with missing fields (e.g. `NETWORK_RADIO_CONFIG="EU/UK Narrow,,,,22dBm"`) — verify only 2 tiles render
5. Test with no radio config (empty/null) — verify the entire Network Info panel still renders gracefully (empty body or component not shown)
### Automated Tests
```bash
# Web-specific tests (render logic)
pytest tests/test_web/ -v
# Quality checks
pre-commit run --all-files
```
### Cross-Theme Testing
- Toggle between dark and light themes via the navbar theme switcher
- Verify icon colour adjusts per theme (via `var(--color-radio)` with light/dark variants in `app.css`)
- Verify label contrast and value contrast are appropriate on both themes
---
## Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| 6 tiles make panel taller than current list | The current list already shows up to 6 rows (one per param). A 3×2 grid is comparable in height. The tile format adds small icon spacing overhead but the grid layout is more space-efficient than 6 stacked rows. |
| Icons may not be immediately intuitive | Use standard metaphor: waveform for frequency, bolt for power, shield for coding rate. Labels are always present below icons. |
| New SVG icons bloat `icons.js` | Each icon function is ~57 lines. 5 new icons ≈ 30 lines. Acceptable. |
| Tile border colour may clash with theme | Use `border-base-content/10` which adapts to theme via DaisyUI. |
| Responsive layout may break at edge cases | Test at 320px, 375px, 768px, 1024px, 1440px widths. The `grid-cols-2 md:grid-cols-3` pattern keeps a minimum of 2 columns at all sizes, which works well for 6 tiles. |
@@ -0,0 +1,45 @@
# Tasks: Radio Info Tile Display
**Plan**: [plan.md](plan.md)
**Date**: 2026-05-06
---
## Implementation
- [ ] 1. **Add 5 radio icon functions to `icons.js`**
- `iconFrequency` — waveform/sine wave SVG
- `iconBandwidth` — horizontal sliders SVG
- `iconSpreadingFactor` — expanding arrows SVG
- `iconCodingRate` — shield-with-check SVG
- `iconTxPower` — bolt/lightning SVG
- Each follows existing pattern: exported function with `(cls = 'h-5 w-5')` param, Heroicons outline style (24×24 viewBox, `stroke="currentColor"`, `stroke-width="2"`)
- [ ] 2. **Replace `renderRadioConfig()` with `renderRadioTiles()` in `home.js`**
- Remove old `renderRadioConfig()` function (lines 1128)
- Add new `renderRadioTiles(rc)` that maps `RadioConfig` fields to tile definitions, filters nulls, returns a `grid grid-cols-2 md:grid-cols-3 gap-3` wrapper with tile `div`s
- Each tile: icon (`radio-tile-icon w-6 h-6`), label (`text-xs opacity-70`), value (`text-sm font-semibold`)
- Update panel container (line ~204): replace `space-y-2` with the grid wrapper
- [ ] 3. **Update icon imports in `home.js`**
- Add `iconSettings`, `iconFrequency`, `iconBandwidth`, `iconSpreadingFactor`, `iconCodingRate`, `iconTxPower` to the import block (lines 69)
- [ ] 4. **Add `--color-radio` CSS to `app.css`**
- Add `--color-radio: oklch(0.75 0.15 210)` to the existing `:root` block (dark theme)
- Add `--color-radio: oklch(0.55 0.15 210)` to the existing `[data-theme="light"]` block
- Add `.radio-tile-icon { color: var(--color-radio); }` rule
## Verification
- [ ] 5. **Run quality checks**
- `pre-commit run --all-files`
- [ ] 6. **Run web tests**
- `pytest tests/test_web/ -v`
- [ ] 7. **Manual visual check**
- Start with `NETWORK_RADIO_CONFIG="EU/UK Narrow,869.618MHz,62.5kHz,8,8,22dBm"`
- Verify 3×2 grid on desktop, 2-column on mobile
- Verify icon colour (cyan) in both dark and light themes
- Test with missing fields: `NETWORK_RADIO_CONFIG="EU/UK Narrow,,,,22dBm"` → 2 tiles
- Test with no config: Network Info panel handles empty gracefully
+6
View File
@@ -26,6 +26,7 @@
--color-map: oklch(0.8471 0.199 83.87); /* yellow (matches btn-warning) */
--color-members: oklch(0.72 0.17 50); /* orange */
--color-neutral: oklch(0.3 0.01 250); /* subtle dark grey */
--color-radio: oklch(0.75 0.15 210); /* cyan — radio tile icons */
}
/* Light mode: darker section colors for contrast on light backgrounds */
@@ -37,6 +38,7 @@
--color-map: oklch(0.58 0.16 45);
--color-members: oklch(0.55 0.18 25);
--color-neutral: oklch(0.85 0.01 250);
--color-radio: oklch(0.55 0.15 210);
}
/* ==========================================================================
@@ -112,6 +114,10 @@
background-color: color-mix(in oklch, var(--panel-color, transparent) 10%, var(--color-base-100));
}
.radio-tile-icon {
color: var(--color-radio);
}
/* ==========================================================================
Scrollbar Styling
========================================================================== */
@@ -133,3 +133,23 @@ export function iconPause(cls = 'w-4 h-4') {
export function iconPlay(cls = 'w-4 h-4') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} viewBox="0 0 20 20" fill="currentColor"><path d="M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.344-5.891a1.5 1.5 0 0 0 0-2.538L6.3 2.84Z" /></svg>`;
}
export function iconFrequency(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2 12C3.333 4 6.667 4 8 12c1.333 8 4.667 8 6 0 1.333-8 4.667-8 6 0" /></svg>`;
}
export function iconBandwidth(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" /></svg>`;
}
export function iconSpreadingFactor(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" /></svg>`;
}
export function iconCodingRate(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /></svg>`;
}
export function iconTxPower(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" /></svg>`;
}
@@ -6,25 +6,31 @@ import {
import {
iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconMembers, iconMap,
iconPage, iconInfo, iconChart, iconGlobe, iconGithub,
iconSettings, iconFrequency, iconBandwidth, iconSpreadingFactor, iconCodingRate, iconTxPower,
} from '../icons.js';
function renderRadioConfig(rc) {
function renderRadioTiles(rc) {
if (!rc) return nothing;
const fields = [
[t('links.profile'), rc.profile],
[t('home.frequency'), rc.frequency],
[t('home.bandwidth'), rc.bandwidth],
[t('home.spreading_factor'), rc.spreading_factor],
[t('home.coding_rate'), rc.coding_rate],
[t('home.tx_power'), rc.tx_power],
const tiles = [
{ icon: iconSettings, label: t('links.profile'), value: rc.profile },
{ icon: iconFrequency, label: t('home.frequency'), value: rc.frequency },
{ icon: iconBandwidth, label: t('home.bandwidth'), value: rc.bandwidth },
{ icon: iconSpreadingFactor, label: t('home.spreading_factor'), value: rc.spreading_factor },
{ icon: iconCodingRate, label: t('home.coding_rate'), value: rc.coding_rate },
{ icon: iconTxPower, label: t('home.tx_power'), value: rc.tx_power },
];
return fields
.filter(([, v]) => v)
.map(([label, value]) => html`
<div class="flex justify-between">
<span class="opacity-70">${label}:</span>
<span class="font-mono">${String(value)}</span>
</div>`);
const visible = tiles.filter(t => t.value);
if (visible.length === 0) return nothing;
return html`
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
${visible.map(({ icon, label, value }) => html`
<div class="flex flex-col items-center justify-center gap-1.5 p-3
border border-base-content/10 rounded-box text-center">
<span class="radio-tile-icon w-6 h-6">${icon('w-full h-full')}</span>
<span class="text-xs opacity-70 leading-tight">${label}</span>
<span class="text-sm font-semibold leading-tight">${String(value)}</span>
</div>`)}
</div>`;
}
function renderNavCard({ href, icon, label, colorVar }) {
@@ -208,8 +214,8 @@ export async function render(container, params, router) {
${iconInfo('h-6 w-6')}
${t('home.network_info')}
</h2>
<div class="space-y-2">
${renderRadioConfig(rc)}
<div class="mt-2">
${renderRadioTiles(rc)}
</div>
</div>
</div>