Files
ukmesh/UI-AUDIT.md
T

1023 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, `<th scope>`, `role=` correctness, icon-only buttons, `<button type>`, 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 8101160px 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: `<th>` 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 `<button>` 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.061.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 641800px viewports |
---
## 4. P0 findings
### UI-01 — Path-segment evidence popover is invisible (1.13:1)
**Files**
- `frontend/src/styles/map-app.css:150156` (the popover styles)
- `frontend/src/styles/map-app.css:914921` (`.maplibregl-popup-content`)
- `frontend/src/components/Map/DeckGLOverlay.tsx:484517` (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:150156 */
.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; }
```
`<strong>` (title) and `<dd>` (all four values) have no colour of their own, so they inherit `#172033`.
**Measured**
| Element | Colour | On `#0d1520` | WCAG AA (4.5:1) |
|---|---|---|---|
| Title `<strong>` + all `<dd>` values | `#172033` | **1.13:1** | ✗ catastrophic |
| Labels `<dt>` | `#64748b` | 3.85:1 | ✗ |
| Summary `<p>` | `#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 `<dt>` 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:150156`:
```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``<Modal className="ui-dialog-modal">` (hard-coded, not overridable)
- `frontend/src/styles/globals.css:20862089``.ui-dialog-modal { width: min(760px, 100%); … }`
- `frontend/src/pages/ukmesh/feed-page.css:406409``.uk-feed-path-modal { width: min(960px, calc(100vw - 32px)); … }`
- `frontend/src/pages/path-modal.css:720``.stats-page__path-modal { width: min(960px, 100%); … }`
- `frontend/src/pages/ukmesh/FeedDialogs.tsx:3945` — the dialog that combines them
**What's wrong**
`Dialog.tsx` always renders `<ModalOverlay><Modal className="ui-dialog-modal"><Dialog className={…}>`. 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 (~8101160px, 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 `<Modal>`, 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:2247` (node dots), `:64` (selected halo), `:7983` (selected marker), `:127132`, `:165170`, `:251263`
- `frontend/src/components/Map/geojsonBuilders.ts:299304`, `:345350` (link colours)
- `frontend/src/components/Map/mapConfig.ts:183207` (`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:217263` 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:217263` 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. P1P3 findings
### UI-04 — P1 — Repeater search dropdown has no hover or keyboard-focus highlight
**File** `frontend/src/styles/globals.css:775778`
```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:105108` 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:21242127`
- 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:17331742`) is `<input type="range">` — 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:16731677`
- `frontend/src/components/Map/MapLibreMap.tsx:12821310`
- `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:12831292`) and **Plan repeater** (`:12941310`, 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 13% 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 79px mono renders smaller than 79px 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:12131227`
```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:21362146` 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 `:255284`
```tsx
<svg viewBox="0 0 1000 600" role="img" aria-label={`${plot.nodes.length} positioned repeaters and ${plot.links.length} links`}>
<circle role="button" tabIndex={0} aria-label={`${node.name …}, ${node.degree} links`} … >
<title>…</title>
</circle>
```
`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
<p className="ui-visually-hidden" id="topology-graph-desc">
{plot.nodes.length} positioned repeaters and {plot.links.length} links
</p>
<svg viewBox="0 0 1000 600" role="group" aria-labelledby="topology-graph-desc">
```
(`.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:339347`; CSS `frontend/src/pages/network-intelligence.css:6769`
```tsx
<div className="topology-page__validation-table" role="table" aria-label="RF model mismatches">
{rfValidation.mismatches.slice(0, 20).map((link) => (
<div role="row" key={…}>
<span role="cell">…</span> {/* link pair */}
<span role="cell">…</span> {/* observations */}
<span role="cell">…</span> {/* path loss dB */}
<strong role="cell">…</strong> {/* 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
<div className="topology-page__validation-table" role="table" aria-label="RF model mismatches">
<div role="row" className="topology-page__validation-table-head">
<span role="columnheader">Link</span>
<span role="columnheader">Observations</span>
<span role="columnheader">Modelled path loss</span>
<span role="columnheader">Classification</span>
</div>
```
```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 `<th>` in the codebase has `scope`
**Files**
- `frontend/src/pages/SpamTransparencyPage.tsx:363`
- `frontend/src/pages/StatusPage.tsx:232`
- `frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx:601605`
- `frontend/src/pages/ukmesh/PacketDetailPanel.tsx:137`, `:329`, `:393`
All 6 `<thead>` header rows use bare `<th>`. 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 `<th>` in a `<thead>` row. Example for `PacketDetailPanel.tsx:137`:
```tsx
<tr><th scope="col">Bits</th><th scope="col">Field</th><th scope="col">Value</th><th scope="col">Binary</th></tr>
```
Purely additive, no visual change.
---
### UI-13 — P2 — Two close buttons have no accessible name
**Files**
- `frontend/src/pages/ukmesh/PacketDetailPanel.tsx:272` — `<button type="button" className="feed-detail__close" onClick={onClose}>✕</button>`
- `frontend/src/pages/ukmesh/UKFeedPage.tsx:680` — `<button className="uk-feed-stats__close" onClick={…}>✕</button>`
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 */
<button type="button" className="feed-detail__close" onClick={onClose} aria-label="Close packet details">✕</button>
/* UKFeedPage.tsx:680 */
<button type="button" className="uk-feed-stats__close" onClick={() => setSelectedPacketHash(null)} aria-label="Clear selected packet">✕</button>
```
(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:530536`
```tsx
<input type="search" className="uk-feed-search" placeholder="Search packets…" value={searchQuery} onChange={…} />
```
No `<label>`, 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 `<label>` (verified across all TSX).
**Fix**
```tsx
<input
type="search"
className="uk-feed-search"
aria-label="Search packets"
placeholder="Search packets…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
```
---
### UI-15 — P2 — Link quality is encoded by colour alone
**Files** `frontend/src/components/Map/geojsonBuilders.ts:299316` and `:345352`; thresholds in `mapConfig.ts:1213`
```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:112115` 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` — `<span style={{ color: 'var(--offline)' }}>OFFLINE</span>` 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:5256`:
```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:298301`, `frontend/src/styles/map-app.css:633641`
There *is* a coarse-pointer rule (`globals.css:21302134`):
```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:219230`
```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 `<button>` 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 `<form>`**, so there is no live submit bug today — this is a latent risk and a consistency gap (the other ~90 buttons in the codebase set it explicitly).
**Fix** Add `type="button"` to each. Consider an ESLint rule (`react/button-has-type`) to prevent regression.
---
### UI-21 — P3 — ~38 orphaned CSS class rules
Cross-checked every class name against all TSX/HTML; these are referenced by **no** component (verified they are not built dynamically via template literals either):
| Prefix | Dead classes | File |
|---|---|---|
| `dev-monitor__*` | 14 (`__card`, `__card--wide`, `__chip`, `__chips`, `__empty`, `__empty-row`, `__grid`, `__meta`, `__mono`, `__row`, `__section-head`, `__summary`, `__table`, `__table-wrap`) | `site-content.css` |
| `dev-telemetry*` | 11 (`dev-telemetry`, `-chart`, `-chart__body/__empty/__foot/__head/__tooltip`, `__charts`, `__grid`, `__item`, `__section`) | `site-content.css` |
| `dev-status-*` | 13 (`-card`, `-card--fixed`, `-grid`, `-list`, `-list--compact`, `-list__summary`, `-note`, `-page`, `-page__header`, `-page__last-seen`, `-shell`, `-table`, `-table-wrap`) | `site-content.css`, `globals.css` |
| `repeater-details-card__*` | 4 (`__map-icon`, `__map-link`, `__packet-type`, `__spinner`) | `globals.css` |
`dev-status-mono` and `dev-status-empty` **are** live (`UKFeedPage.tsx:582`, `:592`) — keep those two.
This is where 9 of the 11 `--border-soft` usages and 1 of the 2 `--bg-tertiary` usages live, which is why they were never noticed.
**Fix** Delete the dead rules, plus their `@media` overrides (`globals.css:661669`, `site-content.css` responsive blocks). Re-run the dead-class script in §8 afterwards to confirm nothing else broke. Do this **after** UI-07 and UI-19, so the token definitions land first and you can tell dead rules from live ones.
---
### UI-22 — P3 — `--accent-rgb` fallback is the wrong colour
**File** `frontend/src/styles/globals.css:1753`
```css
box-shadow: 0 0 0 3px rgba(var(--accent-rgb, 80, 220, 180), 0.2);
```
`--accent-rgb` is never defined, so the fallback always applies: `rgb(80, 220, 180)` — a mint green. The actual accent is `#00c4ff` = `rgb(0, 196, 255)`, cyan. The range-slider thumb therefore carries a green glow in an otherwise cyan UI.
**Fix** Either define `--accent-rgb: 0, 196, 255;` in `globals.css:39` next to `--accent`, or replace the whole expression with the existing token: `box-shadow: 0 0 0 3px var(--accent-dim);`.
---
### UI-23 — P3 — Duplicated declarations
**File** `frontend/src/pages/site-content.css:181192`
`.site-home__card` declares `background: var(--bg-panel)`, `border: 1px solid var(--border)` and `border-radius: 8px` **twice**, back to back. Harmless but confusing.
**Fix** Delete lines 185187.
---
### UI-24 — P3 — Unused design tokens
- `frontend/src/styles/tokens.css:6970` — `--map-label-color`, `--map-link-color` are defined on `.maplibregl-map` and consumed nowhere. Map label colours are actually set in `mapConfig.ts:3245`.
- `frontend/src/styles/tokens.css:6` `--color-gold`, `:10` `--color-purple`, `:22` `--font-sans` — zero consumers.
**Fix** Delete, or wire `--map-label-color`/`--map-link-color` into the real label pipeline if the intent was theme-driven map labels (see UI-03, which would benefit from exactly that plumbing).
---
### UI-25 — P3 — Backend operator login: invisible field boundaries
**File** `backend/src/backend-site/login.html:1417`
**Measured**
| Pair | Contrast | Needs |
|---|---|---|
| Input background `#0f1512` vs card `#151b18` | **1.06:1** | 3:1 (1.4.11) |
| Input border `#3b4842` vs card `#151b18` | **1.82:1** | 3:1 |
| Card border `#2f3a35` vs body `#0b0f0d` | 1.63:1 | 3:1 |
| Submit `#fff` on `#267d51` | 5.08:1 | ✓ |
| Body text `#a9b5af` on `#151b18` | 8.25:1 | ✓ |
The token field is effectively invisible until focused — there is no `:focus` style either.
Separately, `<p id="status" role="status">` (`:27`) receives **error** messages (`:47`). `role="status"` is polite; failures should be assertive.
**Fix**
```css
input { background: #0b100d; color: #fff; border: 1px solid #6b7d74; }
input:focus-visible { outline: 2px solid #5ad18f; outline-offset: 2px; border-color: #5ad18f; }
main { border-color: #55635c; }
```
and change `role="status"` → `role="alert"` on the status paragraph (or keep `status` and add `aria-live="assertive"`).
Low priority — this page is operator-only behind the local HTTPS/tunnel endpoint.
---
### UI-26 — P3 — Mobile nav is styled twice, reconciled by a specificity hack
**Files** `frontend/src/styles/globals.css:588623` (`@media (max-width: 640px)`) and `frontend/src/pages/site-shell.css:137215` (`@media (max-width: 860px)`)
Both blocks style `.site-nav`, `.site-nav__links`, `.site-nav__links--open`, `.site-nav__link` and `.site-nav__app-btn`, with **different** layouts: globals uses a full-bleed `flex-direction: column` dropdown anchored at `top: 52px`; site-shell uses a `360px`, 2-column `grid` anchored at `top: calc(100% + 8px)`.
Today site-shell wins, but only because every one of its selectors is prefixed with `html ` (e.g. `html .site-nav__links`) to buy specificity — `site-content.css`/`site-shell.css` load in a lazy chunk **after** `index-*.css`, so bare-class rules would tie and the wrong one could win. The same `html ` hack appears in `feed-page.css` (`html .uk-feed-*`), and `map-app.css:1384` carries a comment explaining the same fight: *"App-specific responsive rules live after the app base rules so lazy-loaded CSS cannot reverse the mobile cascade."*
Note the residue: globals' `.site-nav { height: 52px }` is **not** overridden by site-shell (which sets `min-height`), so the final nav mixes declarations from both files.
**Impact** No visible bug today. It is a maintenance trap — this is the same cascade-ordering class of problem that produced **UI-05** and the `path-modal.css` extraction comment (`path-modal.css:15`) documenting a real regression from commit `83d770a`.
**Fix** Delete the duplicated mobile-nav block from `globals.css:588623` (globals should not know about `.site-nav`, which belongs to the UK site shell), and drop the now-unneeded `html ` prefixes in `site-shell.css`. Longer term, consider CSS layers (`@layer base, components, pages;`) so lazy-chunk order stops mattering.
---
### UI-27 — P3 — Dead paint properties on the node layer
**File** `frontend/src/components/Map/mapSourceLayers.ts:4547` (and an identical dead trio at `:237`)
```ts
'circle-stroke-width': 0,
'circle-stroke-color': '#00c4ff',
'circle-stroke-opacity': 0.7,
```
With `stroke-width: 0` the colour and opacity never render. Either delete the two dead lines or — better — set `circle-stroke-width: 1` as part of **UI-03**, which is the cheapest way to lift every marker over 3:1 in light mode.
---
### UI-28 — P3 — Map annotation squeezed on narrow desktop widths
**File** `frontend/src/styles/map-app.css:136`
```css
.map-annotation { max-width: min(520px, calc(100vw - 500px)); … white-space: nowrap; text-overflow: ellipsis; }
```
The `≤640px` override (`map-app.css:1409`) resets this to `calc(100vw - 30px)`, so phones are fine. But between **641px and ~800px** the annotation is capped at 141300px and truncated to an ellipsis almost immediately. At exactly 641px it is 141px wide.
The same pattern in `.node-drawer` (`map-app.css:380`, `width: min(370px, calc(100vw - 480px))`) yields 161px at 641px — tight but not broken, and also reset at `≤640px`.
**Fix** Raise the breakpoint for the annotation reset, or use a floor:
```css
.map-annotation { max-width: min(520px, max(260px, calc(100vw - 500px))); }
```
---
## 6. Verified correct — do NOT re-investigate
These were flagged by automated scans or looked suspicious on reading, and were then **checked and cleared**. Re-chasing them will waste time.
| Thing | Verdict |
|---|---|
| `var(--trigger-width)` (`globals.css:731`, `:2105`; `map-app.css:87`) undefined | **Not a bug.** Injected at runtime by react-aria-components' `Popover`/`ComboBox`. Confirmed in `node_modules/react-aria-components/dist/private/Popover.mjs`. |
| `--color-text-dim: #6b7280` (`tokens.css:12`) measures 3.97:1 | **Not a bug.** Only consumed inside `tokens.css` to build `--text-muted` in the **high-contrast** blocks, where it is redefined to `#c7d2df`. The default theme's `--text-muted` comes from `globals.css:50` (`#8697b0`, 6.17:1). The `#6b7280` value never reaches the screen. |
| `<img>` without `alt` | **None exist.** Every `<img>` in the codebase has `alt`. |
| Text clipped without ellipsis (`nowrap` + `overflow:hidden`, no `text-overflow`) | **Only `.ui-visually-hidden`** (`globals.css:2064`), which is intentional. Everything else pairs them correctly. |
| `.sm-timeline` table overflowing on mobile | **Handled.** `spam-page.css:422` has `.sm-timeline { overflow-x: auto; }` (single-line rule, easy to miss). Same for `.repeater-details-card__table-wrap` and `.dev-monitor__table-wrap`. |
| "51 buttons missing `type`" (naive line-grep) | **Actually 13**, and none inside a `<form>`. See UI-20. The other 38 have `type` on a following line. |
| "4 unlabelled form controls" | **Actually 1** (UI-14). `ObserverRegistrationForm.tsx:34` and `OwnerPortalSections.tsx:165,166` use valid implicit `<label>` wrapping. |
| Filter toggle on/off state colour-only | **Not colour-only.** `.filter-toggle--on::after` translates the knob 12px (`map-app.css:720`) and `aria-pressed` is set (`MobileControls.tsx:73`, `FilterPanel.tsx`). |
| Public-site body/nav/footer/card text contrast | **All pass AA.** `#b4c6d8` measures 10.511.1:1 on its backgrounds; `#66829e` 4.87:1; `#b8c9dc` 10.85:1; `#bcefff` 13.5:1; `#6b8aaa` 5.10:1; `.site-stat__hash` (accent @ 0.7) 5.00:1. |
| Core dark palette (`--text-primary/secondary/muted`, `--accent`, `--amber`, `--online`) | **All ≥ 4.97:1** on all five app surfaces. Solid. |
| `.stats-page__path-node-label { color: #ffffff }` on a map | **Fine.** StatsPage/Spam/Owner maps use `MAP_STYLE` (dark) only; the light theme is exclusive to the main app map. |
| `aria-live` / `role="status"` coverage | **Good.** 17 correct usages across loading, error and connection states. |
| `role="img"` on the four Recharts wrappers (`LinkQualitySparkline.tsx:90`, `ActivitySparkline.tsx:102`, `OwnerPortalCharts.tsx:242,282,395`) | **Correct pattern** — those wrap non-interactive charts. Only the topology SVG (UI-10) is wrong, because it contains focusable children. |
| `prefers-reduced-motion` handling | **Present and global** (`globals.css:21362146`, `map-app.css:13721382`). |
| Modal scroll containment | **Correct.** `.disclaimer-modal`, `.stats-page__path-modal`, `.ui-dialog` all set `max-height` + `overflow-y: auto` + `overscroll-behavior: contain`, with `100dvh` used for mobile browser chrome. |
| `onClick` on non-interactive elements | **Only one** (`FilterPanel.tsx:158`), and it is a `stopPropagation` guard on a wrapper, not an interactive control. |
---
## 7. Suggested work order
**Batch 1 — undefined tokens (single commit, ~20 min, fixes 3 findings)**
Define `--bg-secondary`, `--border-soft`, `--bg-tertiary` (or substitute existing tokens) → **UI-04, UI-07, UI-19**. Highest value per line changed; UI-04 alone restores keyboard operation of the repeater search.
**Batch 2 — P0 readability**
**UI-01** (5-line CSS fix), then **UI-02** (modal shell — touches `Dialog.tsx`, re-test every dialog consumer).
**Batch 3 — focus & keyboard**
**UI-05** (de-`:where()` the global ring, remove 8 `outline:none`), then **UI-10** and **UI-11** together since both are in `TopologyPage.tsx`.
**Batch 4 — typography**
**UI-08** step 1 (floor at 10px) and **UI-09** together — they share the `.packet-item` grid risk. Re-test the map side panels at 1280×800.
**Batch 5 — map theming**
**UI-03** + **UI-27** + **UI-15**. Largest single piece of work; needs a designed light palette. Ship the `circle-stroke-width: 1` partial fix first if the palette work is deferred.
**Batch 6 — a11y polish**
**UI-12, UI-13, UI-14, UI-16, UI-17, UI-18, UI-20**. All small and independent.
**Batch 7 — cleanup**
**UI-21** (dead CSS — do after Batch 1), **UI-22, UI-23, UI-24, UI-26, UI-28, UI-25**.
**Regression guards worth adding**
- Extend `frontend/scripts/check-css-structure.mjs` to fail on (a) `var(--x)` with no definition and no fallback, (b) computed `font-size < 10px`. Both scanners are in §8 and can be dropped in nearly as-is — they would have caught UI-04, UI-07, UI-08, UI-19 and UI-22 automatically.
- The repo already has `@axe-core/playwright` in `devDependencies`. Wiring an axe pass into `test:e2e` would catch UI-12, UI-13, UI-14 and parts of UI-05/UI-10.
---
## 8. Reproducing the measurements
Save these to a scratch directory and run with Node ≥ 20 from `frontend/`.
### 8.1 `contrast.mjs` — WCAG contrast with alpha compositing
```js
function parse(c) {
c = c.trim().replace('#', '');
if (c.length === 3) c = c.split('').map((x) => x + x).join('');
return [0, 2, 4].map((i) => parseInt(c.slice(i, i + 2), 16));
}
function lum([r, g, b]) {
const f = (v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
}
export function ratio(fgHex, bgHex, alpha = 1) {
let fg = parse(fgHex); const bg = parse(bgHex);
if (alpha < 1) fg = fg.map((c, i) => Math.round(c * alpha + bg[i] * (1 - alpha)));
const l1 = lum(fg), l2 = lum(bg);
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
return (hi + 0.05) / (lo + 0.05);
}
```
Thresholds: normal text **4.5:1** (AA) / 7:1 (AAA); large text (≥18.66px bold or ≥24px) **3:1** / 4.5:1; non-text UI and graphics **3:1** (WCAG 1.4.11).
### 8.2 Undefined custom-property scanner
```js
import { readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
const files = [];
(function walk(d) { for (const e of readdirSync(d)) {
if (['node_modules','dist','.git'].includes(e)) continue;
const p = join(d, e);
if (statSync(p).isDirectory()) walk(p); else if (/\.(css|tsx|ts|html)$/.test(p)) files.push(p);
} })('src');
const defined = new Set(), used = new Map();
for (const f of files) readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
for (const m of line.matchAll(/(^|[;{\s])(--[a-zA-Z0-9_-]+)\s*:/g)) defined.add(m[2]);
for (const m of line.matchAll(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(,)?/g)) {
if (!used.has(m[1])) used.set(m[1], []);
used.get(m[1]).push({ file: f, line: i + 1, hasFallback: !!m[2] });
}
});
for (const [name, locs] of used) if (!defined.has(name)) {
const noFb = locs.filter((l) => !l.hasFallback);
if (noFb.length) console.log(`${name}: ${noFb.length} usages without fallback\n` +
noFb.map((l) => ` ${l.file}:${l.line}`).join('\n'));
}
```
**Expected clean output after fixes:** only `--trigger-width` (runtime-injected by react-aria — allowlist it).
### 8.3 Sub-11px typography scanner
Walk all `.css`, match `font-size:\s*([0-9.]+)(px|rem)` and `font:\s*(?:[0-9]{3}\s+)?([0-9.]+)px`, normalise rem × 16, report `< 11`.
**Current count: 79.** Target after UI-08: 0 below 10px.
### 8.4 Dead-class detector
Collect every `.class-name` from all CSS, then check each against the concatenation of all `.tsx`/`.ts`/`index.html`. Flag names that never appear. Manually confirm each hit is not assembled from a template literal (e.g. `` `filter-row${on ? '--on' : ''}` ``) before deleting.
### 8.5 Cascade-order verification
Specificity ties in this codebase are resolved by **stylesheet load order**, which is determined by Vite's chunking, not by import statements. To check which rule really wins:
```bash
cd frontend
grep -o "\.your-selector{[^}]*}" dist/assets/*.css # which chunk holds it
grep -o "\[[^]]*YourPage-[a-zA-Z0-9_-]*\.css[^]]*\]" dist/assets/index-*.js # preload order
```
Chunk CSS is injected in the order it appears in the preload manifest array. `index-*.css` (containing `globals.css` + `tokens.css`) always loads first, so **every page-level stylesheet outranks the design system on ties.** This is the root cause of UI-05 and a contributing factor in UI-02 and UI-26.
---
## 9. Coverage notes
**Fully read:** `tokens.css`, `globals.css`, `map-app.css`, `site-shell.css`, `network-intelligence.css`, `path-modal.css`, `mapConfig.ts`, `mapSourceLayers.ts` (layer definitions), `Dialog.tsx`, `Combobox.tsx`, `MobileControls.tsx`, `FeedPathViews.tsx`, `FeedDialogs.tsx`, `pathNodePopup.ts`, `login.html`.
**Read in relevant part + scanned in full:** `site-content.css`, `feed-page.css`, `spam-page.css`, `stats-page.css`, `docs-pages.css`, `owner-portal.css`, `App.tsx`, `MapLibreMap.tsx`, `DeckGLOverlay.tsx`, `geojsonBuilders.ts`, `TopologyPage.tsx`, `StatusPage.tsx`, `UKFeedPage.tsx`, `UKRepeaterSearchPage.tsx`, `SpamTransparencyPage.tsx`, `PacketDetailPanel.tsx`, `NodePopupContent.tsx`, `OwnerPortalCharts.tsx`, `OwnerPortalSections.tsx`.
**Scanned by automation only** (all CSS/TSX): undefined tokens, contrast pairs, font sizes, `z-index` inventory, low-opacity text, `calc(100vw - N)` negatives, text-clipping, `role=`/`aria-` usage, form labelling, `<th scope>`, `<button type>`, icon-only buttons, dead classes, `!important` distribution.
**Not covered — recommend a follow-up pass**
- `backend/src/backend-site/template.html` (573 lines) — only spot-checked.
- `healthcheck-overrides/index.html` and `share.html` (369/370 lines) — not reviewed; they are deploy-time overrides for a third-party health page.
- Runtime/visual verification. Everything here is static analysis. Geometry claims in **UI-02** are computed from the CSS box model and should be confirmed in a browser at 850/900/1024/1280px before and after the fix.
- `frontend/test/` Playwright specs were not reviewed for coverage gaps against these findings.