# MeshCore Analytics — Frontend UI/UX Audit
**Date:** 2026-08-01
**Branch audited:** `fix/repeater-tree-modal` (working tree, uncommitted changes included)
**Scope:** `frontend/src/**` (57 `.tsx`, 63 `.ts`, 12 `.css`), `frontend/index.html`, `backend/src/backend-site/login.html`
**Type:** Read-only static audit. **No fixes were applied.**
> **Resolution update — 2026-08-01:** The audit itself was read-only, but the
> follow-up implementation has now resolved UI-01 through UI-28 in the current
> working tree. Verification includes 38 frontend unit tests, 27 desktop/axe
> browser tests, 21 responsive/mobile browser tests, production builds, the CSS
> structure gate, and the asset-size budget. The browser suite covers every
> public route, keyboard/focus flows, touch-only map controls, and modal geometry
> from 320px through 1440px. The source gates report no undefined CSS custom
> properties, no text below the 10px floor, no duplicate selectors, no missing
> button `type`, no missing table-header `scope`, and no diff whitespace errors.
---
## 0. How to use this document
Each finding has a stable ID (`UI-01`…`UI-28`), a severity, exact file/line anchors, the *measured* evidence, the user-visible impact, and a concrete fix with acceptance criteria. Findings are independent unless a **Depends on** line says otherwise.
**Recommended order of work:** §7 has a sequenced plan. Do not work top-to-bottom by ID — several P1 items share a root cause and are cheaper to fix together.
**Before you start:** read §6 ("Verified correct — do not re-investigate"). Several plausible-looking problems were checked and are *not* bugs. Re-chasing them wastes time and risks regressions.
---
## 1. Method
1. **Full read** of `tokens.css`, `globals.css`, `map-app.css`, `site-shell.css`, `site-content.css`, `network-intelligence.css`, `path-modal.css`, plus targeted reads of `feed-page.css`, `spam-page.css`, `stats-page.css`, `docs-pages.css`, `owner-portal.css`.
2. **Undefined CSS custom property scan** — parsed every `--x:` definition and every `var(--x)` usage across all CSS/TSX, flagged usages with no definition and no fallback.
3. **WCAG 2.1 contrast computation** — a scripted sRGB relative-luminance calculator (script in §8) was run on every foreground/background pair discovered, including alpha compositing for `rgba()` and `opacity`.
4. **Typography scan** — every `font-size` and `font` shorthand across all CSS, normalised px/rem, flagged `< 11px`.
5. **Cascade verification** — checked the *built* CSS in `frontend/dist/assets/` and Vite's preload manifest to confirm real-world stylesheet load order, because specificity ties are resolved by load order here.
6. **Dead-code cross-check** — every class named in a finding was checked against all TSX/HTML to confirm it actually renders. Findings on dead classes are demoted to P3 and labelled.
7. **Structured a11y scan** — form-control labelling, `
`, `role=` correctness, icon-only buttons, ``, focus indicators, touch-target sizes.
Scripts used are reproduced in §8 so results can be regenerated.
---
## 2. Severity definitions
| Sev | Meaning |
|---|---|
| **P0** | Content is unreadable, invisible, or unreachable. Blocks the user. |
| **P1** | Serious degradation — feature unusable for a class of users (keyboard, touch, low vision), or visibly broken layout. |
| **P2** | Clear defect with a workaround; fails a WCAG AA criterion; noticeably wrong visual. |
| **P3** | Polish, consistency, dead code, latent risk. |
---
## 3. Summary
| ID | Sev | Area | One-line |
|---|---|---|---|
| UI-01 | P0 | Map | Path-segment popover text is dark-on-dark at **1.13:1** — effectively invisible |
| UI-02 | P0 | Feed modal | Repeater-tree modal is 960px inside a hard-coded 760px shell — **clipped off-screen at 810–1160px viewports** |
| UI-03 | P0 | Map | Light basemap mode: 9 of 13 marker colours below 3:1; selected node at **1.13:1** |
| UI-04 | P1 | Repeater search | `--bg-tertiary` undefined → **no hover/keyboard-focus highlight** in the search dropdown |
| UI-05 | P1 | Focus | Global focus ring uses `:where()` (specificity 0) and is defeated by 8 `outline:none` rules; range slider has **no** focus indicator |
| UI-06 | P1 | Map tools | LOS + Plan-repeater tools `display:none` on **all** coarse-pointer devices, no alternative entry point |
| UI-07 | P1 | Owner portal | `--border-soft` undefined → telemetry chart/stat cards render with **no border at all** |
| UI-08 | P1 | Typography | **79** declarations below 11px, down to **7px**; worst is 7px uppercase mono |
| UI-09 | P1 | Packet feed | `.packet-item__pin` is 7px text animating down to **1.92:1** |
| UI-10 | P2 | Topology | `role="img"` on the SVG hides its own focusable nodes from screen readers |
| UI-11 | P2 | Topology | RF-validation table has **no header row** (neither visual nor ARIA) |
| UI-12 | P2 | Tables | All 5 data tables: `` missing `scope` |
| UI-13 | P2 | Buttons | 2 close buttons have no accessible name (bare `✕`) |
| UI-14 | P2 | Feed | Packet search input is placeholder-only — no accessible name |
| UI-15 | P2 | Map | Link quality is red/amber/green **colour-only** (width differs by 0.4px) |
| UI-16 | P2 | Status | `--offline` (`#546e7a`) used as status text at **3.40:1** |
| UI-17 | P2 | Touch | Topbar info button is 22px wide; packet watch button 20px — below the 24px floor |
| UI-18 | P2 | Nav | `.site-nav__badge` white-on-red at **3.76:1** |
| UI-19 | P3 | Repeater page | `--bg-secondary` undefined → 5 live surfaces lose their background |
| UI-20 | P3 | Buttons | 13 `` without `type` |
| UI-21 | P3 | Dead CSS | ~38 orphaned class rules (`dev-monitor__*`, `dev-telemetry*`, `dev-status-*`, 4 others) |
| UI-22 | P3 | Tokens | `--accent-rgb` fallback is green while `--accent` is cyan |
| UI-23 | P3 | Site | `.site-home__card` declares background/border/radius twice |
| UI-24 | P3 | Tokens | `--map-label-color` / `--map-link-color` / `--color-gold` / `--color-purple` / `--font-sans` never consumed |
| UI-25 | P3 | Backend login | Input boundary contrast 1.06–1.82:1; errors use `role="status"` not `alert` |
| UI-26 | P3 | Nav | Mobile nav styled twice (globals.css + site-shell.css) and reconciled with an `html ` specificity hack |
| UI-27 | P3 | Map | `circle-stroke-width: 0` with live `stroke-color`/`stroke-opacity` — dead paint |
| UI-28 | P3 | Map | `.map-annotation` squeezed to ~140px between 641–800px viewports |
---
## 4. P0 findings
### UI-01 — Path-segment evidence popover is invisible (1.13:1)
**Files**
- `frontend/src/styles/map-app.css:150–156` (the popover styles)
- `frontend/src/styles/map-app.css:914–921` (`.maplibregl-popup-content`)
- `frontend/src/components/Map/DeckGLOverlay.tsx:484–517` (constructs it)
**What's wrong**
`DeckGLOverlay.tsx:488` builds a `div.path-explanation-popover` and injects it into a MapLibre popup via `setDOMContent`. The popup shell is forced dark:
```css
/* map-app.css:914 */
.maplibregl-popup-content {
background: var(--bg-panel) !important; /* #0d1520 */
color: var(--text-primary) !important;
}
```
But the popover overrides the inherited colour with a **light-theme slate palette**:
```css
/* map-app.css:150–156 */
.path-explanation-popover { min-width: 220px; color: #172033; … }
.path-explanation-popover dt { color: #64748b; }
.path-explanation-popover p { margin: 7px 0 0; color: #475569; font-size: 11px; }
```
`` (title) and `` (all four values) have no colour of their own, so they inherit `#172033`.
**Measured**
| Element | Colour | On `#0d1520` | WCAG AA (4.5:1) |
|---|---|---|---|
| Title `` + all `` values | `#172033` | **1.13:1** | ✗ catastrophic |
| Labels ` ` | `#64748b` | 3.85:1 | ✗ |
| Summary `` | `#475569` | 2.42:1 | ✗ |
**Impact** Clicking a beta path segment on the map opens a popup whose entire payload — Confidence, Observers, Evidence, Top alternatives, and the summary — is unreadable. Only the four `
` labels are faintly visible. This is a total content failure, not a contrast nit.
**Fix** Replace the three hard-coded slate values with design tokens in `map-app.css:150–156`:
```css
.path-explanation-popover { min-width: 220px; color: var(--text-primary); font-family: var(--font-body); }
.path-explanation-popover > strong { display: block; margin-bottom: 7px; font-size: 13px; color: var(--text-primary); }
.path-explanation-popover dt { color: var(--text-secondary); }
.path-explanation-popover dd { margin: 0; font-weight: 600; color: var(--text-primary); }
.path-explanation-popover p { margin: 7px 0 0; color: var(--text-secondary); font-size: 11px; }
```
Note `#64748b` → `var(--text-secondary)` (`#8aa6c4`, 7.28:1) and `#475569` → `var(--text-secondary)`; do **not** use `--text-muted` for the 11px summary (6.17:1 is fine but `--text-secondary` is the established pairing for popup body text elsewhere in this file).
**Acceptance** Every string in the popover ≥ 4.5:1 against `#0d1520`. Verify with the script in §8.
**Repro** Open the app map, enable a beta path overlay, click a path segment.
---
### UI-02 — Repeater-tree modal overflows its shell and is clipped off-screen
**Files**
- `frontend/src/components/ui/Dialog.tsx:46` — `` (hard-coded, not overridable)
- `frontend/src/styles/globals.css:2086–2089` — `.ui-dialog-modal { width: min(760px, 100%); … }`
- `frontend/src/pages/ukmesh/feed-page.css:406–409` — `.uk-feed-path-modal { width: min(960px, calc(100vw - 32px)); … }`
- `frontend/src/pages/path-modal.css:7–20` — `.stats-page__path-modal { width: min(960px, 100%); … }`
- `frontend/src/pages/ukmesh/FeedDialogs.tsx:39–45` — the dialog that combines them
**What's wrong**
`Dialog.tsx` always renders ``. The `className` prop styles the **innermost** element, but the **middle** element is permanently capped at `760px`. The repeater-tree dialog asks for `960px`, which its containing block cannot provide, so the dialog overflows the modal box to the right.
Cascade was verified against the build: `dist/assets/path-modal-*.css` is preloaded **before** `dist/assets/UKFeedPage-*.css` (confirmed in the Vite preload manifest inside `dist/assets/index-*.js`), and both selectors are specificity `(0,1,0)`, so **`.uk-feed-path-modal` wins** → the dialog computes to `min(960px, 100vw - 32px)` inside a `760px` parent.
**Measured geometry** (overlay has `padding: 16px`, `justify-content: center`)
```
modalLeft = 16 + (100vw - 32 - 760) / 2
dialogW = min(960, 100vw - 32)
dialogRight = modalLeft + dialogW
```
| Viewport | Modal | Dialog | Dialog right edge | Result |
|---|---|---|---|---|
| 792px | 760px | 760px | 776px | fits |
| 850px | 760px | 818px | 863px | **13px clipped** |
| 900px | 760px | 868px | 938px | **38px clipped** |
| 1024px | 760px | 960px | 1092px | **68px clipped** |
| 1160px | 760px | 960px | 1160px | boundary |
| 1440px | 760px | 960px | 1300px | fits but **100px off-centre** |
Because `ModalOverlay` is `position: fixed`, the overflow does **not** create a document scrollbar — the right-hand strip is simply unreachable.
**Impact** On tablets and small laptops (~810–1160px, a very common range) the right edge of the Predicted Repeater Tree modal — including part of the map and the right side of the tree rows — is cut off with no way to scroll to it. Above 1160px the modal is visibly off-centre by 100px. This is the same class of defect the branch name suggests is being chased.
**Fix — pick one, (A) preferred**
**(A) Make the modal shell width-neutral.** In `globals.css:2086`:
```css
.ui-dialog-modal {
width: auto; /* was: min(760px, 100%) */
max-width: calc(100vw - 32px);
max-height: calc(100dvh - 32px);
}
```
Then give the *default* dialog its own width so nothing else regresses — add to `globals.css` near `.ui-dialog` (line 2091):
```css
.ui-dialog { width: min(760px, calc(100vw - 32px)); }
```
Audit every current `Dialog` consumer afterwards (`FeedDialogs.tsx`, `StatsDecodedPathDialog.tsx`, `App.tsx:656`) to confirm none relied on the 760px cap.
**(B) Add a size prop to `Dialog`.** Extend `DialogProps` with `modalClassName?: string`, pass it through to ``, and give the path modal a `uk-feed-path-modal-shell` class with `width: min(960px, calc(100vw - 32px))`. Lower blast radius, more code.
**Also fix regardless of option:** `.stats-page__path-modal` (`path-modal.css:11`) and `.uk-feed-path-modal` (`feed-page.css:407`) both set `width` on the same element with equal specificity. Delete the `width` and `max-height` from one of them so the winner is not decided by chunk load order. Keep the value on `.uk-feed-path-modal` and reduce `path-modal.css` to the shared visual styling only.
**Acceptance** At 850, 900, 1024, 1280 and 1440px the modal is fully visible, horizontally centred, and its right edge is ≥ 16px inside the viewport. At 375px it still fits with the `≤640px` overrides.
---
### UI-03 — Light basemap mode makes most map markers invisible
**Files**
- `frontend/src/components/Map/mapSourceLayers.ts:22–47` (node dots), `:64` (selected halo), `:79–83` (selected marker), `:127–132`, `:165–170`, `:251–263`
- `frontend/src/components/Map/geojsonBuilders.ts:299–304`, `:345–350` (link colours)
- `frontend/src/components/Map/mapConfig.ts:183–207` (`MAP_STYLE_LIGHT`, `background-color: #edf2f7`)
- `frontend/src/App.tsx:121, 257` (`map-theme` toggle)
**What's wrong**
`mapConfig.ts` correctly themes the basemap raster **and the vector label colours** (`MAP_LABEL_COLORS.light/dark`). But every overlay drawn on top — node dots, selection rings, links, coverage fills — uses a single hard-coded palette tuned for the dark basemap. `MapLibreMap.tsx:217–263` swaps the raster source and label colours on theme change and nothing else.
Making this worse, the main node layer has `'circle-stroke-width': 0` (`mapSourceLayers.ts:45`), so there is no outline to rescue a low-contrast fill.
**Measured** — WCAG 1.4.11 non-text contrast requires **≥ 3:1**. Measured against each theme's `background-color` (`#080d14` dark, `#edf2f7` light):
| Marker | Colour | Dark | Light |
|---|---|---|---|
| Repeater (default) | `#00c4ff` | 9.59 ✓ | **1.80 ✗** |
| ChatNode (role 1) | `#ff9f43` | 9.55 ✓ | **1.81 ✗** |
| RoomServer (role 3) | `#a78bfa` | 7.16 ✓ | **2.42 ✗** |
| Sensor (role 4) | `#34d399` | 10.13 ✓ | **1.71 ✗** |
| Inferred | `#7dd3fc` | 11.68 ✓ | **1.48 ✗** |
| Replay active | `#fbbf24` | 11.67 ✓ | **1.48 ✗** |
| Hex-clash relay | `#22c55e` | 8.55 ✓ | **2.02 ✗** |
| **Selected node** | `#8af4ff` | 15.27 ✓ | **1.13 ✗** |
| **Selected stroke** | `#ffffff` | 19.48 ✓ | **1.13 ✗** |
| Selected halo | `#22e0ff` | 12.22 ✓ | **1.42 ✗** |
| Stale / offline | `#6b7280` | 4.03 ✓ | 4.29 ✓ |
| Link-only stale | `#4b5563` | **2.58 ✗** | 6.71 ✓ |
| Hex-clash offender | `#ef4444` | 5.18 ✓ | 3.34 ✓ |
Link colours (`geojsonBuilders.ts`) fail identically in light mode: `#22c55e` 2.02, `#fbbf24` 1.48, `#d1d5db` ≈1.2.
**Impact** Turning on the light basemap makes the map largely unusable: online repeaters, chat nodes, sensors, inferred nodes and all link lines wash out, and **the currently selected node is the single least visible element on screen (1.13:1)** — the user cannot see what they clicked. Note also `#4b5563` fails in *dark* mode (2.58:1).
**Fix** Introduce a theme-aware overlay palette next to `MAP_LABEL_COLORS` in `mapConfig.ts`:
```ts
export const MAP_OVERLAY_COLORS = {
dark: { repeater: '#00c4ff', companion: '#ff9f43', roomServer: '#a78bfa', sensor: '#34d399',
inferred: '#7dd3fc', replay: '#fbbf24', stale: '#6b7280', linkOnlyStale: '#7b8794',
clashRelay: '#22c55e', clashOffender: '#ef4444',
selected: '#8af4ff', selectedStroke: '#ffffff', selectedHalo: '#22e0ff' },
light: { /* darkened equivalents, each verified ≥ 3:1 on #edf2f7 */ },
} as const;
```
Then:
1. Thread `mapLight` into `mapSourceLayers.ts` and use `MAP_OVERLAY_COLORS[theme]` for every `circle-color`, `fill-color`, `line-color` literal.
2. Thread the theme into `geojsonBuilders.ts` (`buildLinksGeoJSON`, and the predicted-links builder at :299) so link colours are chosen per theme.
3. Add a contrasting outline to the node layer instead of `stroke-width: 0`: `'circle-stroke-width': 1`, `'circle-stroke-color'` = `#080d14` in dark / `#ffffff` in light. This alone lifts every marker over 3:1 and is the cheapest partial fix if the full palette work is deferred.
4. Fix `#4b5563` → a lighter grey for dark mode (it fails there today).
5. `MapLibreMap.tsx:217–263` already re-applies style on `mapLight` change — extend that effect to call `map.setPaintProperty` for the overlay layers, or rebuild the layer set.
**Acceptance** Every marker/link colour ≥ 3:1 against its theme's background, in both themes. Selected node clearly distinguishable in light mode.
**Note** `SpamTransparencyPage.tsx:177,488` and `OwnerMapView.tsx:111` use `MAP_STYLE` (dark) only, so they are unaffected — do not change them.
---
## 5. P1–P3 findings
### UI-04 — P1 — Repeater search dropdown has no hover or keyboard-focus highlight
**File** `frontend/src/styles/globals.css:775–778`
```css
.repeater-search-box__result:hover,
.repeater-search-box__result[data-focused] {
background: var(--bg-tertiary); /* NEVER DEFINED ANYWHERE */
}
```
`--bg-tertiary` is not defined in any stylesheet. Per the CSS Custom Properties spec, a `var()` referencing an undefined property with no fallback makes the declaration *invalid at computed-value time*; `background` is a shorthand, so **all** background longhands reset to their initial values → `background-color: transparent`. The rule does nothing.
`[data-focused]` is the attribute react-aria-components sets on the active `ListBoxItem` (`Combobox.tsx:105–108` passes `optionClassName` straight to `ListBoxItem`). So arrow-keying through the repeater search results produces **zero visual feedback** — the user cannot tell which option Enter will select. Mouse hover is equally dead.
**Impact** Keyboard operation of the primary search on `/repeater` is effectively broken (WCAG 2.4.7 Focus Visible).
**Fix** Use a defined token. In `globals.css:777`:
```css
.repeater-search-box__result:hover,
.repeater-search-box__result[data-focused] { background: var(--bg-hover); }
```
`--bg-hover` (`#162030`) is the established hover surface and is what the equivalent map search uses (`map-app.css:115`). Consider also adding `color: var(--accent);` to match `.node-search__result[data-focused]`.
**Acceptance** Arrow keys visibly move a highlight through the dropdown; hover highlights too.
---
### UI-05 — P1 — Global focus ring is defeated by `outline: none` in 8 places
**Files**
- Rule: `frontend/src/styles/globals.css:2124–2127`
- Overrides: `globals.css:726`, `globals.css:1281`, `globals.css:1740`, `map-app.css:80`, `owner-portal.css:24`, `feed-page.css:839`, `docs-pages.css:247`, `network-intelligence.css:40`
**What's wrong** The app defines one global focus indicator:
```css
/* globals.css:2124 */
:where(a, button, input, select, textarea, [tabindex]):focus-visible,
[data-focus-visible] { outline: 3px solid var(--accent); outline-offset: 2px; }
```
`:where()` contributes **zero** specificity, so this compound weighs only `(0,1,0)` — the same as a single class. Every component rule that sets `outline: none` therefore wins, and page CSS chunks load *after* `index-*.css` (verified in `dist/`), so even ties go to the component.
| Override | Replacement indicator | Verdict |
|---|---|---|
| `globals.css:726` `.repeater-search-box__input:focus` | `border-color: var(--accent)` | weak (1px) but present |
| `globals.css:1281` `.regions-search__input:focus` | `border-color: var(--accent)` | weak but present |
| **`globals.css:1740` `.regions-rel__neighbour-range`** | **none** | **✗ no indicator at all** |
| `map-app.css:80` `.node-search__input` | `:focus { border-color }` | weak but present |
| `owner-portal.css:24` `.owner-login__input:focus` | `border-color: var(--accent)` | weak but present |
| `feed-page.css:839` `.uk-feed-search` | `:focus { border-color }` | weak but present |
| **`docs-pages.css:247` `.health-card--interactive:focus-visible`** | shares hover styling; `border-color: var(--border-bright)` (rgba .25) | **✗ near-invisible, indistinguishable from hover** |
| **`network-intelligence.css:40` `.topology-page__node:focus`** | fill change identical to `:hover` | **✗ weak, and see UI-10** |
The range slider (`globals.css:1733–1742`) is ` ` — fully keyboard-operable with arrow keys — and has **no** focus indicator whatsoever.
**Impact** Keyboard users lose or barely retain the focus indicator on the login field, both search fields, the neighbour-range slider, the health cards and the topology nodes. WCAG 2.4.7 (A) failure on at least the three bolded rows.
**Fix**
1. **Raise the global rule above component rules.** In `globals.css:2124`, drop `:where()` so the selector carries real weight, and add `!important` only if needed after re-testing:
```css
a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible,
textarea:focus-visible, [tabindex]:focus-visible, [data-focus-visible] {
outline: 3px solid var(--accent);
outline-offset: 2px;
}
```
This makes it `(0,1,1)`, beating bare-class rules.
2. **Delete the eight `outline: none` declarations** listed above. Keep the `border-color` transitions — they are a nice *additional* affordance, just not a substitute.
3. For `.regions-rel__neighbour-range` specifically, if a ring on the track looks wrong, style the thumb instead:
```css
.regions-rel__neighbour-range:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px var(--accent-glow); }
.regions-rel__neighbour-range:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px var(--accent-glow); }
```
4. For `.health-card--interactive`, split hover and focus so they are not the same rule; let the global ring apply on focus.
5. For `.topology-page__node`, see **UI-10** — fix together.
**Acceptance** Tab through `/`, `/repeater`, `/feed`, `/topology`, `/docs`, `/login` and the app map: every stop shows a 3px cyan ring or an equally prominent, focus-only indicator.
---
### UI-06 — P1 — LOS and Plan-repeater tools are unreachable on every touch device
**Files**
- `frontend/src/styles/map-app.css:1673–1677`
- `frontend/src/components/Map/MapLibreMap.tsx:1282–1310`
- `frontend/src/components/app/MobileControls.tsx` (whole file — no equivalent)
```css
/* map-app.css:1673 */
@media (max-width: 640px), (pointer: coarse) {
.map-tools { display: none !important; }
}
```
`.map-tools` holds the two map tool buttons: **LOS** (custom line-of-sight, `MapLibreMap.tsx:1283–1292`) and **Plan repeater** (`:1294–1310`, gated on `viewshedEnabled`). `MobileControls.tsx` renders map modes, the layer filter grid, node search and the watchlist — but has no LOS or planner entry, and no other component references `customLosMode` / `planRepeaterMode`.
**Impact** `(pointer: coarse)` matches **any** touch-primary device, including 12.9" tablets and touchscreen laptops at 1400px+, not just phones. On all of them two whole features silently disappear with no alternative path. This is functionality loss, not just a layout adaptation.
**Fix — choose based on product intent**
- **If the tools should work on touch:** drop `(pointer: coarse)` from the media query so only `max-width: 640px` hides them, and add both buttons to `MobileControls.tsx` for the phone case. The buttons already carry text labels ("LOS") so they only need `min-height: 44px` and adequate padding — the global coarse-pointer rule (`globals.css:2130`) supplies the height.
- **If they are genuinely desktop-only:** keep the query but surface *why*. Add a disabled/explanatory affordance in `MobileControls.tsx` (e.g. a note in the Layers section: "Line-of-sight and repeater planning require a mouse"). Silent removal is the actual defect.
**Acceptance** On an iPad-class viewport, either the tools are usable, or the UI explains their absence.
---
### UI-07 — P1 — Owner-portal telemetry cards render with no border
**Files** `frontend/src/pages/owner-portal.css:205`, `:218`
```css
.owner-telemetry-metric__chart { … border: 1px solid var(--border-soft); … }
.owner-telemetry-metric__stat { … border: 1px solid var(--border-soft); … }
```
`--border-soft` is **never defined**. As in UI-04, the `border` shorthand is invalid at computed-value time, so all three longhands reset — critically `border-style` → `none`. **The border does not render at all** (it is not merely the wrong colour).
Both classes are live — confirmed used by `frontend/src/pages/owner/OwnerPortalCharts.tsx`.
Their only remaining separation from the panel is `background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01))` — a 1–3% white wash, well under the 3:1 required for a UI component boundary (WCAG 1.4.11).
**Impact** In the owner portal, telemetry chart tiles and stat tiles have no visible edge and bleed into the panel background; the grid reads as one undifferentiated block.
**Fix** Either define the token once in `globals.css:26` (`:root`) —
```css
--border-soft: rgba(32, 80, 140, 0.22);
```
— or, simpler and consistent with the rest of the file, replace both usages with the existing `var(--border)`.
**Note** The other 9 `--border-soft` usages in `site-content.css` sit on **dead classes** (see UI-21) and have no user impact. Fix them only as part of the dead-code cleanup.
**Acceptance** Owner portal telemetry tiles have a visible 1px edge.
---
### UI-08 — P1 — 79 declarations render text below 11px, down to 7px
**Scan result** 79 `font-size`/`font` declarations across all CSS compute to `< 11px`. Body text is 13px (`globals.css:84`). Worst offenders:
| Size | File:line | Selector | Notes |
|---|---|---|---|
| **7px** | `map-app.css:629` | `.watchlist-panel li small` | + `text-transform: uppercase`, `--font-mono` |
| **7px** | `map-app.css:1218` | `.packet-item__pin` | see **UI-09** |
| **8px** | `map-app.css:662` | `.planner-comparison__summary span` | metric labels |
| 9px ×14 | `map-app.css:68,454,590,593,598,656,667,671,967,1554`; `network-intelligence.css:66,69`; `spam-page.css:233,287,438` | timeline meta, planner labels, validation stats, **`.sm-timeline th`** | `.sm-timeline th` is a public-page table header |
| 10px ×60 | across all files | — | pervasive |
`--font-mono` is `'Share Tech Mono'`, a narrow face with a small x-height, so 7–9px mono renders smaller than 7–9px Inter would. Uppercase + `letter-spacing` at these sizes compounds the problem.
**Impact** Genuinely hard to read at normal viewing distance, and unreadable for many users with low vision. None of it is decorative — `.watchlist-panel li small` carries the watch category, `.planner-comparison__summary span` labels the planner metrics, `.sm-timeline th` labels a public data table.
**Fix — staged, do not do a blanket find/replace**
1. **Floor at 10px, immediately:** raise every `< 10px` declaration to `10px`. That is 18 declarations (all the 7px, 8px and 9px rows above). Low risk — these are in fixed-width panels with room.
2. **Then raise the floor to 11px** for anything that is a *label the user must read* rather than a dense-grid affordance: `.watchlist-panel li small`, `.planner-comparison__summary span`, `.planner-comparison li strong`, `.sm-timeline th`, `.topology-page__validation-stats span`, `.topology-page__validation-table [role='row']`, `.status-page__checks strong`, `.status-page__privacy`.
3. **Check for reflow after each step.** The at-risk containers are `.planner-comparison` (290px fixed, `map-app.css:643`), `.filter-panel` (200px fixed, `map-app.css:159`), `.node-legend` (max 190px, `map-app.css:1462`) and `.packet-item` (see UI-09 note on its 9-column grid). Widen those panels rather than reverting the font sizes.
4. Add a lint guard: extend `frontend/scripts/check-css-structure.mjs` to fail on any computed `font-size < 10px`.
**Acceptance** No CSS declaration under 10px; the eight listed label selectors at ≥ 11px; no clipped panel content at 1280×800.
---
### UI-09 — P1 — Pinned-packet marker is 7px text pulsing to 1.92:1
**File** `frontend/src/styles/map-app.css:1213–1227`
```css
.packet-item__pin { … font-size: 7px; color: var(--accent); opacity: 0.7;
animation: pin-pulse 2s ease-in-out infinite; }
@keyframes pin-pulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 0.3; } }
```
**Measured** against the pinned row background (`rgba(0,196,255,0.08)` over `rgba(8,16,27,0.97)` ≈ `#071e2d`):
| Animation phase | Effective colour | Contrast |
|---|---|---|
| Peak (`opacity: 0.7`) | ≈ `#0292c0` | 4.77:1 — marginal at 7px |
| **Trough (`opacity: 0.3`)** | ≈ `#05506c` | **1.92:1 — invisible** |
**Impact** The pin indicator is the smallest text in the app *and* spends half of every 2s cycle below the visibility threshold. It also runs indefinitely; `prefers-reduced-motion` is honoured globally (`globals.css:2136–2146` forces `animation-duration: 0.01ms`), which covers WCAG 2.2.2 for users who set that preference but not for anyone else.
**Fix**
```css
.packet-item__pin { font-size: 10px; color: var(--accent); opacity: 1; }
```
Drop the `pin-pulse` animation and the `@keyframes` block entirely (also remove `.packet-item__pin` from the `prefers-reduced-motion` list at `map-app.css:1378`). If a "recently pinned" cue is wanted, use a one-shot 0.4s flash rather than an infinite loop.
**Watch out:** `.packet-item` is a 9-column grid (`map-app.css:1174`) whose fixed columns total 302px including gaps. `.packet-feed` is `min(420px, 100vw - 32px)` on desktop but `calc(100% - 32px)` with `max-height: 180px` on mobile (`map-app.css:1619`). At a 360px viewport the content box is ~292px — already 10px short of the fixed columns, so the flexible summary column collapses to 0 and `overflow-x: hidden` clips the right-hand cells. Raising the pin to 10px slightly worsens this. **Fix the grid at the same time**: on `≤640px`, reduce `.packet-item` to the columns that matter (time, type, summary, hops) and hide the rest, rather than relying on clipping.
**Acceptance** Pin indicator legible and static; packet rows show all their cells at 360px.
---
### UI-10 — P2 — `role="img"` hides the topology graph's own focusable nodes
**File** `frontend/src/pages/TopologyPage.tsx:228` and `:255–284`
```tsx
…
…
```
`role="img"` makes the SVG a single leaf node in the accessibility tree — **all descendants are removed from it**. The `role="button" tabIndex={0}` circles remain keyboard-focusable but are no longer exposed to assistive tech.
**Impact** A screen-reader user tabs into the graph and lands on N focus stops that announce nothing, with no way to know what they are or that Enter/Space selects a repeater. Combined with **UI-05** (`network-intelligence.css:40` `outline: none` on `:focus`), a sighted keyboard user also gets only a subtle fill change.
**Fix**
1. Change `role="img"` → `role="group"` (or drop the role and keep `aria-label`) on `TopologyPage.tsx:228` so descendants stay in the a11y tree.
2. Move the graph-level summary into a visually-hidden live description so the count is still announced:
```tsx
{plot.nodes.length} positioned repeaters and {plot.links.length} links
```
(`.ui-visually-hidden` already exists at `globals.css:2064`.)
3. Remove `outline: none` from `network-intelligence.css:40` and split `:focus` out of the `:hover, :focus, --active` group so focus has a distinct look. SVG `outline` support is patchy — prefer an explicit ring:
```css
.topology-page__node:focus-visible { stroke: #fff; stroke-width: 3.5; }
```
**Acceptance** VoiceOver/NVDA announces each focused repeater's name and link count; keyboard focus is visually distinct from hover.
---
### UI-11 — P2 — RF-validation table has no header row
**File** `frontend/src/pages/TopologyPage.tsx:339–347`; CSS `frontend/src/pages/network-intelligence.css:67–69`
```tsx
{rfValidation.mismatches.slice(0, 20).map((link) => (
… {/* link pair */}
… {/* observations */}
… {/* path loss dB */}
… {/* classification */}
```
There is no header row — neither a `role="row"` of `role="columnheader"` cells, nor any visual heading. The CSS confirms it: `.topology-page__validation-table` only styles `[role='row']` and `strong`.
**Impact** Four unlabelled columns. A sighted user sees `Node A ↔ Node B | 42 obs · 8 strong | 131.2 dB | model mismatch` and must infer every column. A screen-reader user navigating the ARIA table gets no column context at all. The ARIA table role actively *promises* headers that do not exist.
**Fix** Add a header row as the first child, and style it:
```tsx
Link
Observations
Modelled path loss
Classification
…
```
```css
/* network-intelligence.css, next to :68 */
.topology-page__validation-table-head {
color: var(--text-muted);
font: 10px var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.06em;
}
```
The existing `[role='row']` grid rule already supplies the 4-column template, so the header aligns automatically. Bump the row font-size per **UI-08** while here.
---
### UI-12 — P2 — No `
` in the codebase has `scope`
**Files**
- `frontend/src/pages/SpamTransparencyPage.tsx:363`
- `frontend/src/pages/StatusPage.tsx:232`
- `frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx:601–605`
- `frontend/src/pages/ukmesh/PacketDetailPanel.tsx:137`, `:329`, `:393`
All 6 `` header rows use bare ``. Without `scope="col"`, screen readers must guess the header/data association; in multi-header tables they frequently get it wrong.
**Fix** Add `scope="col"` to every ` ` in a `` row. Example for `PacketDetailPanel.tsx:137`:
```tsx
Bits Field Value Binary
```
Purely additive, no visual change.
---
### UI-13 — P2 — Two close buttons have no accessible name
**Files**
- `frontend/src/pages/ukmesh/PacketDetailPanel.tsx:272` — `✕ `
- `frontend/src/pages/ukmesh/UKFeedPage.tsx:680` — `✕ `
Both are labelled only by the bare glyph `✕` (U+2715). Screen readers announce it as "multiplication X", "times", or nothing depending on the AT and its punctuation settings.
Every other close control in the codebase is done correctly — `App.tsx:656` (`aria-label="Close"`), `MapLibreMap.tsx:1393` (`aria-label="Close node details"`), `WatchlistPanel.tsx:54`, `NodeSearch.tsx:172`, `Combobox.tsx:91`. These two are the outliers.
**Fix**
```tsx
/* PacketDetailPanel.tsx:272 */
✕
/* UKFeedPage.tsx:680 */
setSelectedPacketHash(null)} aria-label="Clear selected packet">✕
```
(The `type="button"` on the second also resolves one instance of **UI-20**.)
---
### UI-14 — P2 — Feed packet search has no accessible name
**File** `frontend/src/pages/ukmesh/UKFeedPage.tsx:530–536`
```tsx
```
No ``, no `aria-label`, no `id`/`htmlFor`. Placeholder text is not an accessible name (WCAG 4.1.2), and it disappears the moment the user types, removing the only cue about what the field filters.
This was the **only** unlabelled control found — every other input in the app either has an `aria-label` or is wrapped in a `` (verified across all TSX).
**Fix**
```tsx
setSearchQuery(e.target.value)}
/>
```
---
### UI-15 — P2 — Link quality is encoded by colour alone
**Files** `frontend/src/components/Map/geojsonBuilders.ts:299–316` and `:345–352`; thresholds in `mapConfig.ts:12–13`
```ts
const color = pathLoss == null ? '#a78bfa'
: pathLoss <= LINK_GREEN_THRESHOLD_DB ? '#22c55e' // good
: pathLoss <= LINK_AMBER_THRESHOLD_DB ? '#fbbf24' // marginal
: '#ef4444'; // poor
width: pathLoss == null ? 1.6 : pathLoss <= …GREEN… ? 2.4 : pathLoss <= …AMBER… ? 2.0 : 1.6,
```
There *is* a width variation, but green→amber→red spans only **2.4px → 2.0px → 1.6px**. A 0.4px step on an anti-aliased map line is not a perceivable redundant channel. In practice the encoding is red/amber/green only — the exact pairing that deuteranopia and protanopia collapse (~8% of men).
**Impact** Users with red-green CVD cannot distinguish good from poor links, which is the core signal of the links overlay. WCAG 1.4.1 (Use of Color, level A).
**Fix — add a second channel.** Cheapest option that needs no legend change:
```ts
// in the link feature properties
dash: pathLoss == null ? [2, 2] : pathLoss <= GREEN ? [1, 0] : pathLoss <= AMBER ? [6, 3] : [2, 3],
width: pathLoss == null ? 1.6 : pathLoss <= GREEN ? 3.0 : pathLoss <= AMBER ? 2.0 : 1.4,
```
and in `mapSourceLayers.ts:112–115` add `'line-dasharray': ['get', 'dash']`.
*Caveat:* MapLibre does not support data-driven `line-dasharray` on a single layer. Either split into three filtered layers (one per band, each with a static dasharray) or widen the width spread to at least 1.4px → 3.0px, which is perceivable on its own. Update `.links-legend-inline` to show the line style, not just the swatch colour.
---
### UI-16 — P2 — `--offline` used as status text at 3.40:1
**Files**
- Token: `frontend/src/styles/globals.css:54` — `--offline: #546e7a`
- `frontend/src/styles/map-app.css:460` — `.node-dock__status--offline { color: var(--offline); }` at `font: 10px/1 mono`
- `frontend/src/components/Map/NodePopupContent.tsx:64, 101, 137` — `OFFLINE ` at 11px
**Measured**
| Background | Contrast | AA (4.5:1) |
|---|---|---|
| `--bg-base` `#080d14` | 3.61:1 | ✗ |
| `--bg-panel` `#0d1520` | **3.40:1** | ✗ |
| `--bg-panel-alt` `#111c2b` | 3.17:1 | ✗ |
| `--bg-active` `#1a2840` | **2.74:1** | ✗ (fails even large-text) |
Used as a background for dots (`map-app.css:782`, `:1349`) it is fine (non-text, 3:1). Used as **text** it fails everywhere.
**Fix** Do not change `--offline` itself — it is correct as a dot fill. Add a text-safe variant in `globals.css:52–56`:
```css
--offline: #546e7a; /* dots / fills only */
--offline-text: #93a7b2; /* 7.34:1 on --bg-panel */
```
and switch the two text usages (`map-app.css:460`, `NodePopupContent.tsx:64`) to `var(--offline-text)`.
**Related, verify while here:** `--danger` (`#ff1744`) measures 4.46:1 on `--bg-panel-alt` and 3.84:1 on `--bg-active` — below AA for normal text on those two surfaces. It passes on `--bg-base` (5.06) and `--bg-panel` (4.77). Check whether `.node-dock__status--stale` (`map-app.css:461`) or `.packet-item` variants ever sit on `--bg-active`.
---
### UI-17 — P2 — Two touch targets below the 24px floor
**Files** `frontend/src/styles/globals.css:298–301`, `frontend/src/styles/map-app.css:633–641`
There *is* a coarse-pointer rule (`globals.css:2130–2134`):
```css
@media (pointer: coarse) {
:where(button, input, select, textarea, [role="button"], [role="tab"], [role="option"]) { min-height: 44px; }
}
```
`min-height` clamps `height` regardless of specificity, so **heights are fixed**. But it sets no `min-width`, so explicit widths survive:
| Control | Declared | Effective on touch | WCAG 2.5.8 (24×24) |
|---|---|---|---|
| `.topbar__info-btn` (`globals.css:298`) | `22px × 22px` | **22 × 44** | ✗ width |
| `.packet-item__watch` (`map-app.css:633`) | `width: 20px` | **20 × 44** | ✗ width |
| `.topbar__shortcut-btn` (`globals.css:329`) | `28px` | 28 × 44 | ✓ |
| `.filter-panel__collapse` (`map-app.css:217`) | `24 × 24` | 24 × 44 | ✓ (boundary) |
Both failing controls remain visible on mobile: `globals.css:511` hides `.topbar__tool-btn:not(.topbar__shortcut-btn)` but not `.topbar__info-btn`.
**Fix** Add `min-width` to the coarse-pointer rule in `globals.css:2130`:
```css
@media (pointer: coarse) {
:where(button, input, select, textarea, [role="button"], [role="tab"], [role="option"]) {
min-height: 44px;
min-width: 44px;
}
}
```
Then check for layout damage — `min-width: 44px` on every button is broad. If it breaks the packet-row grid, instead widen the two offenders directly:
```css
.topbar__info-btn { width: 24px; height: 24px; } /* globals.css:299 */
.packet-item__watch { width: 24px; min-width: 24px; } /* map-app.css:634 — also widen the grid column at map-app.css:1174 */
```
---
### UI-18 — P2 — Nav badge fails contrast
**File** `frontend/src/pages/site-shell.css:219–230`
```css
.site-nav__badge { background: var(--color-red); color: #fff; font: 10px var(--font-mono); }
```
`--color-red` is `#ef4444` (`tokens.css:7`). White on `#ef4444` = **3.76:1** — below AA (4.5:1) for 10px text. Under `[data-contrast='high']` (`tokens.css:31`) `--color-red` becomes `#ff6b6b`, making it **worse** (~2.9:1) — the high-contrast theme actively regresses this element.
**Fix** Darken the badge background and keep white text:
```css
.site-nav__badge { background: #b91c1c; color: #fff; font: 11px var(--font-mono); }
```
`#fff` on `#b91c1c` = 6.47:1. Do not source it from `--color-red`, which is tuned as a *foreground* colour. Bump 10px → 11px per **UI-08**.
---
### UI-19 — P3 — `--bg-secondary` undefined on 5 live surfaces
**Files** `globals.css:912`, `:971`, `:986`, `:1021`; `feed-page.css:1027`, `:1148`
Same mechanism as UI-04/UI-07 — `background: var(--bg-secondary)` with no definition and no fallback → `background-color: transparent`.
| Selector | Live? | Effect |
|---|---|---|
| `.repeater-details-card__neighbour` (`globals.css:912`) | yes | neighbour tiles lose their inset fill; only the `--border` outline remains |
| `.repeater-details-card__table th` (`globals.css:971`) | yes | header row no longer distinguished from body rows |
| `.repeater-search-box__count` (`globals.css:986`) | yes | result-count strip blends into the dropdown |
| `.repeater-details-card__empty-msg` (`globals.css:1021`) | yes | empty-state block has no surface |
| `.uk-feed-inline-map` (`feed-page.css:1027`, `:1148`) | yes (mobile) | no placeholder fill behind the map while it loads |
Low severity because a border or parent background still delimits each one — the surfaces are flatter than intended, not broken.
**Fix** Define once in `globals.css:26` (`:root`):
```css
--bg-secondary: var(--bg-panel-alt); /* #111c2b */
```
That gives a surface one step lighter than `--bg-panel`, which is what each of these five call sites clearly wants. Note `feed-page.css:1027` and `:1148` are duplicate rules inside two different media queries — consolidate them while you are there.
---
### UI-20 — P3 — 13 `` elements without `type`
`DisclaimerModal.tsx:47`; `FeedPathViews.tsx:195`, `:203`; `UKFeedPage.tsx:456, 462, 468, 477, 484, 624, 680, 684, 690`; `UKRepeaterSearchPage.tsx:468`.
The HTML default is `type="submit"`. **None of these files contain a `