The replay on load is the newest 50 messages and the newest 50 packets, and packets reach back far less, so older message and command rows had a hash and no packet. Opening such a row now asks GET /api/packets/by-hash/<hash>?near=<epoch> for that packet's stored receptions and fills in route, path, receptions and Inspect bytes. The near time bounds the scan to the timestamp index, since the hash is only inside the row's JSON (about 6 ms against a full scan's 250 ms on 90k rows).
The fetched receptions fill the row only: they stay out of the spark and the replay's coverage, since they aren't new hearings. The detail says Loading while it waits, that the packet isn't in the bot's log when nothing comes back, or that it couldn't load and reopening retries.
"packet not on the tape" didn't say why. On load the page replays the newest 50 messages and the newest 50 packets, and packets reach back far less, so older messages have no packet to join. The row no longer carries the fact; the expanded detail says the packet is older than the ones loaded, wasn't seen on the page (live rows), or was never recorded by the bot.
- Real-time rows put SNR before the hop count. On a phone the end of the line is cut off, and SNR is the reading a quick check wants; it used to show as "SNR …".
- A repeat heard again only lights its "heard ×N" count instead of re-running the whole row's 1.6 s flash, so a busy tape no longer shimmers. In 20 s of live traffic: 26 count bumps, 8 flashes for genuinely new rows. Reduced motion turns both off.
- The health strip says "1 client", not "1 clients".
- American spelling throughout the user-visible text: "One-hop neighbors" and its tooltip and empty message.
- "Multibyte share of packets" in sentence case like the other card titles.
- The hops chart's y-axis reads "% of nodes or packets" instead of chart-library "% of series".
- The route bar's label rounds to one decimal like its legend, so it no longer shows 98% beside 98.4%.
- The note that bot statistics need collect_stats is configuration guidance, not a degraded state, so it drops the warning color.
The full suite passes (5878), and axe-core still finds nothing on any page in either theme.
The dashboard's payload mix colored its bars by rank, so a packet type's color changed whenever the traffic order did, and disagreed with the multibyte chart further down, which colors the same types by a fixed slot. The sixth rank was also #198754, the status green. Payload types now take their fixed slot from the chart's SERIES_PALETTE, per theme, so each type has one color everywhere on the page. The role mix gives each role a fixed color matching the Mesh page's node fills: repeaters Signal Blue, room servers Route Teal, then companion orange, sensor violet, bot pink, gateway cyan. Unknown and Other stay neutral, any other role takes a spare, and none of these colors is a lamp.
Real-time hashed channel names onto the whole hue wheel, so a channel could come out status green or alarm red; Public was green. Channel dots now draw from 20 colors per theme, ten hues kept clear of the lamp red, amber and green at two lightness steps, each holding 3:1 on the card (4.2:1 light, 5.2:1 dark). A channel starts at its hashed slot, so its color stays the same between visits. If another channel on the page already holds that slot it steps 7 slots on, a clearly different hue, so up to 20 channels never share a color; with a plain hash, two of three channels on the test mesh collided.
DESIGN.md's data-palette section describes both.
Real-time's receiver strip gains an RX LED beside the station state, borrowed from the radio's own front panel. It lights for 140 ms on each live reception, and a burst keeps it lit instead of strobing. It is dark when nothing is arriving, which honestly answers "is anything coming in?" on a quiet station. It uses the connected lamp's green because it means receiving, sits behind aria-hidden next to "Last heard", which carries the same information in words, and stays dark under prefers-reduced-motion. Replayed history doesn't light it; it's one class toggle per packet, with no polling.
The dashboard's New nodes tile names the newest arrival from the last day under its count, e.g. "newest: N7JMV-BEACON-RP · 7 hops · 1h ago", so the number has a neighbor behind it. Long names truncate so the tile keeps its height. The snapshot computes it with one indexed query alongside the other contact counts, so nothing new is polled.
On a fresh install the dashboard was about fifteen identical "No data." boxes and empty donut rings, which read as broken at exactly the moment an operator wants to know the new bot is working. Real-time said "Nothing heard yet" forever, with no sense of whether the silence was normal.
Until the snapshot has seen any node or packet, the dashboard now shows one panel in place of the grid:
- What the station is doing, from the same verdict as the strip: listening, starting, not running (start the bot), or the radio problem in the way.
- What fills in first: nodes from adverts, then paths and hop counts, then one-hop neighbors.
- That timing depends on the mesh.
- For admins, buttons to watch Real-time and check the radio settings; both pages are admin-only, so anonymous visitors get the panel without them.
The grid comes back on its own once anything is heard.
Real-time's empty tape now says "Listening since 2:28 PM (12 min) · nothing heard yet", counting each second from the bot's start time, which /api/banner-status now reports while the bot runs. After ten minutes with nothing, it adds one line of diagnosis: that can be normal on a quiet mesh, otherwise check the frequency and preset in Radio settings, or the Logs. When the bot isn't running or the radio can't receive, it says that instead.
Recoveries passed in silence. When the banner poll saw the radio come back, the offline or zombie banner simply vanished; clearing the flag by hand replaced the banner's markup with "Offline flag cleared … Refresh to confirm", so a refresh was the only confirmation. Worse, that replacement was permanent: if the radio dropped again on the same page, the banner came back showing the stale success message (and its Since time was gone). The zombie banner had the same bug.
The banner poll now notices when the bot starts running again, the radio answers again, reconnects, or comes back online. It shows one notice for the most basic of these, e.g. "Radio back online at 2:23 PM · offline for 42 min, since 1:41 PM". The notice dismisses itself after 20 seconds or on close. The server keeps no start time for a dropped radio link, so for that case the page notes when it first saw the link down. The clear and restart buttons no longer touch their banner: they wait with a spinner, poll again sooner, and reset once the flag clears, so the banner is intact if the radio drops again.
Real-time records both kinds of event on the tape as small dividers in time order:
- A recovery gets the same line with a green dot.
- A dropped socket gets a line when it reconnects. The reconnect replays the newest 50 rows; if the oldest of them reaches back past the drop, the line says "Link lost 2:28–2:31 PM · nothing missed" (and drops under 15 seconds aren't mentioned). Otherwise the line says which span isn't shown, with an amber dot, and the 10-minute spark draws that span as unknown instead of quiet.
Clear tape removes the dividers, and at most 20 are kept.
The dashboard's health strip and Real-time's receiver strip both said all was well whenever the viewer itself answered. On a database the bot had never run against, the strip showed a green "Online · radio ok" beside "bot down?", and Real-time a green "Listening": /api/health reported "healthy" with no bot at all, and "radio ok" only meant the zombie flag was unset.
/api/banner-status, which every page already polls every 10 seconds, now also says whether the bot is running and whether its radio is connected. The stored start time survives the bot stopping, so "running" comes from the system_health heartbeat the bot rewrites every 30 seconds: older than two minutes, or missing, and the bot isn't there. radio_connected is only reported while it is, since it's the bot's last word on the link. The poll also runs once on load instead of waiting 10 seconds.
base.html turns that into one verdict for both pages: Bot starting, Bot not running (with when it was last seen), Radio not responding, Radio disconnected, Radio can't transmit, or Listening. The lamp colors follow DESIGN.md: red for not working, amber for degraded (sends failing while receiving may still work), green only when the radio is listening. Real-time says "Checking the bot…" until the first answer instead of assuming. Uptime shows a dash while the bot isn't running, in place of "bot down?".
The change chips compared the last two complete days even when one of them had recorded nothing, so a day the bot was down showed as a 100% collapse on every tile. When either day has no packets, adverts or messages, the chips stay neutral and the note under the tiles names the day: "No change shown: nothing was recorded on Oct 23." A fresh install, where both days are empty, gets the neutral chips without the note.
The navbar's connection text changed silently, so a screen-reader user wasn't told when the viewer lost or regained its link to the bot. It becomes a status region after the first update, which keeps the routine connect on every page load quiet and announces later changes; it's only rewritten when the text actually changes.
The Logs stream scrolls but couldn't take focus, so keyboard users couldn't scroll it. It is now a focusable region named "Bot log stream". It is deliberately not a live region: announcing every log line would drown everything else.
Screen-reader users skim a page by its headings, and several outlines were broken:
- The dashboard had no h1; it now has a visually hidden "Dashboard" heading ahead of the health strip.
- Feeds and Radio stat cards made the number the heading ("heading level 2, 4") under an h5 label. The label is now the h2 and the number a styled paragraph, and their card sections are h2s instead of h5s straight after the h1. Radio's uppercase group labels become h3s. Feeds' error count toggles its color classes instead of replacing them, which had dropped the readout size.
- Logs went from h1 to h6 for its stream card; it is an h2.
- Config's panel titles are h2s, their group labels h3s, and the database table names (built in JS) h4s.
Visual sizes are unchanged: each heading keeps its old look through Bootstrap's .h5/.h6 classes.
11 of 17 modals had no aria-labelledby, so they announced as an unnamed "dialog". Each now points at its modal title.
axe-core reports no violations on any of 15 pages in either theme.
Font Awesome draws its glyphs as CSS content, and none of the viewer's 235 icons (plus the ones pages build in JS) were hidden from assistive tech, so private-use glyphs ended up in the accessible names of headings and buttons: the dashboard's Mesh heading was " MESH". Font Awesome's JS normally marks icons aria-hidden; the viewer loads only its CSS.
A small script at the top of base.html does the same: every i.fas/far/fab/fa gets aria-hidden="true" unless it has an aria-label, in which case it gets role="img" so the label is valid. A MutationObserver covers icons that pages add later, and a label added after the fact un-hides an icon the script had hidden. That also fixes Radio's slot-limit info icon, whose aria-label was prohibited on an <i> with no role.
Tag chips draw their readout color on a 14% tint of itself, and on that tint several fell short of 4.5:1: orange 4.2:1 on the dashboard in light, blue 4.47:1 on dark cards and 3.76:1 across Greeter's dark table (207 chips). The readout colors move a step darker in light and lighter in dark so each holds 4.5:1 on its own tint over paper, paper-sunk, night-raised and night-sunk; violet in light already did.
Multibyte's "MB Relay" and "Direct MB" filters used Bootstrap's outline info and success, whose text is the fill tone (1.95:1 and 3.03:1). They now resolve through --text-info and --text-success like the other outline buttons.
axe-core finds no contrast failures on any page in either theme.
Chrome makes Leaflet's interactive SVG shapes Tab stops even without a tabindex, so the Mesh page put about 120 unnamed stops with no focus ring between its toolbar and the rest of the page. After each render the overlay and marker shapes get tabindex="-1" and their SVGs aria-hidden; the map container stays focusable for arrow-key panning and +/- zoom. The Tab walk on /mesh drops from 147 stops to 22.
The map and graph had no accessible name. They are now labeled "Mesh map" and "Mesh graph" (the graph as an image), described by a visually hidden note: both are visual only, the map pans with the arrow keys, and the counts above, Search Node and the Contacts page carry the same information as text.
Screen readers navigate by landmark, but only /config had a <main>; everywhere else the page content sat outside any landmark, and keyboard users tabbed through about 20 navbar links before reaching it. base.html now wraps the content block in <main id="main-content">, labels the navbar "Main", and opens every page with a "Skip to content" link that appears on focus and moves focus into the main region. The login card is the login page's main.
/config had its own <main>, which would have nested inside the new one, so its panel column is a plain div. Its sidebar group names (Core, Database, Admin Tools) were h6 headings ahead of the page's h5 sections, which broke the heading order and left three unlabeled navigation landmarks; they are now paragraphs that label their nav.
Every color on the Mesh page now comes from `--mesh-*` tokens declared at the top of mesh.html. The map and graph have their own light/dark toggle, separate from the site theme, so the tokens switch on `.mesh-dark` and the JS reads them back through `meshColor()`. The viridis heat colors are unchanged.
Edges colored by recency used the status lamps (green, amber, orange, red) with no legend, so the default 72-hour view was mostly amber lines that read as warnings. They now fade one magenta-rose hue with age, under a new "Last heard" legend that hides while a selection recolors edges by direction. Unknown ages are solid gray, since dashes already mean single-byte evidence. Opacity steps more gently because the color carries the fade.
Room servers move from lamp green to Route Teal. A selected node or traced path is ringed or drawn in ink instead of amber, and keeps its own fill. Direction from a selected node uses the dashboard's series blue (incoming), orange (outgoing) and violet (both ways). The stat chips color each value like what it counts on the map; Avg km is plain.
Legends, popups, tooltips, zoom controls and the fullscreen button use the tokens, and popup labels move from #888 (3.5:1) to the muted panel color. Switching the map theme in graph view recolors the graph in place instead of rerunning its layout.
DESIGN.md documents the palette. Checked at 1400px and 390px in both themes, with a selection and in heat mode, with no console errors; 366 web viewer tests pass.
Bootstrap 5.1.3, Font Awesome 6.0.0, Socket.IO 4.7.2 and Chart.js 4.4.0 are now served from static/vendor/ at the versions the CDNs served, so every page works on a LAN with no internet. Only map tiles still need it. The Content-Security-Policy drops jsDelivr and cdnjs.
Every vendored package, the map stack and login fonts included, is pinned in scripts/web-vendor/package.json and its lockfile. sync.mjs copies the listed files into static/, strips the source-map comment, empties each directory it owns first, and regenerates the version table in static/vendor/README.md; it reproduces the previously vendored files byte for byte. Font Awesome ships only its woff2 fonts, which every supported browser takes before the .ttf.
Dependabot watches that directory: security fixes as advisories land, grouped minor and patch releases monthly, majors one per PR. Dependabot can't regenerate the vendored files, so vendor-refresh.yml adds them to its PRs as a second commit and dispatches the tests, since a GITHUB_TOKEN push doesn't trigger them. It runs on pull_request_target, so it uses the base branch's sync.mjs, spells out its commands instead of running the PR's npm scripts, and installs with install scripts disabled. A new CI job re-runs the sync and fails if anything under static/ changes.
Tests now check that no template loads a script, stylesheet or font from another host, that every vendored file a template names exists, and that the CSP allows no CDN host. In Chrome, 12 pages load with no external request, failed load or console error.
My fork of meshcore-decoder is now at 0.4.0, which brings it in line with current MeshCore firmware: encrypted PATH payloads, GRP_DATA and MULTIPART, region transport codes, the new request and text types, and the firmware's packet hash. 0.4.0 isn't published to npm, so build.sh now fetches the fork at a pinned commit (60733c0) and installs its runtime dependencies from the lockfile, still with install scripts disabled. Two builds produce byte-identical bundles.
The browser entry exports the same additions as the fork's lite entry (AnonRequestType, TextType and their name functions, PathPayloadDecoder) plus the region transport-code helpers.
The byte breakdown's hash fallback now uses the decoder's packetHash, which is the firmware hash the server sends. The old fallback hashed the hex text and never matched.
All 72,828 packets in the bot's database decode to the same payload type and path as the bot's own decoder, and packetHash matches the stored packet_hash on every one. Advert names and Public decryption are unchanged from 0.3.0. In the browser, Real-time loads without console errors, and 2-byte-hop packets, including a decrypted Public message, show the right header, path and payload in the byte breakdown.
- base.html adds shared readout colors (blue, teal, violet, orange), a row-action component (per-action color with a light outline, as on Contacts) and tinted tag chips
- Radio and Feeds stat tiles read in their own colors; Feeds' Active Errors turns red only when there are errors
- Greeter's 311 solid red "Mark Ungreeted" buttons are red-outline row actions, Manual/Rollout are tinted chips instead of lamp green and amber, and names are plain text instead of pink code
- Feeds' four solid row buttons use the row-action style
- Radio's writes are primary blue; amber stays only on the frequency/bandwidth write, which changes the channel for every node on it
- the dashboard's ranked lists (commands, users, channels, repeaters, paths) show counts as tinted chips in each list's own color with a muted rank, instead of lamp-colored pills
The polish passes had turned these gray in the name of keeping lamp colors for state. They now carry color that means something:
- Real-time's filter lenses take the colors of what they show (Everything in the link blue, Messages in the message blue, Commands in the bot's violet), filling when chosen; Pause fills while the tape is paused, and the 10-minute activity bars are blue
- the byte breakdown's segments are fuller tints (34% instead of 20%) with darker or lighter ink, 5.4:1 and up in light and 11.9:1 in dark, and each segment card's swatch is solid
- Contacts' Active Contacts counts read in the link blue and its Advertisements counts in teal, with the card icons matching
Contacts and Cache inserted their alerts at the top of the content, so every save, star or error pushed the whole page down, and Radio, Feeds and the connection manager each pinned their own fixed alert at the same spot, so two messages covered each other. base.html now has one toast stack (window.viewerToast) that floats over the page below the navbar, or along the bottom on a phone, keeps at most four, announces them politely (errors as alerts) and takes either a string or a node the caller filled with text. Every page routes through it.
The Contacts polish had turned the row actions into muted bare icons, which made them hard to find and to tell apart. Each action now has its own color and a light outline: a gold star (filled when starred), blue info, teal location and red delete, with icon contrast from 3.8:1 in light to 8.7:1 in dark. The new-device counts take their role colors, matching the chips and the activity chart.
Fixes from a rendered pass at desktop and phone width in both themes:
- stat tiles: counts are grouped and set as tabular readouts in the text color, so 257,052 and "Companions" no longer break mid-number or mid-word, and the blue, teal and green number colors are gone
- device roles are tinted chips in the dashboard's categorical data colors (repeater blue, companion orange, room server indigo, sensor teal) instead of lamp amber and green badges, and the activity chart reads the same --role-* tokens; the chips measure 6.6:1 or better
- SNR, distance, bytes/hop and advert counts are plain tabular readings, with a dash for unknown values instead of N/A and green or amber SNR badges
- the hop count that opens a contact's paths is a button (it was a span with a click handler, so unreachable by keyboard), labeled with the hop and path counts
- row actions are bare icons that take the link color on hover, red only for delete; a starred contact shows a filled star with aria-pressed
- sort icons stay attached to their header labels, and the table still fits at 1440px with all four actions
- paths modal: "N paths this radio has heard to <name>" instead of "from observed_paths table", correct plurals ("1 hop", "heard 2 times"), path bytes in the text color in both themes instead of Bootstrap's pink code color, a neutral Primary chip instead of a green badge and border, scrollable and full-screen on phones
- advert modal: the JSON wraps instead of running off the window, and a missing contact shows the page's error toast instead of a blocking alert()
- phone cards label distance and SNR and drop the bare "N/A · N/A" line; the sort select wraps under "Select all on page" with its full label
- "Just now" is no longer green
The web viewer's static/js/meshcore-browser.mjs was an esbuild bundle of @michaelhart/meshcore-decoder at 0.2.7 or older, added without a record of its version or how it was built. Those versions read the path-length byte as a plain byte count, so every packet with 2- or 3-byte hops was misparsed from the path onward: wrong header and type, a path split into single bytes, garbage payload fields, garbled advert names and group text that never decrypted.
The bundle is rebuilt from 0.3.0, which takes the hop count from bits 0-5 and the hash size from bits 6-7. Twelve real packets from the bot's database (adverts, group text, direct messages and returned paths at 1, 2 and 3 bytes per hop) decode to the same hop count, path and payload as the bot's own decoder; multibyte adverts get their real names and a 2-byte-hop Public message decrypts, on the tape and in the byte breakdown.
scripts/meshcore-decoder-browser/ holds the browser entry the old bundle was built from (recovered from the bundle: it re-exports the library and routes Ed25519's SHA-512 through crypto-js so signatures verify on a plain-HTTP page) and build.sh, which fetches the published package, installs its runtime dependencies with install scripts disabled and bundles it with a pinned esbuild in a temporary directory. It reproduces the committed bundle byte for byte. The unused meshcore-browser.min.mjs is removed, and the multibyte workarounds from the byte breakdown polish (skipping the browser decode and saying so) are taken out again.
The bundled browser decoder reads the path-length byte as a plain byte count, so on a packet with 2- or 3-byte hops it misplaces everything after it. The modal took its header fields, path and payload from that decode first: a flood group-text packet (0x15) showed as 0x3D Custom, the path came out as dozens of single bytes, and the advert and decrypted sections read garbage. The header now comes from the byte on the wire, path and payload from the bot's own decode, and multibyte packets skip the browser decode with a note saying why. The tape skips browser decoding for them too ("multibyte path, not decoded here") rather than risk a garbled advert name or a false "no key here".
The rest is the polish pass:
- the modal shows the 16-character packet hash the tape, search and Copy hash use, not the decoder's 8-character one
- byte ranges account for transport codes ("Byte 4", not a fixed "Bytes 0-0"), paths are grouped by hop and payloads by byte so the hex wraps between bytes, and the path-length byte's bits are explained (hop count in bits 0-5, hash size in bits 6-7)
- colors come from the base.html theme tokens, light first with dark overrides, replacing a separate bluish dark palette and its light-mode patches; byte segments use the dashboard's categorical data colors as tints (5.3:1 and up in light, 8.9:1 in dark) instead of lamp green, amber and red, the hash is no longer amber and the binary column no longer red
- one level of cards: the raw bytes are a single well instead of a card holding a box with a 4px amber stripe, the segment cards carry a swatch that ties them to the bytes, and labels follow the viewer's small-caps style
- Copy raw hex is the primary button, and copying falls back to execCommand when the Clipboard API is missing (plain-HTTP LAN viewers), saying so if the browser still refuses
On a phone the strip was forced onto one line with items allowed to shrink below their text, so once the tape was held and the "N new · show" button joined Pause, "heard 10 s ago" ran into the rate and the button's label spilled out of it. The readings now keep their width, the controls drop to their own line at the right when there isn't room, and the button reads just "N new" on a phone (its accessible name keeps the full wording).
Real-time was three equal panels (commands, packets, messages) that each scrolled on their own, couldn't be held still, and showed the same transmission up to three times. It's now one tape with one row per transmission: a packet, the channel message decrypted from it and a command it triggered share a row, joined by packet hash, and the bot's reply hangs under the command with how many times a repeater was heard echoing it. Those echoes fold into the reply instead of getting rows of their own, and a packet heard several times is one row with a count and every reception (SNR, RSSI, hops, path) when opened.
A receiver strip at the top says whether the bot is listening or the radio is offline or not responding (from the banner poll base.html already runs, now published as a banner-status event), when it last heard a packet, the packet rate and a 10-minute activity bar. The tape holds still while you read it: Pause (P), scrolling down, a moving mouse pointer or keyboard focus queue new rows behind an "N new" button. Rows can be kept (safe from the 300-row limit and from Clear), filtered to messages or commands, by packet type or channel, and searched (/). Replayed history sits under a "Heard before you opened this page" divider, and adverts and keyed group text are decoded in the browser and labeled as such. Each row's glyph chip carries its packet type's icon in the dashboard chart's color for that type; decoded messages get a stronger tint, and the bot's commands and replies are violet.
Server side, command rows carry trigger_packet_hash (from the message's routing info) and, once the transmission tracker matches a repeat, reply_packet_hash. Replayed rows carry stream_ts and replayed, and live rows stream_ts. Commands stored before this pair with their message only on an exact sender, channel and text match within 20 seconds.
Real-time is admin-only now: its socket already was, so anonymous visitors got three "Admin login required" badges over spinners that never filled. They're redirected to login and the nav link is hidden from them.
The byte breakdown sliced multibyte paths by hop count, so 2- and 3-byte hops spilled into the payload; it uses the path's byte count and shows the raw path-length byte. Route types 0 and 2 were swapped, and the channel key store was often built before the decoder had loaded, so it never decrypted anything. js-sha256 is vendored under static/vendor instead of loading from cdnjs.
The pre-Bootstrap-5 #007bff and #28a745 (and their rgba forms) still sat on the navbar brand, status lamps, dark form focus, the conversation rail, the Contacts focus ring, the login page's brand tokens, and Realtime's hex path and newest-row glow. They now use Signal Blue (#0d6efd) and Lamp Green (#198754), which also brings the hex path's white text from 3.1:1 to 4.5:1. The brand stays above 3:1 on the rail as large bold text. The unused .log-entry, .command-entry and .packet-entry rules in base.html are removed.
Realtime packet rows carried an 11-color stripe down the left of each rounded card, repeating what the colored type badge in the row already says. The stripes are gone, so the stream reads like the dashboard's cards.
DESIGN.md adds the 2px (hair) and 5px (meter) radii that legend swatches, focus rings and meters already use, drops the drift notes these changes resolve, and records the touch-target sizing from the adapt pass.
The radio offline and zombie banners printed the raw UTC ISO string (2026-10-15T14:42:58.701279+00:00). They now show the time in the viewer's own time zone with a relative age, such as "Oct 15, 7:42 AM · 2 days ago" (just the time when it's today), in a <time> element that keeps the exact value in datetime= and a full date in its tooltip. The 10-second banner poll reformats it, so the age stays current.
Every page loaded Chart.js (~200KB), Moment and js-sha256 from a CDN whether it used them or not. Chart.js now loads only on the dashboard, Contacts and Multibyte, and js-sha256 only on Realtime, through a page_libs block in base.html. Moment is gone: its one call wrote to a #last-update element that no page renders, and that formatting is now done natively. The remaining base scripts stay at the end of <body>, so they don't block first paint.
The Mesh page loaded Leaflet 1.9.4, MapLibre GL 5.6.0, maplibre-gl-leaflet 0.1.4 and vis-network 9.1.9 from unpkg, and the login page loaded DM Sans and Outfit from Google Fonts, so neither worked on a LAN without internet. They are now vendored under static/vendor/ and static/fonts/ with their licenses and a README recording versions and sources (source-map comments stripped, since the maps aren't vendored). With nothing left on unpkg or Google Fonts, the CSP drops those hosts; the characterization golden is regenerated for that. package-data gains a fourth directory level for Leaflet's images.
Bootstrap, Font Awesome, Socket.IO, Chart.js and js-sha256 still come from CDNs; PRODUCT.md records that as the remaining offline gap.
On a phone most controls were under 32px: Bootstrap's btn-sm, form-select-sm and 16px checkboxes are used everywhere, with 103 of 214 controls on Contacts and 61 of 127 on Plugins under 24px. A shared (pointer: coarse) block in base.html grows standard controls to 44px and small ones to 36px, checkboxes, radios and switches to 24px, and dropdown items, nav links, close buttons, summaries and Leaflet's zoom buttons to match. The dashboard's bare icon buttons and the log level toggles get the same treatment in their own stylesheets. Desktop with a mouse is unchanged. What remains under 32px is the 24px checkboxes and inline text links, both within WCAG 2.5.8.
Bigger controls squeezed some card-header titles into a column, so card headers now wrap their controls onto a second row when both don't fit.
/config was 429px wide on a 390px phone because its single-column grid used 1fr, which can't shrink below its widest child; it now uses minmax(0, 1fr). The radio offline and zombie banners took about 200px of a phone screen; below 576px they drop the secondary sentence (the reconnect warning stays), put the time on its own line and tighten their padding, for 132px. Their small text also loses an opacity that put it at 3.96:1.
Realtime message times were 9.3px (0.58rem), Realtime meta chips and phone Mesh stat labels 9.9px, Mesh, Plugins and Contacts badges about 10.5px, and the Contacts path count compounded to 10.5px as a <small> inside a badge. All now sit at the 0.7rem floor DESIGN.md sets, and the path count inherits the badge size in a lighter weight. Phone layouts still fit at 390px.
Monospace was five different stacks, three of them ending in 'Courier New'. base.html now defines --font-mono (the ui-monospace stack from DESIGN.md) and points Bootstrap's --bs-font-monospace at it, and logs, realtime, mesh, contacts, greeter, config and the legacy .log-entry all read it.
Bootstrap's text-success, text-warning, text-info and text-danger are fill tones, so used as text they failed contrast (amber on white was 1.6:1, info 2.0:1), and nothing adjusted them for dark mode. base.html now defines per-theme status text tokens that pass 4.5:1 on every surface tier, and the text utilities, text-muted, form-text, code, links and the outline buttons resolve through them. Info and warning badges carry dark text. Ink Muted moves a step darker so it holds on paper-sunk.
The dark message bubbles on Realtime were solid navy, forest green and purple, which overpowered the page and dropped the sender names to 1.7:1. They are now low-alpha tints of the signal and lamp hues over the card, like the dashboard delta chips, and the error bubble is red in both themes instead of purple in dark.
Also: the light log levels and level toggles move to tones that pass on the log well, the dark ERROR toggle uses dark text, Realtime drops its local orange text-warning and its opacity on message times, and the four copies of the dark .table td * rule no longer force body text color onto badges and buttons. DESIGN.md records the tokens and the rule that a fill hue never goes on text directly.
A scan of the rendered pages found about 300 Plugins settings fields whose visible label wasn't tied to the control, so screen readers announced them without names. renderField now links each label (and its help text, via aria-describedby) to the control it renders. Contacts gets names for its search box, export range, purge threshold and the select-all and per-row checkboxes ("Select <name>"); the Radio create-channel labels gain for=; and the Multibyte time range and repeater search get aria-labels.
Icon-only buttons were named only by title, or not at all: the Realtime scroll buttons, the Mesh toolbar, contact row actions, the Plugins remove and reveal buttons, the Feeds and Radio row actions, every Bootstrap close button and the navbar toggler. Each now has an aria-label (scoped where it helps, such as "Scroll packets to newest" or "Remove Webhook 2"), with its icon hidden from assistive tech. Toggles that hold state expose it: the Mesh hot-connections button and the password reveal buttons set aria-pressed. The Plugins filter box and the dynamic key/value fields also get names, and repeating blocks name their enable switch after the block.
The Feeds form labels had no for=, so screen readers announced the fields without names, and the new-channel box, the three JSON config boxes and the Greeter search relied on placeholder text alone. Labels are now linked to their fields, the unlabeled boxes get aria-label, and the Output Format Help button sits beside its label instead of inside it. The empty spacer label above Preview Format is now a hidden div, and the accordion toggles declare aria-expanded and aria-controls.
Packet rows with raw hex only opened their detail modal on a mouse click. They now act as buttons (role, tabindex, Enter and Space), show the hover treatment plus a focus ring when focused, and closing the modal sends focus back to the row that opened it, since Bootstrap doesn't do that for a modal opened from script.
The header that opens a plugin's settings was a <div> with a click handler, so Expand all was the only keyboard route into a card. The title area is now a <button> with aria-expanded and aria-controls, kept separate from the enable switch, and Expand/Collapse all update it through the same path. The enable switch also gets an accessible name, since it had no label.
The sort headers were plain <th> cells with a click handler, so keyboard and screen reader users couldn't sort. Each header label is now a <button> inside the <th>, the active column carries aria-sort, and the focus ring follows the header text color. The Hops info tip sits outside the sort button, so opening it no longer re-sorts the table.
Fixes from a Codex review of the previous commit, plus the shared settings the rebase onto dev showed were still missing.
Settings that misstated or changed what runs:
- sports never read [Sports_Command] api_timeout (documented in the example config); the new field did nothing. It now sets the ESPN timeout.
- The path-byte requirement help on test, multitest and path said "bytes per hop"; the gate checks the path's total size (at least 2, or exactly 3).
- feed's allow_private_urls showed false while it inherited true from [Feed_Manager]. Fields can now name an inherit_section whose value they show when unset.
- notify_on_prefix_bytes was an int field for a value the service reads as a list (2,3), so the form refused a valid setting.
- The greeter's form defaults differed from the fallbacks it uses when the keys are missing. Both now come from shared constants.
- The MQTT weather limits accepted values the service clamps (payload 256 to 1 MiB, staleness 5 s to 7 days, length 64 to 4000).
Shared settings: the wx card now carries the [Weather] temperature, wind and precipitation units and the three temperature_*_format templates (with previews through format_temperature_high_low), next to wx's own [Wx_Command] unit overrides, which default to "Same as [Weather]". Two fields with one key in different sections are told apart as key@Section in the save payload; a bare key still resolves when only one field has it. An enum's "" option, like a blank number, removes the key. Packet capture brokers gain username, password, topic prefix, JWT timings and include_decoded; blank inheriting values are left out of block saves, since the service reads them with getint/getboolean when present.
Previews:
- They use the bot's saved config: a blank test format falls back to [Keywords] test, multitest lays out paths per condense_paths, and MQTT applies passthrough_max_length.
- A quoted empty template ("") counts as blank, as the commands strip the quotes first.
- A str.format width or precision over 500 isn't rendered, so `{sender:100000000}` can't make the endpoint build a huge string.
- The page invalidates an in-flight preview as soon as the field changes, and shows the server's note about fallbacks instead of guessing it.
The endpoint inventory golden gains the preview route (401 for anonymous requests when a password is set).
The Plugins page now has a field for every setting the plugins read. I compared each plugin's settings_schema against config.ini.example and the keys the code reads; fourteen plugins had gaps (greeter, trace, stats, path, wx/gwx, rain, test, multitest, sports, worldcup, cmd, feed, the webhook, weather, packet capture, earthquake and prefix-collision services) and the World Cup service had no form at all. Webhook URLs, bot tokens and channel keys use the password field. A guard test needed four [Rain_Command] keys documented in config.ini.example.
Template fields get a { } button that opens a reference of the placeholders they take, with click-to-insert. Fields rendered by the piped engine (test's response_format, path's reply_prefix) also list the filters and conditionals. Every template field previews as you type: piped fields against a 2-byte, a 1-byte and a direct sample message, so a conditional's effect shows before saving, and the str.format fields (greeter, mesh info, multitest, MQTT weather) as their plugin renders them, including what it sends when the template has a mistake. The specs and renderers live in modules/template_reference.py; the preview endpoint only renders and never saves. The greeter's channel_greetings parser and multi-part split moved into module functions so the command and the preview share them.
Fixes the new fields would otherwise have exposed:
- Clearing a number field saved `key =`, which makes getint/getfloat raise and the plugin fail to load. A cleared number now removes the key.
- The weather service raised in __init__ when only some blitz_area_* corners were set. It now needs all four and logs a warning otherwise.
- A blank MQTT weather json_template saved an empty template, so every reply said no message had arrived. Blank now saves the default.
Channel-delivered region warnings were hard-coded to scope='*', on the belief that a sender with no region could not hear a scoped message. That is not how MeshCore works: the companion firmware's filterRecvFloodPacket drops nothing, so a client accepts a regional flood whatever region it has set itself, and only repeaters decide how far it goes. The override only meant that a bot configured with outgoing_flood_scope_override flooded the whole mesh to tell someone not to, which is exactly what #306 reports (the log shows the override 'was not applied (explicit scope="*")').
The warning now resolves its scope like any other proactive channel send: [Region_Warnings] flood_scope, then flood_scope.<channel>, then outgoing_flood_scope_override, then global. Its body is sized with channel_body_budget so the regional overhead is accounted for, and the web viewer's byte limit reserves that overhead whenever a regional scope could apply. flood_scope = * restores the old behavior.
* feat(web_viewer): split anonymous read access from an admin session
When web_viewer_password is set, Dashboard, Contacts, and Mesh Graph are readable without logging in, along with the read-only APIs they need. Radio, Config, Logs, every mutating API, /api/channels, and the live Socket.IO streams require an admin session, since those can expose channel keys and decrypted traffic. The Realtime page still renders publicly but shows "Admin login required" instead of connecting. Anything not on the public allowlist fails closed.
Login now compares HMAC digests with hmac.compare_digest and clears the session before setting authenticated_admin. Logout clears the whole session and returns to the dashboard.
Co-authored-by: Gerard Hickey <hickey@kinetic-compute.com>
* fix(web_viewer): stop Contacts redeclaring IS_ADMIN and hide banner admin buttons
contacts.html declared its own top-level const IS_ADMIN, but base.html already declares one in an earlier script on the same page. Classic scripts share one global lexical scope, so the redeclaration threw a SyntaxError and the whole Contacts script never ran, leaving the table on its loading placeholder in every mode. Contacts now uses the global from base.html.
The zombie-recover and clear-offline banner buttons were still rendered for anonymous viewers even though their /api/admin/* endpoints now return 401. They are gated on the same auth_enabled/is_admin check as the other admin controls.
* fix(web_viewer): drop sockets on logout, block off-site login redirects, allow HEAD on public paths
Logging out cleared the HTTP session but left that login's Socket.IO connections open, and a socket keeps the session it connected with, so a /realtime tab kept streaming packets, decrypted messages, and logs after logout. Each login now gets an admin_login_id, sockets record it on connect, and logout disconnects the sockets opened under that login. Other admin logins stay connected.
The login next check passed ///host and /\host because urlparse sees no netloc in either, but browsers resolve both as another origin. Next targets are now rejected if they start with //, contain a backslash, or contain a tab or newline. This check predates the admin split.
Public pages and GET APIs only accepted literal GET, so HEAD (and OPTIONS) on them returned 401 and made uptime checks report the viewer as down. They now accept HEAD and OPTIONS too; admin paths are unchanged.
* docs(changelog): note the login redirect fix
* fix(web_viewer): pass IS_ADMIN through a body data attribute so ESLint can parse base.html
The frontend lint job failed with "Parsing error: Unexpected token {" because base.html's inline script assigned IS_ADMIN from a raw Jinja expression, which isn't valid JS until rendered. The flag now lives in data-is-admin on <body> and the script reads it from document.body.dataset. I also dropped the unused AUTH_ENABLED constant and declared IS_ADMIN as a readonly ESLint global, since contacts.html reads it from base.html.
---------
Co-authored-by: Gerard Hickey <hickey@kinetic-compute.com>
Region scopes had to be hand-edited in config.ini, and the radio's own default
region could not be set from the bot at all. Both now live on the Radio page.
Node Settings gains a Default Region Scope field beside the path hash size,
which is the firmware's own setting — NodePrefs.default_scope_name and
default_scope_key, the region the radio falls back to for any send the bot does
not scope itself. Firmware that has no such command is reported as not having
answered rather than shown as an empty field, and a stored key that is not the
stored name's hash is flagged, because the radio routes by the key and the name
beside it is only a label. A build-flag default stores the bare name while
hashing the '#' form, so the comparison normalizes first.
Clearing it does not go through the meshcore library, because none of its reset
paths can: set_default_flood_scope(None) raises on len(None), "" earns
ILLEGAL_ARG from the firmware, and "*" only works because the frame is padded by
character count rather than by the name it wrote, landing one byte short of the
length the firmware reads as "a scope follows". The firmware's own contract for
clearing is a bare CMD_SET_DEFAULT_FLOOD_SCOPE, so that is what goes out. The
same padding bug is why a device scope name must be ASCII: a multi-byte
character displaces the transport key out of its field.
A separate Region Scopes card covers the bot's side — [Channels] flood_scopes as
an explicit choice between "reply whatever the scope" and an allowlist, and
outgoing_flood_scope_override. It writes config.ini, queues a hot reload, then
polls that reload and reports what the bot did with it, including saying plainly
when nothing picked it up. Per-channel flood_scope.<channel> entries are listed
read-only, since they are the reason a channel can ignore the default.
The two settings interact in a way worth stating on the page: once the bot sends
a scoped message it restores with set_flood_scope("*"), which leaves the radio in
forced-unscoped mode, so the device default stops applying until the bot scopes
another send.
Also from building it:
- flood_scopes left in [Bot] is surfaced rather than hidden. CommandManager
still honours it when [Channels] is empty, so a page that ignored it would
show "replies to every scope" while the bot enforced an allowlist — and
saving "off" hands the allowlist straight back to [Bot], which the banner now
says instead of claiming the old key stops mattering.
- outgoing_flood_scope_override = none is read as global flood on the send path,
as it already was everywhere else. send_channel_message tested the raw value
against a fixed tuple, so the lowercase spelling became the region "#none".
- Scope normalization moved to modules/flood_scope.py so the viewer, a separate
process, can validate a typed name without importing the bot's command
machinery. CommandManager delegates to it.
- Dark mode named only .border, so a .border-start divider drew Bootstrap's
light #dee2e6 onto a dark card. All four directional utilities now match.
The known-contact gate for DM delivery returns before anything is recorded, so
a bot that keeps no contacts dropped every warning and wrote no rows: the page
showed "No warnings decided yet" indefinitely beside a status card saying it
was sending, and nothing distinguished a clean mesh from one where every
warning was being discarded. Only a debug log said otherwise.
Withheld warnings are now counted per local day in bot_metadata — a counter
rather than event rows, because this fires ahead of the cooldowns that would
rate-limit rows, so logging each one would bury the decisions worth reading.
The status card shows it, and the empty log explains itself and points at
channel delivery.
Also from the verification pass:
- The percent test built its value with config.set and a doubled %, which
set() is the one input that cannot produce the bug. It now uses read_string
with a bare %, and I checked it fails against raw=False by substituting
DEFAULT_MESSAGE — the actual failure mode.
- docs still said the bot identifies itself by public key, contradicting the
section forty lines above. On this path it is name-only, and that means the
self-exemption is spoofable; the doc says so.
- delivered_today folded dry runs in with real sends, so a morning's preview
read as afternoon transmissions. previewed_today is now separate and the
figure follows the current mode.
- TRIM(MIN(channel)) so a whitespace-padded historical row cannot become the
display name for a merged channel.
**A `global` verdict now requires RF correlated to the message.** It was also
reachable through `_is_confirmed_global_flood`'s argument-from-absence route
("no scope-eligible packet in the window, therefore unscoped"), so a row that
literally said TC_FLOOD, or a scoped ADVERT that the GRP_TXT filter excluded,
came back as "no region code". That inference is fine for deciding whether a
`*` in flood_scopes authorizes a reply; it is not fine for accusing someone of
a misconfiguration. Channel messages still correlate through the payload match
(#255), so the ordinary case is unaffected.
**A `%` in the warning message no longer wedges config reload.** `_get` read
without `raw=True`, so configparser's interpolation raised, the error was
swallowed, and the bot transmitted the default wording instead. Worse, the
bot's own `_validate_config_snapshot` iterates `config.items(section)` and
would reject every hot reload until the file was hand-edited. The save endpoint
now rejects `%` outright and caps the message at 500 characters, and the read
is raw so a hand-edited value is still honored.
**One unreachable sender no longer eats the daily cap forever.** The cap counted
failed attempts but the per-sender cooldown did not, so a node the radio cannot
reach was retried every `min_unscoped_messages` messages indefinitely and no
real offender was ever warned. Both count attempts now. The regression test
fails with three sends against the old filter.
**The sender is a display name, not an identity.** MeshCore's CHANNEL_MSG_RECV
carries no public key, so `sender_pubkey` was always empty on this path and the
only identity is a prefix anyone with the channel key can forge. DM delivery
now requires a contact the radio already holds, which bounds the bot to nodes
it knows and stops failed sends spending cap slots. Two tests asserted the
opposite because the fixture supplied a pubkey the radio never sends; they now
run with what the call site actually passes, and the docs no longer claim
pubkey identity.
Also: a negative `max_warnings_per_day` fell back instead of clamping to the
"unlimited" sentinel; tallies group case-insensitively so a `#` or case change
does not split a channel; retention uses the same clock the rows are written
in; the status figure shows delivered with attempts beside it, rather than a
count of four next to "last warning: none yet"; and the channel bars are scaled
over classified traffic so their width equals the percentage printed beside
them (33.7% was drawn at 28.7%).
An empty daily-volume chart rendered as an 84px hole with a date range under
it, which reads as broken rather than as no data; it is hidden until there is
something to plot, and the summary says so in words.
"Count only" disabled the rest of the form, which implied those values would
not be saved — they are — and stopped anyone drafting their wording before
turning warnings on. A sentence under the mode selector says it instead.
Bootstrap's .text-warning is #ffc107, which is 1.6:1 on a white card — the
headline percentage and the "daily cap reached" line were close to invisible
in light mode. The chart fills had the same shape of problem in reverse: the
grey "couldn't tell" band sat at 2.1:1 against a white card and 2.9:1 against
a dark one.
Each color is now a token chosen per theme against the surface it lands on:
text clears 4.5:1, fills clear 3:1. Measured in the browser in both themes.
An unselected chip looked identical to a selected one, so clicking it was a
coin flip between adding and removing. The chips now reflect the field, in
both directions, and stay in step when the list is typed by hand.
Also re-read after a save rather than patching the form in place (the status
strip's budget is derived from the cap that was just changed), and stop the
reset-to-default button from blanking the message when the page never loaded.
Closes#279.
A MeshCore client with no region configured sends every channel message as
a plain FLOOD, which every repeater on the mesh rebroadcasts. This adds two
things: free observation of how much of that the bot hears, and an opt-in
warning to the senders.
Observation classifies each channel message as scoped, global or unknown and
tallies it per channel per local day. It costs one upsert and no airtime, and
it is what lets an operator see the size of the problem before deciding to
spend airtime on it. The classification runs ahead of the flood_scopes
allowlist, because an unscoped message is exactly what that allowlist drops.
Warnings only ever fire on positive RF evidence of an unscoped FLOOD. Absence
of correlation is not proof that a sender omitted a region, so it classifies
as unknown and stays quiet. They are off by default, start in dry run, and are
fenced by min_unscoped_messages, a per-sender cooldown, a mesh-wide cooldown
and a daily cap on attempts. Dry run consumes the same budget it previews, so
the log is what going live would put on the air, not an upper bound. All three
limits read from the database, so a restart cannot release a burst.
Channel-delivered warnings go out at global scope on purpose: the recipient is
by definition outside any region the bot replies under.
New Settings -> Region Warnings page in the web viewer covers all of it. Its
dark-mode striped rows exposed a pre-existing base.html bug where Bootstrap's
light-theme text color survived on a dark row background (~1.3:1); fixed there
for every table in the app.
Add a password type to settings_schema so plugin secrets render as masked inputs and never reach the browser. Stored values stay in config.ini; an empty box on save leaves an existing secret unchanged.