Kpa-clawbot 933ef4e6ef fix(#1356): WCAG 2.2 AA map a11y — cluster bubbles, role pills, multi-byte labels (#1357)
Red commit: d48c1add88 (CI:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26411462973)

Green commit CI:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26411699037

## What

Brings the map's three visual surfaces — cluster bubbles, role pills
inside cluster bubbles, and multi-byte hash labels on repeater markers —
up to WCAG 2.2 AA. Replaces the prior color-only signaling with
structural carriers (size, border-style, glyph, letter prefix) so color
is no longer the only channel.

## How

Locked design = Tufte's structural framing ([issue
comment](https://github.com/Kpa-clawbot/CoreScope/issues/1356#issuecomment-4535244400))
WITH the WCAG audit's "Minimal patch to reach AA" applied as overrides
([issue
comment](https://github.com/Kpa-clawbot/CoreScope/issues/1356#issuecomment-4535849354)).
Where the audit and the original proposal disagreed (border color, pill
text color, V3 accent palette, font sizes), the audit's values won.

## V1 cluster bubbles

- Neutral fill `rgba(33,41,54,0.92)` via new `--mc-cluster-fill` (was
per-bucket `--info / --warning / --accent`).
- Border-style ramp as the redundant non-color carrier of the count
bucket: `mc-sm` `1.5px solid`, `mc-md` `2.5px solid`, `mc-lg` `2px
double`.
- Border color `#666` + dark halo `box-shadow: 0 0 0 1px
rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.35)` so the border edge is
visible against both Carto Positron (`#f8f9fa`) and Carto Dark Matter
(`#262626`).
- `<div role="img" aria-label="<n> nodes — <breakdown>">` with the count
+ pills wrapped `aria-hidden="true"` so the AT announcement is the
summary, not the literal glyphs.

## V2 role pills

- `ROLE_LETTERS` map (`R` / `C` / `M` / `S` / `O`) is the primary
carrier — visible inside every pill, so protanopes/deuteranopes can read
the role without depending on hue.
- Wong (2011) palette as the secondary carrier, declared as
`--mc-role-repeater/companion/room/sensor/observer` — does NOT touch the
reserved `--info / --warning / --accent` system vars.
- `color: #1a1a1a` on **all five** pills (CSS rule + inline
defense-in-depth). Passes SC 1.4.3 small-text (≥4.5:1) against every
Wong hue.
- Font now `0.625rem/1.1 ui-monospace` (was `9px`, audit bumped to
`10px`, this PR converts to `rem` so user font-size preferences scale
the pill).
- Per-pill `aria-label="<n> <role>s"`, `overflow: visible` so a user
`letter-spacing` override doesn't clip (SC 1.4.12).

## V3 multi-byte hash labels

- `MB_GLYPHS` prefix (`✓` / `?` / `✗`) is the primary non-color status
carrier; the hash text is the data.
- Neutral dark fill `--mc-mb-fill` + colored 3px left border via
per-status `--mc-mb-confirmed/suspected/unknown` (high-luminance set
`#56F0A0` / `#FFD966` / `#FF8888` — audit override of original Tol
"vibrant" set, which failed border-stripe SC 1.4.11).
- Font now `0.75rem/1.2 ui-monospace` (was `11px`, audit bumped to
`12px`, this PR converts to `rem` for SC 1.4.4 robustness).
- `<div role="img" aria-label="multi-byte <status>, hash <ID>"><span
aria-hidden="true">` so AT reads the meaningful label (not the literal
`✓ 3E`). Observer-overlay `★` carries `aria-hidden="true"` for the same
reason. Null `mbStatus` falls through to `"repeater hash <ID>"` cleanly
— no `"multi-byte undefined"`.
- Forced-colors graceful degradation via `@media (forced-colors:
active)` block mapping all three surfaces to `Canvas` / `CanvasText`
with `forced-color-adjust: auto` (NOT `none`).

## TDD red→green

| Commit | Files | CI |
|---|---|---|
| `d48c1add` (red) | `test-issue-1356-map-a11y.js`,
`.github/workflows/deploy.yml` (test + wiring only) | [**failure** — 27
assertion ✗, exit
1](https://github.com/Kpa-clawbot/CoreScope/actions/runs/26411462973) |
| `b94755e6` (green) | `public/map.js`, `public/style.css`,
`test-issue-1356-map-a11y.js` (impl) |
[**success**](https://github.com/Kpa-clawbot/CoreScope/actions/runs/26411699037)
|
| `ac63e6ab` | refactor: drop `MB_COLORS` alias, hoist `MB_MARKER_TINT`
(round-1 #3 + #4) | (round-2) |
| `8aad60cb` | style: font sizes to `rem` for SC 1.4.4 (round-1 #2) |
(round-2) |
| `50a1aab1` | test: round-1 coverage adds + de-tautologise V2.c / V3.h
(round-1 #5) | (round-2) |

Red commit failed on **assertions** (not compile error) — the harness
loaded `public/map.js` + `public/style.css` end-to-end and exhausted all
27 string-presence checks. Green commit lands the audit-overridden
design and clears 32/32. Round-2 commits extend coverage to 40/40
without altering the original red→green gate.

## WCAG SC addressed

- **SC 1.4.1 Use of Color (A)**: cluster size + border-style ramp; pill
capital-letter prefix; MB label glyph prefix. Every visual is now
carried by at least one non-color channel.
- **SC 1.4.3 Contrast Minimum (AA)**: cluster `#fff` count on composited
fill = 10.12:1 vs Positron / 14.64:1 vs Dark Matter. MB label text =
11.48:1 / 14.65:1. Pill `#1a1a1a` on Wong hues: R 5.43, C 9.10, M 6.14,
S 13.16, O 6.86 — all ≥4.5:1.
- **SC 1.4.11 Non-text Contrast (AA)**: cluster border `#666` = 4.83:1
vs Positron, 3.30:1 vs Dark Matter; MB stripes vs `--mc-mb-fill`:
`#56F0A0` 5.13, `#FFD966` 8.66, `#FF8888` 4.62. Stripe-vs-basemap edge
is mitigated by the 1px dark halo box-shadow on `.mc-mb-label`.
- **SC 1.3.1 Info & Relationships (A)**: every divIcon now has
`role="img"` + a descriptive `aria-label`; visible glyph spans are
`aria-hidden="true"` so AT reads the meaning, not the typography.
- **SC 1.4.5 Images of Text (AA)**: implemented surfaces use live text
(`<span>` + `<div>` with CSS font), not rasterised glyphs — user
font-size / zoom scale them. Where SVG markers are used (non-label
path), the textual information is also exposed via `marker.alt` + popup,
satisfying the "essential" exception.

## Manual verification

1. **Both Carto themes on staging.** Open https://analyzer.00id.net and
switch the basemap (Positron and Dark Matter) — cluster bubbles, pills,
and MB labels must remain legible on both. Border edge of cluster bubble
visible on Positron (was the original bug).
2. **Screen-reader (NVDA / VoiceOver) test.**
- Focus a cluster bubble → expect `"<n> nodes — <role breakdown>"` and
NO literal letter/number announce per pill.
- Focus a MB label on a repeater marker → expect `"multi-byte confirmed,
hash 3E"` (or whatever status/hash applies) and NO `"check mark thin
space 3 E"`.
- Observer-also-repeater label → still announces the meaningful label
only; ★ is silent.
3. **Coblis simulation** (or equivalent). Run cluster + pills + MB
labels through deuteranopia / protanopia / tritanopia simulation.
Cluster bucket must be distinguishable by size + border-style (without
hue). Pill role must be distinguishable by the letter (without hue). MB
status must be distinguishable by glyph (without hue).
4. **Windows High Contrast / forced-colors.** Toggle on; all three
surfaces should fall back to `Canvas` / `CanvasText` (no invisible
elements, no `forced-color-adjust: none` regression).

## Out of scope

Filed for separate follow-up issues (audit explicitly tagged these as
either pre-existing or modern-interpretation non-blockers):

1. **SC 2.1.1 Keyboard (A)** — cluster click-to-zoom is mouse-only today
(Leaflet markercluster limitation). Needs `role="button"` + `tabindex=0`
+ `keydown` handler. Pre-existing, not introduced by this PR.
2. **SC 2.4.7 Focus Visible (AA)** — moot until #1 is addressed (no
focusable target). When the cluster becomes focusable, a
`:focus-visible` outline must be added.
3. **`prefers-reduced-motion` gate** — `.mc-cluster:hover { transform:
scale(1.06) }` and the 120ms transition are untouched from pre-PR.
Should be gated on `@media (prefers-reduced-motion: reduce)` in a
follow-up hygiene pass.
4. **px → rem for non-font sizes** — this PR converts font sizes (the SC
1.4.4 sensitive surface). Border widths and small paddings are kept in
px because physical-pixel snapping matters more for borders than user
font-zoom.

Fixes #1356

---------

Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
2026-05-25 11:38:50 -07:00
2026-04-05 06:36:03 +00:00
2026-03-20 05:38:23 +00:00

CoreScope

Go Server Coverage Go Ingestor Coverage E2E Tests Frontend Coverage Deploy

High-performance mesh network analyzer powered by Go. Sub-millisecond packet queries, ~300 MB memory for 56K+ packets, real-time WebSocket broadcast, full channel decryption.

Self-hosted, open-source MeshCore packet analyzer. Collects MeshCore packets via MQTT, decodes them in real time, and presents a full web UI with live packet feed, interactive maps, channel chat, packet tracing, and per-node analytics.

Performance

The Go backend serves all 40+ API endpoints from an in-memory packet store with 5 indexes (hash, txID, obsID, observer, node). SQLite is for persistence only — reads never touch disk.

Metric Value
Packet queries < 1 ms (in-memory)
All API endpoints < 100 ms
Memory (56K packets) ~300 MB (vs 1.3 GB on Node.js)
WebSocket broadcast Real-time to all connected browsers
Channel decryption AES-128-ECB with rainbow table

See PERFORMANCE.md for full benchmarks.

Features

📡 Live Trace Map

Real-time animated map with packet route visualization, VCR-style playback controls, and a retro LCD clock. Replay the last 24 hours of mesh activity, scrub through the timeline, or watch packets flow live at up to 4× speed.

Live VCR playback — watch packets flow across the Bay Area mesh

📦 Packet Feed

Filterable real-time packet stream with byte-level breakdown, Excel-like resizable columns, and a detail pane. Toggle "My Nodes" to focus on your mesh.

Packets view

🗺️ Network Overview

At-a-glance mesh stats — node counts, packet volume, observer coverage.

Network overview

📊 Node Analytics

Per-node deep dive with interactive charts: activity timeline, packet type breakdown, SNR distribution, hop count analysis, peer network graph, and hourly heatmap.

Node analytics

💬 Channel Chat

Decoded group messages with sender names, @mentions, timestamps — like reading a Discord channel for your mesh.

Channels

📱 Mobile Ready

Full experience on your phone — proper touch controls, iOS safe area support, and a compact VCR bar.

Live view on iOS

And More

  • 11 Analytics Tabs — RF, topology, channels, hash stats, distance, route patterns, and more
  • Node Directory — searchable list with role tabs, detail panel, QR codes, advert timeline
  • Packet Tracing — follow individual packets across observers with SNR/RSSI timeline
  • Observer Status — health monitoring, packet counts, uptime, per-observer analytics
  • Hash Collision Matrix — detect address collisions across the mesh
  • Channel Key Auto-Derivation — hashtag channels (#channel) keys derived via SHA256
  • Multi-Broker MQTT — connect to multiple brokers with per-source IATA filtering
  • Dark / Light Mode — auto-detects system preference, map tiles swap too
  • Theme Customizer — design your theme in-browser, export as theme.json
  • Global Search — search packets, nodes, and channels (Ctrl+K)
  • Shareable URLs — deep links to packets, channels, and observer detail pages
  • Protobuf API Contract — typed API definitions in proto/
  • Accessible — ARIA patterns, keyboard navigation, screen reader support

Quick Start

No build step required — just run:

docker run -d --name corescope \
  --restart=unless-stopped \
  -p 80:80 -p 1883:1883 \
  -v /your/data:/app/data \
  ghcr.io/kpa-clawbot/corescope:latest

Open http://localhost — done. No config file needed; CoreScope starts with sensible defaults.

For HTTPS with a custom domain, add -p 443:443 and mount your Caddyfile:

docker run -d --name corescope \
  --restart=unless-stopped \
  -p 80:80 -p 443:443 -p 1883:1883 \
  -v /your/data:/app/data \
  -v /your/Caddyfile:/etc/caddy/Caddyfile:ro \
  -v /your/caddy-data:/data/caddy \
  ghcr.io/kpa-clawbot/corescope:latest

Disable built-in services with -e DISABLE_MOSQUITTO=true or -e DISABLE_CADDY=true, or drop a .env file in your data volume. See docs/deployment.md for the full reference.

Build from Source

git clone https://github.com/Kpa-clawbot/CoreScope.git
cd CoreScope
./manage.sh setup

The setup wizard walks you through config, domain, HTTPS, build, and run.

./manage.sh status       # Health check + packet/node counts
./manage.sh logs         # Follow logs
./manage.sh backup       # Backup database
./manage.sh update       # Pull latest + rebuild + restart
./manage.sh mqtt-test    # Check if observer data is flowing
./manage.sh help         # All commands

Configure

Copy config.example.json to config.json and edit:

{
  "port": 3000,
  "mqtt": {
    "broker": "mqtt://localhost:1883",
    "topic": "meshcore/+/+/packets"
  },
  "mqttSources": [
    {
      "name": "remote-feed",
      "broker": "mqtts://remote-broker:8883",
      "topics": ["meshcore/+/+/packets"],
      "username": "user",
      "password": "pass",
      "iataFilter": ["SJC", "SFO", "OAK"]
    }
  ],
  "channelKeys": {
    "public": "8b3387e9c5cdea6ac9e5edbaa115cd72"
  },
  "defaultRegion": "SJC"
}
Field Description
port HTTP server port (default: 3000)
mqtt.broker Local MQTT broker URL ("" to disable)
mqttSources External MQTT broker connections (optional)
channelKeys Channel decryption keys (hex). Hashtag channels auto-derived via SHA256
defaultRegion Default IATA region code for the UI
dbPath SQLite database path (default: data/meshcore.db)

Environment Variables

Variable Description
PORT Override config port
DB_PATH Override SQLite database path

Architecture

                           ┌─────────────────────────────────────────────┐
                           │              Docker Container               │
                           │                                             │
Observer → USB →           │  Mosquitto ──→ Go Ingestor ──→ SQLite DB   │
  meshcoretomqtt → MQTT ──→│                    │                        │
                           │              Go HTTP Server ──→ WebSocket   │
                           │                    │               │        │
                           │              Caddy (HTTPS) ←───────┘        │
                           └────────────────────┼────────────────────────┘
                                                │
                                             Browser

Two-process model: The Go ingestor handles MQTT ingestion and packet decoding. The Go HTTP server loads all packets into an in-memory store on startup (5 indexes for fast lookups) and serves the REST API + WebSocket broadcast. Both are managed by supervisord inside a single container with Caddy for HTTPS and Mosquitto for local MQTT.

MQTT Setup

  1. Flash an observer node with MESH_PACKET_LOGGING=1 build flag
  2. Connect via USB to a host running meshcoretomqtt
  3. Configure meshcoretomqtt with your IATA region code and MQTT broker address
  4. Packets appear on topic meshcore/{IATA}/{PUBKEY}/packets

Or POST raw hex packets to POST /api/packets for manual injection.

Project Structure

corescope/
├── cmd/
│   ├── server/              # Go HTTP server + WebSocket + REST API
│   │   ├── main.go          # Entry point
│   │   ├── routes.go        # 40+ API endpoint handlers
│   │   ├── store.go         # In-memory packet store (5 indexes)
│   │   ├── db.go            # SQLite persistence layer
│   │   ├── decoder.go       # MeshCore packet decoder
│   │   ├── websocket.go     # WebSocket broadcast
│   │   └── *_test.go        # 327 test functions
│   └── ingestor/            # Go MQTT ingestor
│       ├── main.go          # MQTT subscription + packet processing
│       ├── decoder.go       # Packet decoder (shared logic)
│       ├── db.go            # SQLite write path
│       └── *_test.go        # 53 test functions
├── proto/                   # Protobuf API definitions
├── public/                  # Vanilla JS frontend (no build step)
│   ├── index.html           # SPA shell
│   ├── app.js               # Router, WebSocket, utilities
│   ├── packets.js           # Packet feed + hex breakdown
│   ├── map.js               # Leaflet map + route visualization
│   ├── live.js              # Live trace + VCR playback
│   ├── channels.js          # Channel chat
│   ├── nodes.js             # Node directory + detail views
│   ├── analytics.js         # 11-tab analytics dashboard
│   └── style.css            # CSS variable theming (light/dark)
├── docker/
│   ├── supervisord-go.conf  # Process manager (server + ingestor)
│   ├── mosquitto.conf       # MQTT broker config
│   ├── Caddyfile            # Reverse proxy + HTTPS
│   └── entrypoint-go.sh     # Container entrypoint
├── Dockerfile               # Multi-stage Go build + Alpine runtime
├── config.example.json      # Example configuration
├── test-*.js                # Node.js test suite (frontend + legacy)
└── tools/                   # Generators, E2E tests, utilities

For Developers

Test Suite

380 Go tests covering the backend, plus 150+ Node.js tests for the frontend and legacy logic, plus 49 Playwright E2E tests for browser validation.

# Go backend tests
cd cmd/server && go test ./... -v
cd cmd/ingestor && go test ./... -v

# Node.js frontend + integration tests
npm test

# Playwright E2E (requires running server on localhost:3000)
node test-e2e-playwright.js

Generate Test Data

node tools/generate-packets.js --api --count 200

Migrating from Node.js

If you're running an existing Node.js deployment, see docs/go-migration.md for a step-by-step guide. The Go engine reads the same SQLite database and config.json — no data migration needed.

Contributing

Contributions welcome. Please read AGENTS.md for coding conventions, testing requirements, and engineering principles before submitting a PR.

Live instance: analyzer.00id.net — all API endpoints are public, no auth required.

API Documentation: CoreScope auto-generates an OpenAPI 3.0 spec. Browse the interactive Swagger UI at /api/docs or fetch the machine-readable spec at /api/spec.

License

MIT

S
Description
No description provided
Readme GPL-3.0 153 MiB
Languages
JavaScript 54%
Go 38.4%
CSS 4.3%
Shell 1.8%
HTML 1%
Other 0.4%