61 KiB
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-headerscope, 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
- 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 offeed-page.css,spam-page.css,stats-page.css,docs-pages.css,owner-portal.css. - Undefined CSS custom property scan — parsed every
--x:definition and everyvar(--x)usage across all CSS/TSX, flagged usages with no definition and no fallback. - 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()andopacity. - Typography scan — every
font-sizeandfontshorthand across all CSS, normalised px/rem, flagged< 11px. - 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. - 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.
- 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 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: <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.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:
/* 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:
/* 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; }
<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:150–156:
.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: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 <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 (~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:
.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):
.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:22–47(node dots),:64(selected halo),:79–83(selected marker),:127–132,:165–170,:251–263frontend/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-themetoggle)
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:
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:
- Thread
mapLightintomapSourceLayers.tsand useMAP_OVERLAY_COLORS[theme]for everycircle-color,fill-color,line-colorliteral. - Thread the theme into
geojsonBuilders.ts(buildLinksGeoJSON, and the predicted-links builder at :299) so link colours are chosen per theme. - Add a contrasting outline to the node layer instead of
stroke-width: 0:'circle-stroke-width': 1,'circle-stroke-color'=#080d14in dark /#ffffffin light. This alone lifts every marker over 3:1 and is the cheapest partial fix if the full palette work is deferred. - Fix
#4b5563→ a lighter grey for dark mode (it fails there today). MapLibreMap.tsx:217–263already re-applies style onmapLightchange — extend that effect to callmap.setPaintPropertyfor 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
.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:
.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:
/* 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 <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
- Raise the global rule above component rules. In
globals.css:2124, drop:where()so the selector carries real weight, and add!importantonly if needed after re-testing:This makes ita: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; }(0,1,1), beating bare-class rules. - Delete the eight
outline: nonedeclarations listed above. Keep theborder-colortransitions — they are a nice additional affordance, just not a substitute. - For
.regions-rel__neighbour-rangespecifically, if a ring on the track looks wrong, style the thumb instead:.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); } - For
.health-card--interactive, split hover and focus so they are not the same rule; let the global ring apply on focus. - 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–1677frontend/src/components/Map/MapLibreMap.tsx:1282–1310frontend/src/components/app/MobileControls.tsx(whole file — no equivalent)
/* 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 onlymax-width: 640pxhides them, and add both buttons toMobileControls.tsxfor the phone case. The buttons already carry text labels ("LOS") so they only needmin-height: 44pxand 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
.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) —
--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
- Floor at 10px, immediately: raise every
< 10pxdeclaration to10px. That is 18 declarations (all the 7px, 8px and 9px rows above). Low risk — these are in fixed-width panels with room. - 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. - 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. - Add a lint guard: extend
frontend/scripts/check-css-structure.mjsto fail on any computedfont-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
.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
.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
<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
- Change
role="img"→role="group"(or drop the role and keeparia-label) onTopologyPage.tsx:228so descendants stay in the a11y tree. - Move the graph-level summary into a visually-hidden live description so the count is still announced:
(
<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-hiddenalready exists atglobals.css:2064.) - Remove
outline: nonefromnetwork-intelligence.css:40and split:focusout of the:hover, :focus, --activegroup so focus has a distinct look. SVGoutlinesupport is patchy — prefer an explicit ring:.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
<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:
<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>
…
/* 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:363frontend/src/pages/StatusPage.tsx:232frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx:601–605frontend/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:
<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
/* 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:530–536
<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
<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:299–316 and :345–352; thresholds in mapConfig.ts:12–13
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:
// 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); }atfont: 10px/1 monofrontend/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:52–56:
--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):
@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:
@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:
.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
.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:
.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):
--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:661–669, 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
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:181–192
.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 185–187.
UI-24 — P3 — Unused design tokens
frontend/src/styles/tokens.css:69–70—--map-label-color,--map-link-colorare defined on.maplibregl-mapand consumed nowhere. Map label colours are actually set inmapConfig.ts:32–45.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:14–17
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
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:588–623 (@media (max-width: 640px)) and frontend/src/pages/site-shell.css:137–215 (@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:1–5) documenting a real regression from commit 83d770a.
Fix Delete the duplicated mobile-nav block from globals.css:588–623 (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:45–47 (and an identical dead trio at :237)
'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
.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 141–300px 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:
.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.5–11.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:2136–2146, map-app.css:1372–1382). |
| 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.mjsto fail on (a)var(--x)with no definition and no fallback, (b) computedfont-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/playwrightindevDependencies. Wiring an axe pass intotest:e2ewould 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
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
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:
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.htmlandshare.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.