M2: emoji → Phosphor Icons — page headers & table chrome (#1648) (#1650)

Red commit: df6a406a89 (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2F1648-m2-headers-tables)

Partial fix for #1648 (M2 of 6). Do NOT close the tracking issue.

M2 covers page headers + table chrome: section glyphs, refresh/action
buttons, status pills, payload-type icon maps. Heavy on analytics.js.

## Per-file swap counts

| file | swaps |
| --- | --- |
| public/analytics.js | 89 |
| public/nodes.js | 29 |
| public/packets.js | 30 |
| public/live.js | 30 |
| public/map.js | 11 |
| public/perf.js | 9 |
| public/audio-lab.js | 5 |
| public/node-analytics.js | 4 |
| public/table-sort.js | 1 |
| public/traces.js | 1 |
| **total** | **209** |

Plus 48 new Phosphor SVG symbols vendored into
`public/icons/phosphor-sprite.svg`
(regular weight, alphabetical): arrows-out, battery-high, battery-low,
bomb,
book-open, buildings, caret-down, caret-up, cell-signal-high,
chart-line, chats,
check-circle, clipboard-text, clock, crosshair, dice-five, envelope,
flame,
gear, globe, graph, handshake, house-line, info, key, link,
list-numbers,
lock-open, map-pin, microphone, path, piano-keys, prohibit, pulse,
push-pin,
question, radio, repeat, ruler, share-network, shuffle, signpost,
speaker-high,
target, thermometer, trend-up, trophy, x-circle. Total sprite now 82
symbols, ~35 KB.

## Tests

- Static scan: `test-issue-1648-m2-emoji-scan.js` asserts ZERO emoji
  codepoints (U+1F300–1FAFF, U+2600–27BF) and zero misc-icon chars
  (◆●■▲★☆○✓✗⚠✉) in each M2 file, plus a minimum `<use href="…#ph-…">`
  ref count per file.
- E2E: `test-issue-1648-m2-icons-e2e.js` — 15 Chromium assertions
  (test-issue-1648-m2-icons-e2e.js:31–245) covering /analytics, /packets
  filter row, /nodes table chrome, /live audio + feed buttons, /map
  controls h3 + toggle, /traces, /perf, /audio-lab loop button, plus a
  sprite-resolution check (every rendered `<use>` resolves to a defined
  `<symbol>` — i.e. no `.notdef` glyph fallback). E2E assertion added:
  `test-issue-1648-m2-icons-e2e.js:96`.
- Both wired into `.github/workflows/deploy.yml` E2E block.

Anti-tautology proof: reverting the audio-lab.js Packet Data h3 swap
(restoring `📦`) flips the static scan from PASS to assertion failure
`actual: 1, expected: 0` (audio-lab.js emoji-hit count check). Verified
locally before push.

## Browser verification

Local Chromium against `corescope-server -port 13581` + e2e fixture DB.
Screenshots of /analytics, /nodes, /packets, /live, /map at 1200×900 and
375×812. No `.notdef` glyphs; theme toggle preserved; sprite resolves on
every page.

## Out of scope (carried forward)

- customize.js / customize-v2.js NODE_EMOJI + PACKET_TYPE_EMOJI configs
**[M5]**
- `cmd/server/routes.go` L567-574 onboarding-tile emoji **[M6]**
- home.js welcome cards 🌱  etc. **[M3]**
- route-view overlays (route-view-utils.js, route-view.js,
hop-display.js, path-inspector.js) **[M4]**
- channels.js modals + footer 💬 📋 🔒 **[M5]**
- roles.js NODE_SHAPE_EMOJI (used by route-view, not M2) **[M4]**
- packets.js L2169 expand caret swapped (was `▶/▼`); other ▶ in
audio-lab
  alabPlay button left as-is — out of M2 range (U+25B6 ≠ emoji).

Adheres to rule 34: no `Fixes #1648`, no auto-close.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
This commit is contained in:
Kpa-clawbot
2026-06-11 00:42:33 -07:00
committed by GitHub
co-authored by openclaw-bot
parent 55e4d957b1
commit 3062745437
22 changed files with 843 additions and 267 deletions
+2
View File
@@ -398,6 +398,8 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1510-live-nav-pin-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-fullscreen-1572-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1599-replay-freeze-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1648-m1-icons-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1648-m2-icons-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1367-channels-chat-app-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt
+95 -95
View File
@@ -98,7 +98,7 @@
app.innerHTML = `
<div class="analytics-page">
<div class="analytics-header">
<h2>📊 Mesh Analytics</h2>
<h2><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> Mesh Analytics</h2>
<p class="text-muted">Deep dive into your mesh network data</p>
<div class="analytics-filters">
<div id="analyticsRegionFilter" class="region-filter-container"></div>
@@ -369,7 +369,7 @@
<div class="analytics-row">
<div class="analytics-card flex-1">
<h3>📈 Packets / Hour</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-trend-up"/></svg> Packets / Hour</h3>
${(() => {
const pph = rf.packetsPerHour;
const counts = pph.map(h => h.count);
@@ -394,18 +394,18 @@
<div class="analytics-row">
<div class="analytics-card flex-1">
<h3>📦 Payload Type Mix</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-package"/></svg> Payload Type Mix</h3>
${renderPayloadPie(rf.payloadTypes)}
</div>
<div class="analytics-card flex-1">
<h3>🔗 Hop Count Distribution</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-link"/></svg> Hop Count Distribution</h3>
${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
</div>
</div>
<div class="analytics-row">
<div class="analytics-card flex-1">
<h3>📡 Relay Airtime Share</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Relay Airtime Share</h3>
<p class="text-muted" style="margin-top:-4px;font-size:12px">Score = payload bytes × distinct repeaters that forwarded the packet. Counts relay re-transmissions; originator TX excluded. Not comparable across meshes.</p>
${renderRelayAirtimeDumbbell(d.airtimeData)}
</div>
@@ -427,7 +427,7 @@
var widget = document.createElement('div');
widget.className = 'analytics-row';
widget.innerHTML = '<div class="analytics-card flex-1">' +
'<h3>🔍 Neighbor Affinity Graph</h3>' +
'<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Neighbor Affinity Graph</h3>' +
'<div class="stats-grid">' +
'<div class="stat-card"><div class="stat-value">' + s.totalEdges + '</div><div class="stat-label">Total Edges</div></div>' +
'<div class="stat-card"><div class="stat-value">' + s.totalNodes + '</div><div class="stat-label">Total Nodes</div></div>' +
@@ -525,7 +525,7 @@
el.innerHTML = `
<div class="analytics-row">
<div class="analytics-card flex-1">
<h3>📶 SNR Distribution</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-cell-signal-high"/></svg> SNR Distribution</h3>
<p class="text-muted">Signal-to-Noise Ratio (higher = cleaner signal)</p>
${snrHist.svg}
<div class="rf-stats">
@@ -537,7 +537,7 @@
</div>
</div>
<div class="analytics-card flex-1">
<h3>📡 RSSI Distribution</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> RSSI Distribution</h3>
<p class="text-muted">Received Signal Strength (closer to 0 = stronger)</p>
${rssiHist.svg}
<div class="rf-stats">
@@ -552,7 +552,7 @@
<div class="analytics-row">
<div class="analytics-card flex-1">
<h3>🎯 SNR vs RSSI Scatter</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-target"/></svg> SNR vs RSSI Scatter</h3>
<p class="text-muted">Each dot = one packet. Cluster position reveals link quality.</p>
${renderScatter(rf.scatterData)}
</div>
@@ -560,17 +560,17 @@
<div class="analytics-row">
<div class="analytics-card flex-1">
<h3>📊 SNR by Payload Type</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> SNR by Payload Type</h3>
${renderSNRByType(rf.snrByType)}
</div>
<div class="analytics-card flex-1">
<h3>📈 Signal Quality Over Time</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-trend-up"/></svg> Signal Quality Over Time</h3>
${renderSignalTimeline(rf.signalOverTime)}
</div>
</div>
<div class="analytics-card">
<h3>📏 Packet Size Distribution</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-ruler"/></svg> Packet Size Distribution</h3>
<p class="text-muted">Raw packet length in bytes</p>
${histogram(rf.packetSizes, 25, '#8b5cf6').svg}
<div class="rf-stats">
@@ -693,7 +693,7 @@
el.innerHTML = `
<div class="analytics-row">
<div class="analytics-card flex-1">
<h3>🔗 Hop Count Distribution</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-link"/></svg> Hop Count Distribution</h3>
<p class="text-muted">Number of repeater hops per packet</p>
${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
<div class="rf-stats">
@@ -704,7 +704,7 @@
</div>
</div>
<div class="analytics-card flex-1">
<h3>🕸️ Top Repeaters</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-graph"/></svg> Top Repeaters</h3>
<p class="text-muted">Nodes appearing most in packet paths</p>
${renderRepeaterTable(topo.topRepeaters)}
</div>
@@ -712,25 +712,25 @@
<div class="analytics-row">
<div class="analytics-card flex-1">
<h3>🤝 Repeater Pair Heatmap</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-handshake"/></svg> Repeater Pair Heatmap</h3>
<p class="text-muted">Which repeaters frequently appear together in paths</p>
${renderPairTable(topo.topPairs)}
</div>
<div class="analytics-card flex-1">
<h3>📊 Hops vs SNR</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> Hops vs SNR</h3>
<p class="text-muted">Does more hops = worse signal?</p>
${renderHopsSNR(topo.hopsVsSnr)}
</div>
</div>
<div class="analytics-card">
<h3>🏆 Best Path to Each Node</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-trophy"/></svg> Best Path to Each Node</h3>
<p class="text-muted">Shortest hop distance seen across all observers</p>
${renderBestPath(topo.bestPathList)}
</div>
<div class="analytics-card">
<h3>🌐 Per-Observer Reachability</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-globe"/></svg> Per-Observer Reachability</h3>
<p class="text-muted">Nodes at each hop distance, from each observer's perspective</p>
${topo.observers.length > 1 ? `<div class="observer-selector" id="obsSelector">
${topo.observers.map((o, i) => `<button class="tab-btn ${i === 0 ? 'active' : ''}" data-obs="${o.id}">${esc(o.name)}</button>`).join('')}
@@ -740,7 +740,7 @@
</div>
${topo.multiObsNodes.length ? `<div class="analytics-card">
<h3>🔀 Cross-Observer Comparison</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-shuffle"/></svg> Cross-Observer Comparison</h3>
<p class="text-muted">Nodes seen by multiple observers — hop distance varies by vantage point</p>
${renderCrossObserver(topo.multiObsNodes)}
</div>` : ''}
@@ -832,7 +832,7 @@
function renderAllObserversReach(perObserverReach) {
let html = '';
for (const [obsId, data] of Object.entries(perObserverReach)) {
html += `<h4 style="margin:12px 0 6px">📡 ${esc(data.observer_name)}</h4>`;
html += `<h4 style="margin:12px 0 6px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> ${esc(data.observer_name)}</h4>`;
html += renderPerObserverReach(perObserverReach, obsId);
}
return html || '<div class="text-muted">No data</div>';
@@ -947,7 +947,7 @@
'<td>' + c.messages + '</td>' +
'<td>' + c.senders + '</td>' +
'<td>' + timeAgo(c.lastActivity) + '</td>' +
'<td>' + (c.encrypted ? (c.group === 'mine' ? '🔑' : '🔒') : '✅') + '</td>' +
'<td>' + (c.encrypted ? (c.group === 'mine' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-key"/></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg>') : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg>') + '</td>' +
'</tr>';
}
@@ -975,8 +975,8 @@
// Empty-name encrypted rows would otherwise leak through with an
// empty <strong> in the row; force the placeholder rendering.
copy.displayName = !isNaN(hashNum)
? '🔒 Encrypted (0x' + hashNum.toString(16).toUpperCase().padStart(2, '0') + ')'
: '🔒 Encrypted';
? '🔒 Encrypted (0x' + hashNum.toString(16).toUpperCase().padStart(2, '0') + ')' // EMOJI-OK: displayName data field consumed by tests
: '🔒 Encrypted'; // EMOJI-OK: displayName data field consumed by tests
copy.group = 'encrypted';
} else {
// Server gave us a real name (rainbow table hit) for an encrypted ch.
@@ -1024,9 +1024,9 @@
(groups[g] || (groups[g] = [])).push(sorted[gi]);
}
var sections = [
{ key: 'mine', label: '🔑 My Channels' },
{ key: 'network', label: '📻 Network' },
{ key: 'encrypted', label: '🔒 Encrypted' },
{ key: 'mine', label: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-key"/></svg> My Channels' },
{ key: 'network', label: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-radio"/></svg> Network' },
{ key: 'encrypted', label: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> Encrypted' },
];
for (var si = 0; si < sections.length; si++) {
var rows = groups[sections[si].key] || [];
@@ -1111,7 +1111,7 @@
el.innerHTML =
'<div class="analytics-card">' +
'<h3>📻 Channel Activity</h3>' +
'<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-radio"/></svg> Channel Activity</h3>' +
'<p class="text-muted">' + ch.activeChannels + ' active channels, ' + ch.decryptable + ' decryptable</p>' +
'<table class="analytics-table" id="channelsTable">' +
channelTheadHtml(_channelSortState.col, _channelSortState.dir) +
@@ -1122,16 +1122,16 @@
'</div>' +
'<div class="analytics-row">' +
'<div class="analytics-card flex-1">' +
'<h3>💬 Messages / Hour by Channel</h3>' +
'<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chat-circle"/></svg> Messages / Hour by Channel</h3>' +
timelineHtml +
'</div>' +
'<div class="analytics-card flex-1">' +
'<h3>🗣️ Top Senders</h3>' +
'<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-megaphone"/></svg> Top Senders</h3>' +
topSendersHtml +
'</div>' +
'</div>' +
'<div class="analytics-card">' +
'<h3>📊 Message Length Distribution</h3>' +
'<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> Message Length Distribution</h3>' +
histoHtml +
'</div>';
@@ -1275,7 +1275,7 @@
})() : ''}
</div>
<div class="analytics-card flex-1">
<h3>📈 Hash Size Over Time</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-trend-up"/></svg> Hash Size Over Time</h3>
${renderHashTimeline(data.hourly)}
</div>
</div>
@@ -1309,7 +1309,7 @@
var capByPubkey = {};
(caps || []).forEach(function(c) { capByPubkey[c.pubkey] = c; });
var statusIcon = { confirmed: '✅', suspected: '⚠️', unknown: '❓' };
var statusIcon = { confirmed: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg>', suspected: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>', unknown: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-question"/></svg>' };
var statusLabel = { confirmed: 'Confirmed', suspected: 'Suspected', unknown: 'Unknown' };
var statusColor = { confirmed: 'var(--success, #22c55e)', suspected: 'var(--warning, #eab308)', unknown: 'var(--text-muted, #888)' };
@@ -1345,7 +1345,7 @@
'<td><strong>' + esc(r.name) + '</strong></td>' +
'<td><span class="badge" style="background:' + roleColor + '20;color:' + roleColor + '">' + esc(r.role || 'unknown') + '</span></td>' +
'<td><span style="color:' + (statusColor[r.status] || statusColor.unknown) + '">' +
(statusIcon[r.status] || '') + ' ' + (statusLabel[r.status] || 'Unknown') + '</span></td>' +
(statusIcon[r.status] || '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-question"/></svg>') + ' ' + (statusLabel[r.status] || 'Unknown') + '</span></td>' +
'<td><span class="badge badge-hash-' + r.hashSize + '">' + r.hashSize + '-byte</span></td>' +
'<td>' + r.packets + '</td>' +
'<td>' + (r.lastSeen ? timeAgo(r.lastSeen) : '—') + '</td>' +
@@ -1370,9 +1370,9 @@
'</div>' +
'<div style="display:flex;gap:4px;flex-wrap:wrap" id="mbCapFilters">' +
'<button class="tab-btn active" data-mb-filter="all">All (' + rows.length + ')</button>' +
'<button class="tab-btn" data-mb-filter="confirmed" style="--filter-color:var(--success, #22c55e)"> Confirmed (' + counts.confirmed + ')</button>' +
'<button class="tab-btn" data-mb-filter="suspected" style="--filter-color:var(--warning, #eab308)">⚠️ Suspected (' + counts.suspected + ')</button>' +
'<button class="tab-btn" data-mb-filter="unknown" style="--filter-color:var(--text-muted, #888)"> Unknown (' + counts.unknown + ')</button>' +
'<button class="tab-btn" data-mb-filter="confirmed" style="--filter-color:var(--success, #22c55e)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> Confirmed (' + counts.confirmed + ')</button>' +
'<button class="tab-btn" data-mb-filter="suspected" style="--filter-color:var(--warning, #eab308)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Suspected (' + counts.suspected + ')</button>' +
'<button class="tab-btn" data-mb-filter="unknown" style="--filter-color:var(--text-muted, #888)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-question"/></svg> Unknown (' + counts.unknown + ')</button>' +
'</div>' +
'</div>' +
'<div id="mbAdoptersTableWrap">' + buildTableContent(rows, 'all') + '</div>' +
@@ -1437,25 +1437,25 @@
async function renderCollisionTab(el, data, collisionData) {
el.innerHTML = `
<nav id="hashIssuesToc" style="display:flex;gap:12px;margin-bottom:12px;font-size:13px;flex-wrap:wrap">
<a href="#/analytics?tab=collisions&section=inconsistentHashSection" style="color:var(--accent)"> Inconsistent Sizes</a>
<a href="#/analytics?tab=collisions&section=inconsistentHashSection" style="color:var(--accent)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Inconsistent Sizes</a>
<span style="color:var(--border)">|</span>
<a href="#/analytics?tab=collisions&section=hashMatrixSection" style="color:var(--accent)">🔢 Hash Matrix</a>
<a href="#/analytics?tab=collisions&section=hashMatrixSection" style="color:var(--accent)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-list-numbers"/></svg> Hash Matrix</a>
<span style="color:var(--border)">|</span>
<a href="#/analytics?tab=collisions&section=collisionRiskSection" style="color:var(--accent)">💥 Collision Risk</a>
<a href="#/analytics?tab=collisions&section=collisionRiskSection" style="color:var(--accent)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-bomb"/></svg> Collision Risk</a>
<span style="color:var(--border)">|</span>
<a href="#/analytics?tab=prefix-tool" style="color:var(--accent)">🔎 Check a prefix </a>
<a href="#/analytics?tab=prefix-tool" style="color:var(--accent)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Check a prefix </a>
</nav>
<p class="text-muted" style="margin:0 0 12px;font-size:0.78em">Collisions <strong>actually observed in packet traffic</strong> among <strong>repeaters</strong> grouped by their configured hash size. For <em>theoretical</em> address conflicts that <em>would</em> occur if all repeaters used a given hash size, see the <a href="#/analytics?tab=prefix-tool" style="color:var(--accent)">Prefix Tool</a> tab.</p>
<div class="analytics-card" id="inconsistentHashSection">
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0"> Inconsistent Hash Sizes</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)"> top</a></div>
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Inconsistent Hash Sizes</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)"> top</a></div>
<p class="text-muted" style="margin:4px 0 8px;font-size:0.8em">Repeaters and room servers sending adverts with varying hash sizes in the last 7 days. Originally caused by a <a href="https://github.com/meshcore-dev/MeshCore/commit/fcfdc5f" target="_blank" style="color:var(--accent)">firmware bug</a> where automatic adverts ignored the configured multibyte path setting, fixed in <a href="https://github.com/meshcore-dev/MeshCore/releases/tag/repeater-v1.14.1" target="_blank" style="color:var(--accent)">repeater v1.14.1</a>. Companion nodes are excluded.</p>
<div id="inconsistentHashList"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading…</div></div>
</div>
<div class="analytics-card" id="hashMatrixSection">
<div style="display:flex;justify-content:space-between;align-items:center">
<h3 style="margin:0" id="hashMatrixTitle">🔢 Hash Usage Matrix</h3>
<h3 style="margin:0" id="hashMatrixTitle"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-list-numbers"/></svg> Hash Usage Matrix</h3>
<a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a>
</div>
<div style="display:flex;align-items:center;gap:16px;margin:8px 0">
@@ -1470,7 +1470,7 @@
</div>
<div class="analytics-card" id="collisionRiskSection">
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0" id="collisionRiskTitle">💥 Collision Risk</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0" id="collisionRiskTitle"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-bomb"/></svg> Collision Risk</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
<div id="collisionList"><div class="text-muted" style="padding:8px">Loading…</div></div>
</div>
`;
@@ -1480,7 +1480,7 @@
const ihEl = document.getElementById('inconsistentHashList');
if (ihEl) {
if (!inconsistent.length) {
ihEl.innerHTML = '<div class="text-muted" style="padding:4px"> No inconsistencies detected — all nodes are reporting consistent hash sizes.</div>';
ihEl.innerHTML = '<div class="text-muted" style="padding:4px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> No inconsistencies detected — all nodes are reporting consistent hash sizes.</div>';
} else {
ihEl.innerHTML = `<table class="analytics-table" style="background:var(--card-bg);border:1px solid var(--border);border-radius:8px;overflow:hidden">
<thead><tr><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Current Hash</th><th scope="col">Sizes Seen</th></tr></thead>
@@ -1519,8 +1519,8 @@
const matrixTitle = document.getElementById('hashMatrixTitle');
const matrixDesc = document.getElementById('hashMatrixDesc');
const riskTitle = document.getElementById('collisionRiskTitle');
if (matrixTitle) matrixTitle.textContent = bytes === 3 ? '🔢 Hash Usage Matrix' : `🔢 ${bytes}-Byte Hash Usage Matrix`;
if (riskTitle) riskTitle.textContent = `💥 ${bytes}-Byte Collision Risk`;
if (matrixTitle) matrixTitle.innerHTML = bytes === 3 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-list-numbers"/></svg> Hash Usage Matrix' : `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-list-numbers"/></svg> ${bytes}-Byte Hash Usage Matrix`;
if (riskTitle) riskTitle.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-bomb"/></svg> ${bytes}-Byte Collision Risk`;
if (matrixDesc) {
if (bytes === 1) matrixDesc.textContent = 'Cells include the first byte of all repeaters — including those using 2- or 3-byte prefixes — so this reflects real conflicts in the 1-byte hash space. Click a cell to see the nodes.';
else if (bytes === 2) matrixDesc.textContent = 'Each cell = first-byte group. Color shows worst 2-byte collision within. Click a cell to see the breakdown.';
@@ -1663,7 +1663,7 @@
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${collisionCount > 0 ? 'var(--status-red)' : 'var(--border)'}${collisionCount > 0 ? ';cursor:pointer' : ''}" ${collisionCount > 0 ? 'onclick="document.getElementById(\'collisionRiskSection\')?.scrollIntoView({behavior:\'smooth\',block:\'start\'})"' : ''} ${collisionCount > 0 ? 'title="Click to see collision details"' : ''}>
<div class="analytics-stat-label">Prefix collisions</div>
<div class="analytics-stat-value" style="color:${collisionCount > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${collisionCount}${collisionCount > 0 ? ' <span style="font-size:11px;opacity:0.7"></span>' : ''}</div>
<div class="analytics-stat-value" style="color:${collisionCount > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${collisionCount}${collisionCount > 0 ? ' <span style="font-size:11px;opacity:0.7"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-down"/></svg></span>' : ''}</div>
</div>
</div>`;
}
@@ -1855,7 +1855,7 @@
const groupNodes = info.group_nodes || [];
let dhtml = `<strong class="mono" style="font-size:1.1em">0x${hex}__</strong> — ${groupNodes.length} node${groupNodes.length !== 1 ? 's' : ''} in group`;
if ((info.collision_count || 0) === 0) {
dhtml += `<div class="text-muted" style="margin-top:6px;font-size:0.85em"> No 2-byte collisions in this group</div>`;
dhtml += `<div class="text-muted" style="margin-top:6px;font-size:0.85em"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> No 2-byte collisions in this group</div>`;
dhtml += `<div style="margin-top:8px">${groupNodes.map(m => {
const prefix = m.public_key.slice(0,4).toUpperCase();
return `<div style="padding:2px 0"><code class="mono" style="font-size:0.85em">${prefix}</code> <a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a></div>`;
@@ -1884,8 +1884,8 @@
if (!collisions.length) {
const cleanMsg = bytes === 3
? ' No 3-byte prefix collisions detected — all repeaters have unique 3-byte prefixes.'
: ` No ${bytes}-byte collisions detected`;
? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> No 3-byte prefix collisions detected — all repeaters have unique 3-byte prefixes.'
: `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> No ${bytes}-byte collisions detected`;
el.innerHTML = `<div class="text-muted" style="padding:8px">${cleanMsg}</div>`;
return;
}
@@ -1904,16 +1904,16 @@
<tbody>${collisions.map(c => {
let badge, tooltip;
if (c.classification === 'local') {
badge = `<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within ${t50} — likely true collision, same RF neighborhood">🏘️ Local</span>`;
badge = `<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within ${t50} — likely true collision, same RF neighborhood"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-buildings"/></svg> Local</span>`;
tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision';
} else if (c.classification === 'regional') {
badge = `<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes ${t50}${t200} apart — edge of LoRa range, could be atmospheric"> Regional</span>`;
badge = `<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes ${t50}${t200} apart — edge of LoRa range, could be atmospheric"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lightning"/></svg> Regional</span>`;
tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links';
} else if (c.classification === 'distant') {
badge = `<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >${t200} apart — beyond typical 915MHz range">🌐 Distant</span>`;
badge = `<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >${t200} apart — beyond typical 915MHz range"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-globe"/></svg> Distant</span>`;
tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
} else {
badge = '<span class="badge" style="background:#6b7280;color:#fff"> Unknown</span>';
badge = '<span class="badge" style="background:#6b7280;color:#fff"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-question"/></svg> Unknown</span>';
tooltip = 'Not enough coordinate data to classify';
}
const nodes = c.nodes || [];
@@ -1933,9 +1933,9 @@
}).join('')}</tbody>
</table>
<div class="text-muted" style="padding:8px;font-size:0.8em">
<strong>🏘 Local</strong> &lt;${t50}: true prefix collision, same mesh area &nbsp;
<strong> Regional</strong> ${t50}${t200}: edge of LoRa range, possible atmospheric propagation &nbsp;
<strong>🌐 Distant</strong> &gt;${t200}: beyond 915MHz range internet bridge, MQTT gateway, or separate networks
<strong><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-buildings"/></svg> Local</strong> &lt;${t50}: true prefix collision, same mesh area &nbsp;
<strong><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lightning"/></svg> Regional</strong> ${t50}${t200}: edge of LoRa range, possible atmospheric propagation &nbsp;
<strong><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-globe"/></svg> Distant</strong> &gt;${t200}: beyond 915MHz range internet bridge, MQTT gateway, or separate networks
</div>`;
}
async function renderSubpaths(el) {
@@ -1967,7 +1967,7 @@
const prefixDisplay = rawHops.join(' → ');
return `<tr data-hops="${esc(rawHops.join(','))}" ${hasSelfLoop ? 'class="subpath-selfloop"' : ''} style="cursor:pointer">
<td>${i + 1}</td>
<td>${routeDisplay}${hasSelfLoop ? ' <span title="Contains self-loop — likely 1-byte prefix collision" style="cursor:help">🔄</span>' : ''}<br><span class="hop-prefix mono">${esc(prefixDisplay)}</span></td>
<td>${routeDisplay}${hasSelfLoop ? ' <span title="Contains self-loop — likely 1-byte prefix collision" style="cursor:help"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-arrow-clockwise"/></svg></span>' : ''}<br><span class="hop-prefix mono">${esc(prefixDisplay)}</span></td>
<td>${s.count.toLocaleString()}</td>
<td>${s.pct}%</td>
<td><div style="background:${hasSelfLoop ? 'var(--status-yellow)' : 'var(--accent)'};height:14px;border-radius:3px;width:${barW}%;opacity:0.7"></div></td>
@@ -1979,7 +1979,7 @@
el.innerHTML = `
<div class="subpath-layout">
<div class="subpath-list" id="subpathList">
<h3>🛤️ Route Pattern Analysis</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-path"/></svg> Route Pattern Analysis</h3>
<p>Click a route to see details. Most common subpaths — reveals backbone routes, bottlenecks, and preferred relay chains.</p>
<label style="display:inline-flex;align-items:center;gap:6px;margin-bottom:12px;cursor:pointer;font-size:0.9em">
<input type="checkbox" id="hideCollisions" aria-label="Hide likely prefix collisions" ${localStorage.getItem('subpath-hide-collisions') === '1' ? 'checked' : ''}> Hide likely prefix collisions (self-loops)
@@ -2059,7 +2059,7 @@
</div>
${nodesWithLoc.length >= 2 ? `<div class="subpath-section">
<h5>📏 Hop Distances</h5>
<h5><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-ruler"/></svg> Hop Distances</h5>
${(() => {
const dists = [];
let total = 0;
@@ -2084,7 +2084,7 @@
${hasMap ? '<div id="subpathMap" style="height:200px;border-radius:8px;margin:12px 0;border:1px solid var(--border,#e5e7eb)"></div>' : ''}
<div class="subpath-section">
<h5>📡 Observer Receive Signal</h5>
<h5><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Observer Receive Signal</h5>
<p class="text-muted" style="font-size:0.8em;margin:0 0 4px">Last hop observer only, not between nodes in the route</p>
${data.signal.avgSnr != null
? `<div>Avg SNR: <strong>${data.signal.avgSnr} dB</strong> · Avg RSSI: <strong>${data.signal.avgRssi} dBm</strong> · ${data.signal.samples} samples</div>`
@@ -2092,7 +2092,7 @@
</div>
<div class="subpath-section">
<h5>🕐 Activity by Hour (UTC)</h5>
<h5><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-clock"/></svg> Activity by Hour (UTC)</h5>
<div class="hour-chart">
${data.hourDistribution.map((c, h) => `<div class="hour-bar" title="${h}:00 UTC — ${c} packets" style="height:${Math.max(2, c / maxHour * 100)}%"></div>`).join('')}
</div>
@@ -2107,13 +2107,13 @@
${data.observers.length ? `
<div class="subpath-section">
<h5>👁️ Observers</h5>
<h5><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> Observers</h5>
${data.observers.map(o => `<div>${esc(o.name)}: ${o.count}</div>`).join('')}
</div>` : ''}
${data.parentPaths.length ? `
<div class="subpath-section">
<h5>🔗 Full Paths Containing This Route</h5>
<h5><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-link"/></svg> Full Paths Containing This Route</h5>
<div class="parent-paths">
${data.parentPaths.map(p => `<div class="parent-path"><span class="mono" style="font-size:0.85em">${esc(p.path)}</span> <span class="text-muted">×${p.count}</span></div>`).join('')}
</div>
@@ -2187,26 +2187,26 @@
return `<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">${esc(n.name || n.public_key.slice(0, 12))}</a>`;
}
function claimedBadge(n) {
return myKeys.has(n.public_key) ? ' <span style="color:var(--accent);font-size:10px"> MINE</span>' : '';
return myKeys.has(n.public_key) ? ' <span style="color:var(--accent);font-size:10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-star-fill"/></svg> MINE</span>' : '';
}
// ROLE_COLORS from shared roles.js
el.innerHTML = `
<div class="analytics-section">
<h3>🔍 Network Status</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Network Status</h3>
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:20px">
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
<div style="font-size:28px;font-weight:700;color:var(--status-green)">${active}</div>
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🟢 Active</div>
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)"><span style="color:var(--status-green)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span> Active</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
<div style="font-size:28px;font-weight:700;color:var(--status-yellow)">${degraded}</div>
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🟡 Degraded</div>
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)"><span style="color:var(--status-yellow)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span> Degraded</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
<div style="font-size:28px;font-weight:700;color:var(--status-red)">${silent}</div>
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🔴 Silent</div>
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)"><span style="color:var(--status-red)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span> Silent</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
<div style="font-size:28px;font-weight:700">${totalNodes}</div>
@@ -2214,7 +2214,7 @@
</div>
</div>
<h3>📊 Role Breakdown</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> Role Breakdown</h3>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px">
${Object.entries(roleCounts).sort((a,b) => b[1]-a[1]).map(([role, count]) => {
const c = ROLE_COLORS[role] || '#6b7280';
@@ -2240,7 +2240,7 @@
</tbody>
</table>` : ''}
<h3>🏆 Most Active Nodes</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-trophy"/></svg> Most Active Nodes</h3>
<table class="analytics-table" style="margin-bottom:24px">
<thead><tr><th scope="col">#</th><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Total Packets</th><th scope="col">Packets Today</th><th scope="col">Analytics</th></tr></thead>
<tbody>
@@ -2250,12 +2250,12 @@
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
<td>${n.health.stats.totalTransmissions || n.health.stats.totalPackets || 0}</td>
<td>${n.health.stats.packetsToday || 0}</td>
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link" aria-label="Per-node analytics"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg></a></td>
</tr>`).join('')}
</tbody>
</table>
<h3>📶 Best Signal Quality</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-cell-signal-high"/></svg> Best Signal Quality</h3>
<table class="analytics-table" style="margin-bottom:24px">
<thead><tr><th scope="col">#</th><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Avg SNR</th><th scope="col">Observers</th><th scope="col">Analytics</th></tr></thead>
<tbody>
@@ -2265,12 +2265,12 @@
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
<td>${n.health.stats.avgSnr.toFixed(1)} dB</td>
<td>${n.health.observers?.length || 0}</td>
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link" aria-label="Per-node analytics"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg></a></td>
</tr>`).join('')}
</tbody>
</table>
<h3>👀 Most Observed Nodes</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> Most Observed Nodes</h3>
<table class="analytics-table" style="margin-bottom:24px">
<thead><tr><th scope="col">#</th><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Observers</th><th scope="col">Avg SNR</th><th scope="col">Analytics</th></tr></thead>
<tbody>
@@ -2280,7 +2280,7 @@
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
<td>${n.health.observers?.length || 0}</td>
<td>${n.health.stats.avgSnr != null ? n.health.stats.avgSnr.toFixed(1) + ' dB' : '—'}</td>
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link" aria-label="Per-node analytics"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg></a></td>
</tr>`).join('')}
</tbody>
</table>
@@ -2294,7 +2294,7 @@
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
<td>${timeAgo(n.health.stats.lastHeard)}</td>
<td>${n.health.stats.packetsToday || 0}</td>
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link" aria-label="Per-node analytics"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg></a></td>
</tr>`).join('')}
</tbody>
</table>
@@ -2340,7 +2340,7 @@
}
// Top hops leaderboard
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">From</th><th scope="col">To</th><th scope="col">Distance (${distUnitLabel})</th><th scope="col">Type</th><th scope="col">Obs</th><th scope="col">Best SNR</th><th scope="col">Median SNR</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
html += `<div class="analytics-section"><h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-trophy"/></svg> Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">From</th><th scope="col">To</th><th scope="col">Distance (${distUnitLabel})</th><th scope="col">Type</th><th scope="col">Obs</th><th scope="col">Best SNR</th><th scope="col">Median SNR</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
const top20 = data.topHops.slice(0, 20);
top20.forEach((h, i) => {
const fromLink = h.fromPk ? `<a href="#/nodes/${encodeURIComponent(h.fromPk)}" class="analytics-link">${esc(h.fromName)}</a>` : esc(h.fromName || '?');
@@ -2349,7 +2349,7 @@
const medianSnr = h.medianSnr != null ? Number(h.medianSnr).toFixed(1) + ' dB' : '<span class="text-muted">—</span>';
const obs = h.obsCount != null ? h.obsCount : 1;
const pktLink = h.hash ? `<a href="#/packet/${encodeURIComponent(h.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(h.hash.slice(0, 12))}…</a>` : '—';
const mapBtn = h.fromPk && h.toPk ? `<button class="btn-icon dist-map-hop" data-from="${esc(h.fromPk)}" data-to="${esc(h.toPk)}" title="View on map">🗺️</button>` : '';
const mapBtn = h.fromPk && h.toPk ? `<button class="btn-icon dist-map-hop" data-from="${esc(h.fromPk)}" data-to="${esc(h.toPk)}" title="View on map" aria-label="View on map"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-trifold"/></svg></button>` : '';
const tsTitle = h.timestamp ? `Best observation: ${h.timestamp}` : '';
html += `<tr title="${esc(tsTitle)}"><td>${i+1}</td><td>${fromLink}</td><td>${toLink}</td><td><strong>${formatDistance(h.dist)}</strong></td><td>${esc(h.type)}</td><td>${obs}</td><td>${bestSnr}</td><td>${medianSnr}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
});
@@ -2357,7 +2357,7 @@
// Top paths
if (data.topPaths.length) {
html += `<div class="analytics-section"><h3>🛤️ Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">Total Distance (${distUnitLabel})</th><th scope="col">Hops</th><th scope="col">Route</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
html += `<div class="analytics-section"><h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-path"/></svg> Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">Total Distance (${distUnitLabel})</th><th scope="col">Hops</th><th scope="col">Route</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
data.topPaths.slice(0, 10).forEach((p, i) => {
const route = p.hops.map(h => esc(h.fromName)).concat(esc(p.hops[p.hops.length-1].toName)).join(' → ');
const pktLink = p.hash ? `<a href="#/packet/${encodeURIComponent(p.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(p.hash.slice(0, 12))}…</a>` : '—';
@@ -2365,7 +2365,7 @@
const pathPks = [];
p.hops.forEach(h => { if (h.fromPk && !pathPks.includes(h.fromPk)) pathPks.push(h.fromPk); });
if (p.hops.length && p.hops[p.hops.length-1].toPk) { const last = p.hops[p.hops.length-1].toPk; if (!pathPks.includes(last)) pathPks.push(last); }
const mapBtn = pathPks.length >= 2 ? `<button class="btn-icon dist-map-path" data-hops='${JSON.stringify(pathPks)}' title="View on map">🗺️</button>` : '';
const mapBtn = pathPks.length >= 2 ? `<button class="btn-icon dist-map-path" data-hops='${JSON.stringify(pathPks)}' title="View on map" aria-label="View on map"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-trifold"/></svg></button>` : '';
html += `<tr><td>${i+1}</td><td><strong>${formatDistance(p.totalDist)}</strong></td><td>${p.hopCount}</td><td style="font-size:0.9em">${route}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
});
html += `</tbody></table></div>`;
@@ -2418,7 +2418,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
async function renderNeighborGraphTab(el) {
el.innerHTML = `
<div class="analytics-card" id="ngCard">
<h3>🕸️ Neighbor Graph</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-graph"/></svg> Neighbor Graph</h3>
<div id="ngFilters" class="ng-filters" style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-bottom:12px">
<label style="font-size:13px">Roles:
<span id="ngRoleChecks" style="margin-left:4px"></span>
@@ -2440,7 +2440,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
<div id="ngTooltip" style="position:absolute;display:none;background:var(--bg-secondary);border:1px solid var(--border);border-radius:4px;padding:6px 10px;font-size:12px;pointer-events:none;z-index:10;box-shadow:0 2px 8px rgba(0,0,0,0.2)"></div>
</div>
<details id="ngAccessibleList" style="margin-top:12px">
<summary style="cursor:pointer;font-size:13px;color:var(--text-secondary)">📋 Text-based neighbor list (accessible alternative)</summary>
<summary style="cursor:pointer;font-size:13px;color:var(--text-secondary)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-clipboard-text"/></svg> Text-based neighbor list (accessible alternative)</summary>
<div id="ngTextList" style="font-size:12px;max-height:300px;overflow-y:auto;padding:8px;background:var(--bg-secondary);border-radius:4px;margin-top:4px"></div>
</details>
</div>`;
@@ -2596,13 +2596,13 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
var neighbors = (adj[n.pubkey] || []).map(function(nb) {
var peer = nodeMap[nb.pk];
var name = peer ? (peer.name || nb.pk.slice(0, 8)) : nb.pk.slice(0, 8);
var conf = nb.ambiguous ? ' ⚠' : (nb.score >= 0.5 ? ' ●' : ' ○');
var conf = nb.ambiguous ? ' ' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : (nb.score >= 0.5 ? ' ' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg>' : ' ' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle"/></svg>');
return esc(name) + conf;
}).join(', ');
html += '<tr><td style="padding:4px;border-bottom:1px solid var(--border)">' + esc(n.name || n.pubkey.slice(0, 12)) + '</td><td style="padding:4px;border-bottom:1px solid var(--border)">' + esc(n.role || 'unknown') + '</td><td style="padding:4px;border-bottom:1px solid var(--border)">' + (neighbors || '<em>none</em>') + '</td></tr>';
});
html += '</tbody></table>';
html += '<p style="margin-top:8px;font-size:11px;color:var(--text-secondary)">● = high confidence (score ≥ 0.5), ○ = low confidence, ⚠ = ambiguous/unresolved</p>';
html += '<p style="margin-top:8px;font-size:11px;color:var(--text-secondary)">' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg>' + ' = high confidence (score ≥ 0.5), ' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle"/></svg>' + ' = low confidence, ' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' + ' = ambiguous/unresolved</p>';
listEl.innerHTML = html;
}
@@ -3052,8 +3052,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
// distinguish theoretical/would-collide-if-used from packet-
// traffic-observed collisions shown on the Hash Issues tab.
const opLine = opC === 0
? `<span style="color:var(--status-green)"> No address conflicts among configured repeaters</span>`
: `<span style="color:var(--status-red)">⚠️ ${opC} address conflict${opC !== 1 ? 's' : ''} among configured repeaters (would-collide-if-used)</span>`;
? `<span style="color:var(--status-green)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> No address conflicts among configured repeaters</span>`
: `<span style="color:var(--status-red)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> ${opC} address conflict${opC !== 1 ? 's' : ''} among configured repeaters (would-collide-if-used)</span>`;
// #1306: expandable WHICH-collides toggles (op + theoretical)
const opEntries = collEntries.operational[b];
const theoEntries = collEntries.theoretical[b];
@@ -3130,7 +3130,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
<h3 style="margin-top:0">Generate Available Prefix</h3>
<p class="text-muted" style="margin-top:0;font-size:0.9em">Find a prefix with zero current collisions.</p>
<p class="text-muted" style="margin:4px 0 10px;font-size:0.82em">
<span aria-hidden="true">🚫</span>
<span aria-hidden="true"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-prohibit"/></svg></span>
<strong>0x00 and 0xFF excluded</strong> as a first byte — the MeshCore firmware keygen routine re-rolls identities whose <code>pub_key[0]</code> is <code>00</code> or <code>FF</code>, so by convention you should not see those prefixes on real nodes (see
<a href="https://github.com/meshcore-dev/MeshCore/blob/8ede7641/examples/simple_repeater/main.cpp#L83"
target="_blank" rel="noopener noreferrer" style="color:var(--accent)">simple_repeater/main.cpp:83</a>).
@@ -3150,7 +3150,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
</div>
<div id="ptGenResult"></div>
<div style="margin-top:14px;padding:10px 14px;border:1px solid var(--accent);border-radius:6px;background:var(--bg-secondary,var(--bg));font-size:0.88em">
📖 <strong>New to multi-byte prefixes?</strong>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-book-open"/></svg> <strong>New to multi-byte prefixes?</strong>
<a href="https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md#39-q-what-is-multi-byte-support--what-do-1-byte-2-byte-3-byte-adverts-and-messages-mean"
target="_blank" rel="noopener noreferrer" style="color:var(--accent);margin-left:4px">
Read the MeshCore FAQ on multi-byte support →
@@ -3168,9 +3168,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
}
function severityBadge(count) {
if (count === 0) return '<span style="color:var(--status-green)"> Unique</span>';
if (count <= 2) return `<span style="color:var(--status-yellow)">⚠️ ${count} collision${count !== 1 ? 's' : ''}</span>`;
return `<span style="color:var(--status-red)">🔴 ${count} collisions</span>`;
if (count === 0) return '<span style="color:var(--status-green)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> Unique</span>';
if (count <= 2) return `<span style="color:var(--status-yellow)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> ${count} collision${count !== 1 ? 's' : ''}</span>`;
return `<span style="color:var(--status-red)"><span style="color:var(--status-red)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span> ${count} collisions</span>`;
}
// --- Checker ---
@@ -3202,7 +3202,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
if (typeof PrefixReserved !== 'undefined' && PrefixReserved &&
PrefixReserved.isReservedPrefix(input)) {
html += `<div role="alert" style="margin-bottom:10px;padding:10px 14px;border:1px solid var(--status-yellow);border-radius:6px;background:var(--bg-secondary,var(--bg))">
<strong style="color:var(--status-yellow)">⚠️ Firmware avoids this first byte</strong>
<strong style="color:var(--status-yellow)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Firmware avoids this first byte</strong>
<div class="text-muted" style="font-size:0.85em;margin-top:4px">
<code class="mono">${input.slice(0,2)}</code> as the first byte of a node pubkey is avoided by the MeshCore firmware keygen convention (the standard repeater re-rolls identities whose <code class="mono">pub_key[0]</code> is <code class="mono">00</code> or <code class="mono">FF</code>). You generally shouldn't see this on real nodes.
</div>
@@ -3298,7 +3298,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
<div style="padding:12px 16px;border:1px solid var(--status-green);border-radius:6px;background:var(--bg-secondary,var(--bg))">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<code class="mono" style="font-size:1.3em;font-weight:700;color:var(--status-green)">${prefix}</code>
<span style="color:var(--status-green)"> No existing nodes use this prefix</span>
<span style="color:var(--status-green)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> No existing nodes use this prefix</span>
</div>
<div class="text-muted" style="font-size:0.85em;margin-top:6px">${available.toLocaleString()} of ${totalSpace.toLocaleString()} ${b}-byte prefixes are available.</div>
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
@@ -3627,7 +3627,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
container.innerHTML = `
<div class="rf-detail-header">
<h3>${esc(name)}</h3>
<button class="rf-detail-close" aria-label="Close detail" title="Close"></button>
<button class="rf-detail-close" aria-label="Close detail" title="Close"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
</div>
<div class="rf-detail-charts">
<div class="rf-detail-chart" id="rfDetailNFChart"></div>
+7 -2
View File
@@ -424,9 +424,13 @@ function toggleFavorite(pubkey) {
localStorage.setItem(FAV_KEY, JSON.stringify(favs));
return idx < 0; // true if now favorited
}
function favStarIconHtml(on) {
var id = on ? 'ph-star-fill' : 'ph-star';
return '<svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#' + id + '"/></svg>';
}
function favStar(pubkey, cls) {
const on = isFavorite(pubkey);
return '<button class="fav-star ' + (cls || '') + (on ? ' on' : '') + '" data-fav="' + pubkey + '" title="' + (on ? 'Remove from favorites' : 'Add to favorites') + '">' + (on ? '★' : '☆') + '</button>';
return '<button class="fav-star ' + (cls || '') + (on ? ' on' : '') + '" data-fav="' + pubkey + '" aria-label="Toggle favorite" aria-pressed="' + (on ? 'true' : 'false') + '" title="' + (on ? 'Remove from favorites' : 'Add to favorites') + '">' + favStarIconHtml(on) + '</button>';
}
function bindFavStars(container, onToggle) {
container.querySelectorAll('.fav-star').forEach(btn => {
@@ -434,8 +438,9 @@ function bindFavStars(container, onToggle) {
e.stopPropagation();
const pk = btn.dataset.fav;
const nowOn = toggleFavorite(pk);
btn.textContent = nowOn ? '★' : '☆';
btn.innerHTML = favStarIconHtml(nowOn);
btn.classList.toggle('on', nowOn);
btn.setAttribute('aria-pressed', nowOn ? 'true' : 'false');
btn.title = nowOn ? 'Remove from favorites' : 'Add to favorites';
if (onToggle) onToggle(pk, nowOn);
});
+5 -5
View File
@@ -200,7 +200,7 @@
document.getElementById('alabDetail').innerHTML = `
<div class="alab-section">
<h3>📦 Packet Data</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-package"/></svg> Packet Data</h3>
<div class="alab-grid">
<div class="alab-stat"><span class="label">Type</span><br><span class="value" style="color:${m.color}">${m.typeName}</span></div>
<div class="alab-stat"><span class="label">Total Size</span><br><span class="value">${m.totalSize} bytes</span></div>
@@ -215,7 +215,7 @@
</div>
<div class="alab-section">
<h3>🎵 Sound Mapping</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-music-note"/></svg> Sound Mapping</h3>
<table class="alab-map-table">
<tr>
<td class="map-param">Instrument</td>
@@ -261,7 +261,7 @@
</div>
<div class="alab-section">
<h3>🎹 Note Sequence</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-piano-keys"/></svg> Note Sequence</h3>
<table class="alab-note-table">
<tr><th scope="col"></th><th scope="col">#</th><th scope="col">Payload Index</th><th scope="col">Byte</th><th scope="col"> MIDI</th><th scope="col"> Freq</th><th scope="col">Duration (why)</th><th scope="col">Gap (why)</th></tr>
${m.notes.map((n, i) => {
@@ -283,7 +283,7 @@
</div>
<div class="alab-section">
<h3>📊 Byte Visualizer</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> Byte Visualizer</h3>
<div class="alab-byte-viz" id="alabByteViz"></div>
</div>
`;
@@ -437,7 +437,7 @@
<div class="alab-main">
<div class="alab-controls" id="alabControls">
<button class="alab-btn" id="alabPlay" title="Play selected packet"> Play</button>
<button class="alab-btn" id="alabLoop" title="Loop playback">🔁 Loop</button>
<button class="alab-btn" id="alabLoop" title="Loop playback"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-repeat"/></svg> Loop</button>
<span style="font-size:12px;color:var(--text-muted)">Speed:</span>
<button class="alab-speed" data-speed="0.25">0.25x</button>
<button class="alab-speed active" data-speed="1">1x</button>
+3 -1
View File
@@ -351,7 +351,9 @@
// ── Saved filters dropdown ─────────────────────────────────────────────
function _renderSavedDropdown(container, input) {
var btn = _h('button', { type: 'button', class: 'fux-saved-trigger', id: 'filterSavedTrigger', title: 'Saved filters' }, '★ Saved ▾');
var savedIconHtml = '<svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-star-fill"/></svg>';
var caretIconHtml = '<svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-caret-down"/></svg>';
var btn = _h('button', { type: 'button', class: 'fux-saved-trigger', id: 'filterSavedTrigger', title: 'Saved filters' }, savedIconHtml + ' Saved ' + caretIconHtml);
var menu = _h('div', { class: 'fux-saved-menu hidden', id: 'filterSavedMenu', role: 'menu' });
container.appendChild(btn);
container.appendChild(menu);
+52 -4
View File
@@ -1,36 +1,84 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<symbol id="ph-arrow-clockwise" viewBox="0 0 256 256"><path d="M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64l-.25-.24a80,80,0,1,0-1.67,114.78,8,8,0,0,1,11,11.63A95.44,95.44,0,0,1,128,224h-1.32A96,96,0,1,1,195.75,60L224,85.8V56a8,8,0,1,1,16,0Z"/></symbol>
<symbol id="ph-arrows-out" viewBox="0 0 256 256"><path d="M216,48V96a8,8,0,0,1-16,0V67.31l-42.34,42.35a8,8,0,0,1-11.32-11.32L188.69,56H160a8,8,0,0,1,0-16h48A8,8,0,0,1,216,48ZM98.34,146.34,56,188.69V160a8,8,0,0,0-16,0v48a8,8,0,0,0,8,8H96a8,8,0,0,0,0-16H67.31l42.35-42.34a8,8,0,0,0-11.32-11.32ZM208,152a8,8,0,0,0-8,8v28.69l-42.34-42.35a8,8,0,0,0-11.32,11.32L188.69,200H160a8,8,0,0,0,0,16h48a8,8,0,0,0,8-8V160A8,8,0,0,0,208,152ZM67.31,56H96a8,8,0,0,0,0-16H48a8,8,0,0,0-8,8V96a8,8,0,0,0,16,0V67.31l42.34,42.35a8,8,0,0,0,11.32-11.32Z"/></symbol>
<symbol id="ph-battery-high" viewBox="0 0 256 256"><path d="M200,56H32A24,24,0,0,0,8,80v96a24,24,0,0,0,24,24H200a24,24,0,0,0,24-24V80A24,24,0,0,0,200,56Zm8,120a8,8,0,0,1-8,8H32a8,8,0,0,1-8-8V80a8,8,0,0,1,8-8H200a8,8,0,0,1,8,8ZM144,96v64a8,8,0,0,1-16,0V96a8,8,0,0,1,16,0Zm-40,0v64a8,8,0,0,1-16,0V96a8,8,0,0,1,16,0ZM64,96v64a8,8,0,0,1-16,0V96a8,8,0,0,1,16,0Zm192,0v64a8,8,0,0,1-16,0V96a8,8,0,0,1,16,0Z"/></symbol>
<symbol id="ph-battery-low" viewBox="0 0 256 256"><path d="M200,56H32A24,24,0,0,0,8,80v96a24,24,0,0,0,24,24H200a24,24,0,0,0,24-24V80A24,24,0,0,0,200,56Zm8,120a8,8,0,0,1-8,8H32a8,8,0,0,1-8-8V80a8,8,0,0,1,8-8H200a8,8,0,0,1,8,8ZM64,96v64a8,8,0,0,1-16,0V96a8,8,0,0,1,16,0Zm192,0v64a8,8,0,0,1-16,0V96a8,8,0,0,1,16,0Z"/></symbol>
<symbol id="ph-bomb" viewBox="0 0 256 256"><path d="M248,32h0a8,8,0,0,0-8,8,52.66,52.66,0,0,1-3.57,17.39C232.38,67.22,225.7,72,216,72c-11.06,0-18.85-9.76-29.49-24.65C176,32.66,164.12,16,144,16c-16.39,0-29,8.89-35.43,25a66.07,66.07,0,0,0-3.9,15H88A16,16,0,0,0,72,72v9.59A88,88,0,0,0,112,248h1.59A88,88,0,0,0,152,81.59V72a16,16,0,0,0-16-16H120.88a46.76,46.76,0,0,1,2.69-9.37C127.62,36.78,134.3,32,144,32c11.06,0,18.85,9.76,29.49,24.65C184,71.34,195.88,88,216,88c16.39,0,29-8.89,35.43-25A68.69,68.69,0,0,0,256,40,8,8,0,0,0,248,32ZM140.8,94a72,72,0,1,1-57.6,0A8,8,0,0,0,88,86.66V72h48V86.66A8,8,0,0,0,140.8,94ZM111.89,209.32A8,8,0,0,1,104,216a8.52,8.52,0,0,1-1.33-.11,57.5,57.5,0,0,1-46.57-46.57,8,8,0,1,1,15.78-2.64,41.29,41.29,0,0,0,33.43,33.43A8,8,0,0,1,111.89,209.32Z"/></symbol>
<symbol id="ph-book-open" viewBox="0 0 256 256"><path d="M232,48H160a40,40,0,0,0-32,16A40,40,0,0,0,96,48H24a8,8,0,0,0-8,8V200a8,8,0,0,0,8,8H96a24,24,0,0,1,24,24,8,8,0,0,0,16,0,24,24,0,0,1,24-24h72a8,8,0,0,0,8-8V56A8,8,0,0,0,232,48ZM96,192H32V64H96a24,24,0,0,1,24,24V200A39.81,39.81,0,0,0,96,192Zm128,0H160a39.81,39.81,0,0,0-24,8V88a24,24,0,0,1,24-24h64Z"/></symbol>
<symbol id="ph-broadcast" viewBox="0 0 256 256"><path d="M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z"/></symbol>
<symbol id="ph-buildings" viewBox="0 0 256 256"><path d="M240,208H224V96a16,16,0,0,0-16-16H144V32a16,16,0,0,0-24.88-13.32L39.12,72A16,16,0,0,0,32,85.34V208H16a8,8,0,0,0,0,16H240a8,8,0,0,0,0-16ZM208,96V208H144V96ZM48,85.34,128,32V208H48ZM112,112v16a8,8,0,0,1-16,0V112a8,8,0,1,1,16,0Zm-32,0v16a8,8,0,0,1-16,0V112a8,8,0,1,1,16,0Zm0,56v16a8,8,0,0,1-16,0V168a8,8,0,0,1,16,0Zm32,0v16a8,8,0,0,1-16,0V168a8,8,0,0,1,16,0Z"/></symbol>
<symbol id="ph-caret-down" viewBox="0 0 256 256"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"/></symbol>
<symbol id="ph-caret-up" viewBox="0 0 256 256"><path d="M213.66,165.66a8,8,0,0,1-11.32,0L128,91.31,53.66,165.66a8,8,0,0,1-11.32-11.32l80-80a8,8,0,0,1,11.32,0l80,80A8,8,0,0,1,213.66,165.66Z"/></symbol>
<symbol id="ph-cell-signal-high" viewBox="0 0 256 256"><path d="M168,72V200a8,8,0,0,1-16,0V72a8,8,0,0,1,16,0Zm-48,32a8,8,0,0,0-8,8v88a8,8,0,0,0,16,0V112A8,8,0,0,0,120,104ZM80,144a8,8,0,0,0-8,8v48a8,8,0,0,0,16,0V152A8,8,0,0,0,80,144ZM40,184a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-8A8,8,0,0,0,40,184Z"/></symbol>
<symbol id="ph-chart-bar" viewBox="0 0 256 256"><path d="M224,200h-8V40a8,8,0,0,0-8-8H152a8,8,0,0,0-8,8V80H96a8,8,0,0,0-8,8v40H48a8,8,0,0,0-8,8v64H32a8,8,0,0,0,0,16H224a8,8,0,0,0,0-16ZM160,48h40V200H160ZM104,96h40V200H104ZM56,144H88v56H56Z"/></symbol>
<symbol id="ph-chart-line" viewBox="0 0 256 256"><path d="M232,208a8,8,0,0,1-8,8H32a8,8,0,0,1-8-8V48a8,8,0,0,1,16,0v94.37L90.73,98a8,8,0,0,1,10.07-.38l58.81,44.11L218.73,90a8,8,0,1,1,10.54,12l-64,56a8,8,0,0,1-10.07.38L96.39,114.29,40,163.63V200H224A8,8,0,0,1,232,208Z"/></symbol>
<symbol id="ph-chat-circle" viewBox="0 0 256 256"><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-6.54-.67L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Z"/></symbol>
<symbol id="ph-chats" viewBox="0 0 256 256"><path d="M216,80H184V48a16,16,0,0,0-16-16H40A16,16,0,0,0,24,48V176a8,8,0,0,0,13,6.22L72,154V184a16,16,0,0,0,16,16h93.59L219,230.22a8,8,0,0,0,5,1.78,8,8,0,0,0,8-8V96A16,16,0,0,0,216,80ZM66.55,137.78,40,159.25V48H168v88H71.58A8,8,0,0,0,66.55,137.78ZM216,207.25l-26.55-21.47a8,8,0,0,0-5-1.78H88V152h80a16,16,0,0,0,16-16V96h32Z"/></symbol>
<symbol id="ph-check" viewBox="0 0 256 256"><path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z"/></symbol>
<symbol id="ph-circle-fill" viewBox="0 0 256 256"><path d="M232,128A104,104,0,1,1,128,24,104.13,104.13,0,0,1,232,128Z"/></symbol>
<symbol id="ph-check-circle" viewBox="0 0 256 256"><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></symbol>
<symbol id="ph-circle" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Z"/></symbol>
<symbol id="ph-circle-fill" viewBox="0 0 256 256"><path d="M232,128A104,104,0,1,1,128,24,104.13,104.13,0,0,1,232,128Z"/></symbol>
<symbol id="ph-clipboard-text" viewBox="0 0 256 256"><path d="M168,152a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,152Zm-8-40H96a8,8,0,0,0,0,16h64a8,8,0,0,0,0-16Zm56-64V216a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V48A16,16,0,0,1,56,32H92.26a47.92,47.92,0,0,1,71.48,0H200A16,16,0,0,1,216,48ZM96,64h64a32,32,0,0,0-64,0ZM200,48H173.25A47.93,47.93,0,0,1,176,64v8a8,8,0,0,1-8,8H88a8,8,0,0,1-8-8V64a47.93,47.93,0,0,1,2.75-16H56V216H200Z"/></symbol>
<symbol id="ph-clock" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm64-88a8,8,0,0,1-8,8H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48A8,8,0,0,1,192,128Z"/></symbol>
<symbol id="ph-cpu" viewBox="0 0 256 256"><path d="M152,96H104a8,8,0,0,0-8,8v48a8,8,0,0,0,8,8h48a8,8,0,0,0,8-8V104A8,8,0,0,0,152,96Zm-8,48H112V112h32Zm88,0H216V112h16a8,8,0,0,0,0-16H216V56a16,16,0,0,0-16-16H160V24a8,8,0,0,0-16,0V40H112V24a8,8,0,0,0-16,0V40H56A16,16,0,0,0,40,56V96H24a8,8,0,0,0,0,16H40v32H24a8,8,0,0,0,0,16H40v40a16,16,0,0,0,16,16H96v16a8,8,0,0,0,16,0V216h32v16a8,8,0,0,0,16,0V216h40a16,16,0,0,0,16-16V160h16a8,8,0,0,0,0-16Zm-32,56H56V56H200v95.87s0,.09,0,.13,0,.09,0,.13V200Z"/></symbol>
<symbol id="ph-crosshair" viewBox="0 0 256 256"><path d="M232,120h-8.34A96.14,96.14,0,0,0,136,32.34V24a8,8,0,0,0-16,0v8.34A96.14,96.14,0,0,0,32.34,120H24a8,8,0,0,0,0,16h8.34A96.14,96.14,0,0,0,120,223.66V232a8,8,0,0,0,16,0v-8.34A96.14,96.14,0,0,0,223.66,136H232a8,8,0,0,0,0-16Zm-96,87.6V200a8,8,0,0,0-16,0v7.6A80.15,80.15,0,0,1,48.4,136H56a8,8,0,0,0,0-16H48.4A80.15,80.15,0,0,1,120,48.4V56a8,8,0,0,0,16,0V48.4A80.15,80.15,0,0,1,207.6,120H200a8,8,0,0,0,0,16h7.6A80.15,80.15,0,0,1,136,207.6ZM128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Z"/></symbol>
<symbol id="ph-diamond" viewBox="0 0 256 256"><path d="M235.33,116.72,139.28,20.66a16,16,0,0,0-22.56,0l-96,96.06a16,16,0,0,0,0,22.56l96.05,96.06h0a16,16,0,0,0,22.56,0l96.05-96.06a16,16,0,0,0,0-22.56ZM128,224h0L32,128,128,32,224,128Z"/></symbol>
<symbol id="ph-dice-five" viewBox="0 0 256 256"><path d="M192,32H64A32,32,0,0,0,32,64V192a32,32,0,0,0,32,32H192a32,32,0,0,0,32-32V64A32,32,0,0,0,192,32Zm16,160a16,16,0,0,1-16,16H64a16,16,0,0,1-16-16V64A16,16,0,0,1,64,48H192a16,16,0,0,1,16,16ZM104,92A12,12,0,1,1,92,80,12,12,0,0,1,104,92Zm72,0a12,12,0,1,1-12-12A12,12,0,0,1,176,92Zm-72,72a12,12,0,1,1-12-12A12,12,0,0,1,104,164Zm36-36a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm36,36a12,12,0,1,1-12-12A12,12,0,0,1,176,164Z"/></symbol>
<symbol id="ph-envelope" viewBox="0 0 256 256"><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48Zm-96,85.15L52.57,64H203.43ZM98.71,128,40,181.81V74.19Zm11.84,10.85,12,11.05a8,8,0,0,0,10.82,0l12-11.05,58,53.15H52.57ZM157.29,128,216,74.18V181.82Z"/></symbol>
<symbol id="ph-envelope-simple" viewBox="0 0 256 256"><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></symbol>
<symbol id="ph-eye" viewBox="0 0 256 256"><path d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"/></symbol>
<symbol id="ph-flame" viewBox="0 0 256 256"><path d="M173.79,51.48a221.25,221.25,0,0,0-41.67-34.34,8,8,0,0,0-8.24,0A221.25,221.25,0,0,0,82.21,51.48C54.59,80.48,40,112.47,40,144a88,88,0,0,0,176,0C216,112.47,201.41,80.48,173.79,51.48ZM96,184c0-27.67,22.53-47.28,32-54.3,9.48,7,32,26.63,32,54.3a32,32,0,0,1-64,0Zm77.27,15.93A47.8,47.8,0,0,0,176,184c0-44-42.09-69.79-43.88-70.86a8,8,0,0,0-8.24,0C122.09,114.21,80,140,80,184a47.8,47.8,0,0,0,2.73,15.93A71.88,71.88,0,0,1,56,144c0-34.41,20.4-63.15,37.52-81.19A216.21,216.21,0,0,1,128,33.54a215.77,215.77,0,0,1,34.48,29.27C193.49,95.5,200,125,200,144A71.88,71.88,0,0,1,173.27,199.93Z"/></symbol>
<symbol id="ph-gear" viewBox="0 0 256 256"><path d="M128,80a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Zm88-29.84q.06-2.16,0-4.32l14.92-18.64a8,8,0,0,0,1.48-7.06,107.21,107.21,0,0,0-10.88-26.25,8,8,0,0,0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186,40.54a8,8,0,0,0-3.94-6,107.71,107.71,0,0,0-26.25-10.87,8,8,0,0,0-7.06,1.49L130.16,40Q128,40,125.84,40L107.2,25.11a8,8,0,0,0-7.06-1.48A107.6,107.6,0,0,0,73.89,34.51a8,8,0,0,0-3.93,6L67.32,64.27q-1.56,1.49-3,3L40.54,70a8,8,0,0,0-6,3.94,107.71,107.71,0,0,0-10.87,26.25,8,8,0,0,0,1.49,7.06L40,125.84Q40,128,40,130.16L25.11,148.8a8,8,0,0,0-1.48,7.06,107.21,107.21,0,0,0,10.88,26.25,8,8,0,0,0,6,3.93l23.72,2.64q1.49,1.56,3,3L70,215.46a8,8,0,0,0,3.94,6,107.71,107.71,0,0,0,26.25,10.87,8,8,0,0,0,7.06-1.49L125.84,216q2.16.06,4.32,0l18.64,14.92a8,8,0,0,0,7.06,1.48,107.21,107.21,0,0,0,26.25-10.88,8,8,0,0,0,3.93-6l2.64-23.72q1.56-1.48,3-3L215.46,186a8,8,0,0,0,6-3.94,107.71,107.71,0,0,0,10.87-26.25,8,8,0,0,0-1.49-7.06Zm-16.1-6.5a73.93,73.93,0,0,1,0,8.68,8,8,0,0,0,1.74,5.48l14.19,17.73a91.57,91.57,0,0,1-6.23,15L187,173.11a8,8,0,0,0-5.1,2.64,74.11,74.11,0,0,1-6.14,6.14,8,8,0,0,0-2.64,5.1l-2.51,22.58a91.32,91.32,0,0,1-15,6.23l-17.74-14.19a8,8,0,0,0-5-1.75h-.48a73.93,73.93,0,0,1-8.68,0,8,8,0,0,0-5.48,1.74L100.45,215.8a91.57,91.57,0,0,1-15-6.23L82.89,187a8,8,0,0,0-2.64-5.1,74.11,74.11,0,0,1-6.14-6.14,8,8,0,0,0-5.1-2.64L46.43,170.6a91.32,91.32,0,0,1-6.23-15l14.19-17.74a8,8,0,0,0,1.74-5.48,73.93,73.93,0,0,1,0-8.68,8,8,0,0,0-1.74-5.48L40.2,100.45a91.57,91.57,0,0,1,6.23-15L69,82.89a8,8,0,0,0,5.1-2.64,74.11,74.11,0,0,1,6.14-6.14A8,8,0,0,0,82.89,69L85.4,46.43a91.32,91.32,0,0,1,15-6.23l17.74,14.19a8,8,0,0,0,5.48,1.74,73.93,73.93,0,0,1,8.68,0,8,8,0,0,0,5.48-1.74L155.55,40.2a91.57,91.57,0,0,1,15,6.23L173.11,69a8,8,0,0,0,2.64,5.1,74.11,74.11,0,0,1,6.14,6.14,8,8,0,0,0,5.1,2.64l22.58,2.51a91.32,91.32,0,0,1,6.23,15l-14.19,17.74A8,8,0,0,0,199.87,123.66Z"/></symbol>
<symbol id="ph-globe" viewBox="0 0 256 256"><path d="M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm88,104a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.84a157.44,157.44,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154,88H102a115.11,115.11,0,0,1,26-45A115.27,115.27,0,0,1,154,88Zm52.33,0H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM107.59,42.4A135.28,135.28,0,0,0,85.29,88H49.63A88.29,88.29,0,0,1,107.59,42.4ZM49.63,168H85.29a135.28,135.28,0,0,0,22.3,45.6A88.29,88.29,0,0,1,49.63,168Zm98.78,45.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z"/></symbol>
<symbol id="ph-graph" viewBox="0 0 256 256"><path d="M200,152a31.84,31.84,0,0,0-19.53,6.68l-23.11-18A31.65,31.65,0,0,0,160,128c0-.74,0-1.48-.08-2.21l13.23-4.41A32,32,0,1,0,168,104c0,.74,0,1.48.08,2.21l-13.23,4.41A32,32,0,0,0,128,96a32.59,32.59,0,0,0-5.27.44L115.89,81A32,32,0,1,0,96,88a32.59,32.59,0,0,0,5.27-.44l6.84,15.4a31.92,31.92,0,0,0-8.57,39.64L73.83,165.44a32.06,32.06,0,1,0,10.63,12l25.71-22.84a31.91,31.91,0,0,0,37.36-1.24l23.11,18A31.65,31.65,0,0,0,168,184a32,32,0,1,0,32-32Zm0-64a16,16,0,1,1-16,16A16,16,0,0,1,200,88ZM80,56A16,16,0,1,1,96,72,16,16,0,0,1,80,56ZM56,208a16,16,0,1,1,16-16A16,16,0,0,1,56,208Zm56-80a16,16,0,1,1,16,16A16,16,0,0,1,112,128Zm88,72a16,16,0,1,1,16-16A16,16,0,0,1,200,200Z"/></symbol>
<symbol id="ph-handshake" viewBox="0 0 256 256"><path d="M254.3,107.91,228.78,56.85a16,16,0,0,0-21.47-7.15L182.44,62.13,130.05,48.27a8.14,8.14,0,0,0-4.1,0L73.56,62.13,48.69,49.7a16,16,0,0,0-21.47,7.15L1.7,107.9a16,16,0,0,0,7.15,21.47l27,13.51,55.49,39.63a8.06,8.06,0,0,0,2.71,1.25l64,16a8,8,0,0,0,7.6-2.1l55.07-55.08,26.42-13.21a16,16,0,0,0,7.15-21.46Zm-54.89,33.37L165,113.72a8,8,0,0,0-10.68.61C136.51,132.27,116.66,130,104,122L147.24,80h31.81l27.21,54.41ZM41.53,64,62,74.22,36.43,125.27,16,115.06Zm116,119.13L99.42,168.61l-49.2-35.14,28-56L128,64.28l9.8,2.59-45,43.68-.08.09a16,16,0,0,0,2.72,24.81c20.56,13.13,45.37,11,64.91-5L188,152.66Zm62-57.87-25.52-51L214.47,64,240,115.06Zm-87.75,92.67a8,8,0,0,1-7.75,6.06,8.13,8.13,0,0,1-1.95-.24L80.41,213.33a7.89,7.89,0,0,1-2.71-1.25L51.35,193.26a8,8,0,0,1,9.3-13l25.11,17.94L126,208.24A8,8,0,0,1,131.82,217.94Z"/></symbol>
<symbol id="ph-hash" viewBox="0 0 256 256"><path d="M224,88H175.4l8.47-46.57a8,8,0,0,0-15.74-2.86l-9,49.43H111.4l8.47-46.57a8,8,0,0,0-15.74-2.86L95.14,88H48a8,8,0,0,0,0,16H92.23L83.5,152H32a8,8,0,0,0,0,16H80.6l-8.47,46.57a8,8,0,0,0,6.44,9.3A7.79,7.79,0,0,0,80,224a8,8,0,0,0,7.86-6.57l9-49.43H144.6l-8.47,46.57a8,8,0,0,0,6.44,9.3A7.79,7.79,0,0,0,144,224a8,8,0,0,0,7.86-6.57l9-49.43H208a8,8,0,0,0,0-16H163.77l8.73-48H224a8,8,0,0,0,0-16Zm-76.5,64H99.77l8.73-48h47.73Z"/></symbol>
<symbol id="ph-house" viewBox="0 0 256 256"><path d="M219.31,108.68l-80-80a16,16,0,0,0-22.62,0l-80,80A15.87,15.87,0,0,0,32,120v96a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V160h32v56a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V120A15.87,15.87,0,0,0,219.31,108.68ZM208,208H160V152a8,8,0,0,0-8-8H104a8,8,0,0,0-8,8v56H48V120l80-80,80,80Z"/></symbol>
<symbol id="ph-house-line" viewBox="0 0 256 256"><path d="M240,208H224V136l2.34,2.34A8,8,0,0,0,237.66,127L139.31,28.68a16,16,0,0,0-22.62,0L18.34,127a8,8,0,0,0,11.32,11.31L32,136v72H16a8,8,0,0,0,0,16H240a8,8,0,0,0,0-16ZM48,120l80-80,80,80v88H160V152a8,8,0,0,0-8-8H104a8,8,0,0,0-8,8v56H48Zm96,88H112V160h32Z"/></symbol>
<symbol id="ph-info" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/></symbol>
<symbol id="ph-key" viewBox="0 0 256 256"><path d="M216.57,39.43A80,80,0,0,0,83.91,120.78L28.69,176A15.86,15.86,0,0,0,24,187.31V216a16,16,0,0,0,16,16H72a8,8,0,0,0,8-8V208H96a8,8,0,0,0,8-8V184h16a8,8,0,0,0,5.66-2.34l9.56-9.57A79.73,79.73,0,0,0,160,176h.1A80,80,0,0,0,216.57,39.43ZM224,98.1c-1.09,34.09-29.75,61.86-63.89,61.9H160a63.7,63.7,0,0,1-23.65-4.51,8,8,0,0,0-8.84,1.68L116.69,168H96a8,8,0,0,0-8,8v16H72a8,8,0,0,0-8,8v16H40V187.31l58.83-58.82a8,8,0,0,0,1.68-8.84A63.72,63.72,0,0,1,96,95.92c0-34.14,27.81-62.8,61.9-63.89A64,64,0,0,1,224,98.1ZM192,76a12,12,0,1,1-12-12A12,12,0,0,1,192,76Z"/></symbol>
<symbol id="ph-lightning" viewBox="0 0 256 256"><path d="M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17ZM109.37,214l10.47-52.38a8,8,0,0,0-5-9.06L62,132.71l84.62-90.66L136.16,94.43a8,8,0,0,0,5,9.06l52.8,19.8Z"/></symbol>
<symbol id="ph-link" viewBox="0 0 256 256"><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,16,.45A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></symbol>
<symbol id="ph-list" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/></symbol>
<symbol id="ph-list-numbers" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H104a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM104,72H216a8,8,0,0,0,0-16H104a8,8,0,0,0,0,16ZM216,184H104a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16ZM43.58,55.16,48,52.94V104a8,8,0,0,0,16,0V40a8,8,0,0,0-11.58-7.16l-16,8a8,8,0,0,0,7.16,14.32ZM79.77,156.72a23.73,23.73,0,0,0-9.6-15.95,24.86,24.86,0,0,0-34.11,4.7,23.63,23.63,0,0,0-3.57,6.46,8,8,0,1,0,15,5.47,7.84,7.84,0,0,1,1.18-2.13,8.76,8.76,0,0,1,12-1.59A7.91,7.91,0,0,1,63.93,159a7.64,7.64,0,0,1-1.57,5.78,1,1,0,0,0-.08.11L33.59,203.21A8,8,0,0,0,40,216H72a8,8,0,0,0,0-16H56l19.08-25.53A23.47,23.47,0,0,0,79.77,156.72Z"/></symbol>
<symbol id="ph-lock" viewBox="0 0 256 256"><path d="M208,80H176V56a48,48,0,0,0-96,0V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80ZM96,56a32,32,0,0,1,64,0V80H96ZM208,208H48V96H208V208Zm-68-56a12,12,0,1,1-12-12A12,12,0,0,1,140,152Z"/></symbol>
<symbol id="ph-lock-open" viewBox="0 0 256 256"><path d="M208,80H96V56a32,32,0,0,1,32-32c15.37,0,29.2,11,32.16,25.59a8,8,0,0,0,15.68-3.18C171.32,24.15,151.2,8,128,8A48.05,48.05,0,0,0,80,56V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80Zm0,128H48V96H208V208Zm-68-56a12,12,0,1,1-12-12A12,12,0,0,1,140,152Z"/></symbol>
<symbol id="ph-magnifying-glass" viewBox="0 0 256 256"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></symbol>
<symbol id="ph-map-pin" viewBox="0 0 256 256"><path d="M128,64a40,40,0,1,0,40,40A40,40,0,0,0,128,64Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,128Zm0-112a88.1,88.1,0,0,0-88,88c0,31.4,14.51,64.68,42,96.25a254.19,254.19,0,0,0,41.45,38.3,8,8,0,0,0,9.18,0A254.19,254.19,0,0,0,174,200.25c27.45-31.57,42-64.85,42-96.25A88.1,88.1,0,0,0,128,16Zm0,206c-16.53-13-72-60.75-72-118a72,72,0,0,1,144,0C200,161.23,144.53,209,128,222Z"/></symbol>
<symbol id="ph-map-trifold" viewBox="0 0 256 256"><path d="M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM104,52.94l48,24V203.06l-48-24ZM40,62.25l48-12v127.5l-48,12Zm176,131.5-48,12V78.25l48-12Z"/></symbol>
<symbol id="ph-megaphone" viewBox="0 0 256 256"><path d="M248,120a48.05,48.05,0,0,0-48-48H160.2c-2.91-.17-53.62-3.74-101.91-44.24A16,16,0,0,0,32,40V200a16,16,0,0,0,26.29,12.25c37.77-31.68,77-40.76,93.71-43.3v31.72A16,16,0,0,0,159.12,214l11,7.33A16,16,0,0,0,194.5,212l11.77-44.36A48.07,48.07,0,0,0,248,120ZM48,199.93V40h0c42.81,35.91,86.63,45,104,47.24v65.48C134.65,155,90.84,164.07,48,199.93Zm131,8,0,.11-11-7.33V168h21.6ZM200,152H168V88h32a32,32,0,1,1,0,64Z"/></symbol>
<symbol id="ph-microphone" viewBox="0 0 256 256"><path d="M128,176a48.05,48.05,0,0,0,48-48V64a48,48,0,0,0-96,0v64A48.05,48.05,0,0,0,128,176ZM96,64a32,32,0,0,1,64,0v64a32,32,0,0,1-64,0Zm40,143.6V240a8,8,0,0,1-16,0V207.6A80.11,80.11,0,0,1,48,128a8,8,0,0,1,16,0,64,64,0,0,0,128,0,8,8,0,0,1,16,0A80.11,80.11,0,0,1,136,207.6Z"/></symbol>
<symbol id="ph-monitor" viewBox="0 0 256 256"><path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V64A24,24,0,0,0,208,40Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H208a8,8,0,0,1,8,8Zm-48,48a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,224Z"/></symbol>
<symbol id="ph-moon" viewBox="0 0 256 256"><path d="M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z"/></symbol>
<symbol id="ph-music-note" viewBox="0 0 256 256"><path d="M210.3,56.34l-80-24A8,8,0,0,0,120,40V148.26A48,48,0,1,0,136,184V98.75l69.7,20.91A8,8,0,0,0,216,112V64A8,8,0,0,0,210.3,56.34ZM88,216a32,32,0,1,1,32-32A32,32,0,0,1,88,216ZM200,101.25l-64-19.2V50.75L200,70Z"/></symbol>
<symbol id="ph-package" viewBox="0 0 256 256"><path d="M223.68,66.15,135.68,18a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM128,32l80.34,44-29.77,16.3-80.35-44ZM128,120,47.66,76l33.9-18.56,80.34,44ZM40,90l80,43.78v85.79L40,175.82Zm176,85.78h0l-80,43.79V133.82l32-17.51V152a8,8,0,0,0,16,0V107.55L216,90v85.77Z"/></symbol>
<symbol id="ph-palette" viewBox="0 0 256 256"><path d="M200.77,53.89A103.27,103.27,0,0,0,128,24h-1.07A104,104,0,0,0,24,128c0,43,26.58,79.06,69.36,94.17A32,32,0,0,0,136,192a16,16,0,0,1,16-16h46.21a31.81,31.81,0,0,0,31.2-24.88,104.43,104.43,0,0,0,2.59-24A103.28,103.28,0,0,0,200.77,53.89Zm13,93.71A15.89,15.89,0,0,1,198.21,160H152a32,32,0,0,0-32,32,16,16,0,0,1-21.31,15.07C62.49,194.3,40,164,40,128a88,88,0,0,1,87.09-88h.9a88.35,88.35,0,0,1,88,87.25A88.86,88.86,0,0,1,213.81,147.6ZM140,76a12,12,0,1,1-12-12A12,12,0,0,1,140,76ZM96,100A12,12,0,1,1,84,88,12,12,0,0,1,96,100Zm0,56a12,12,0,1,1-12-12A12,12,0,0,1,96,156Zm88-56a12,12,0,1,1-12-12A12,12,0,0,1,184,100Z"/></symbol>
<symbol id="ph-path" viewBox="0 0 256 256"><path d="M200,168a32.06,32.06,0,0,0-31,24H72a32,32,0,0,1,0-64h96a40,40,0,0,0,0-80H72a8,8,0,0,0,0,16h96a24,24,0,0,1,0,48H72a48,48,0,0,0,0,96h97a32,32,0,1,0,31-40Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,200,216Z"/></symbol>
<symbol id="ph-piano-keys" viewBox="0 0 256 256"><path d="M208,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM80,48h24v88H80Zm32,104a8,8,0,0,0,8-8V48h16v96a8,8,0,0,0,8,8h8v56H104V152Zm40-16V48h24v88ZM48,48H64v96a8,8,0,0,0,8,8H88v56H48ZM208,208H168V152h16a8,8,0,0,0,8-8V48h16V208Z"/></symbol>
<symbol id="ph-prohibit" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm88,104a87.56,87.56,0,0,1-20.41,56.28L71.72,60.4A88,88,0,0,1,216,128ZM40,128A87.56,87.56,0,0,1,60.41,71.72L184.28,195.6A88,88,0,0,1,40,128Z"/></symbol>
<symbol id="ph-pulse" viewBox="0 0 256 256"><path d="M240,128a8,8,0,0,1-8,8H204.94l-37.78,75.58A8,8,0,0,1,160,216h-.4a8,8,0,0,1-7.08-5.14L95.35,60.76,63.28,131.31A8,8,0,0,1,56,136H24a8,8,0,0,1,0-16H50.85L88.72,36.69a8,8,0,0,1,14.76.46l57.51,151,31.85-63.71A8,8,0,0,1,200,120h32A8,8,0,0,1,240,128Z"/></symbol>
<symbol id="ph-push-pin" viewBox="0 0 256 256"><path d="M235.32,81.37,174.63,20.69a16,16,0,0,0-22.63,0L98.37,74.49c-10.66-3.34-35-7.37-60.4,13.14a16,16,0,0,0-1.29,23.78L85,159.71,42.34,202.34a8,8,0,0,0,11.32,11.32L96.29,171l48.29,48.29A16,16,0,0,0,155.9,224c.38,0,.75,0,1.13,0a15.93,15.93,0,0,0,11.64-6.33c19.64-26.1,17.75-47.32,13.19-60L235.33,104A16,16,0,0,0,235.32,81.37ZM224,92.69h0l-57.27,57.46a8,8,0,0,0-1.49,9.22c9.46,18.93-1.8,38.59-9.34,48.62L48,100.08c12.08-9.74,23.64-12.31,32.48-12.31A40.13,40.13,0,0,1,96.81,91a8,8,0,0,0,9.25-1.51L163.32,32,224,92.68Z"/></symbol>
<symbol id="ph-question" viewBox="0 0 256 256"><path d="M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></symbol>
<symbol id="ph-radio" viewBox="0 0 256 256"><path d="M104,168a8,8,0,0,1-8,8H64a8,8,0,0,1,0-16H96A8,8,0,0,1,104,168Zm-8-40H64a8,8,0,0,0,0,16H96a8,8,0,0,0,0-16Zm0-32H64a8,8,0,0,0,0,16H96a8,8,0,0,0,0-16ZM232,80V192a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V72a8,8,0,0,1,5.7-7.66l160-48a8,8,0,0,1,4.6,15.33L86.51,64H216A16,16,0,0,1,232,80ZM216,192V80H40V192H216Zm-16-56a40,40,0,1,1-40-40A40,40,0,0,1,200,136Zm-16,0a24,24,0,1,0-24,24A24,24,0,0,0,184,136Z"/></symbol>
<symbol id="ph-repeat" viewBox="0 0 256 256"><path d="M24,128A72.08,72.08,0,0,1,96,56H204.69L194.34,45.66a8,8,0,0,1,11.32-11.32l24,24a8,8,0,0,1,0,11.32l-24,24a8,8,0,0,1-11.32-11.32L204.69,72H96a56.06,56.06,0,0,0-56,56,8,8,0,0,1-16,0Zm200-8a8,8,0,0,0-8,8,56.06,56.06,0,0,1-56,56H51.31l10.35-10.34a8,8,0,0,0-11.32-11.32l-24,24a8,8,0,0,0,0,11.32l24,24a8,8,0,0,0,11.32-11.32L51.31,200H160a72.08,72.08,0,0,0,72-72A8,8,0,0,0,224,120Z"/></symbol>
<symbol id="ph-ruler" viewBox="0 0 256 256"><path d="M235.32,73.37,182.63,20.69a16,16,0,0,0-22.63,0L20.68,160a16,16,0,0,0,0,22.63l52.69,52.68a16,16,0,0,0,22.63,0L235.32,96A16,16,0,0,0,235.32,73.37ZM84.68,224,32,171.31l32-32,26.34,26.35a8,8,0,0,0,11.32-11.32L75.31,128,96,107.31l26.34,26.35a8,8,0,0,0,11.32-11.32L107.31,96,128,75.31l26.34,26.35a8,8,0,0,0,11.32-11.32L139.31,64l32-32L224,84.69Z"/></symbol>
<symbol id="ph-scales" viewBox="0 0 256 256"><path d="M239.43,133l-32-80h0a8,8,0,0,0-9.16-4.84L136,62V40a8,8,0,0,0-16,0V65.58L54.26,80.19A8,8,0,0,0,48.57,85h0v.06L16.57,165a7.92,7.92,0,0,0-.57,3c0,23.31,24.54,32,40,32s40-8.69,40-32a7.92,7.92,0,0,0-.57-3L66.92,93.77,120,82V208H104a8,8,0,0,0,0,16h48a8,8,0,0,0,0-16H136V78.42L187,67.1,160.57,133a7.92,7.92,0,0,0-.57,3c0,23.31,24.54,32,40,32s40-8.69,40-32A7.92,7.92,0,0,0,239.43,133ZM56,184c-7.53,0-22.76-3.61-23.93-14.64L56,109.54l23.93,59.82C78.76,180.39,63.53,184,56,184Zm144-32c-7.53,0-22.76-3.61-23.93-14.64L200,77.54l23.93,59.82C222.76,148.39,207.53,152,200,152Z"/></symbol>
<symbol id="ph-square-fill" viewBox="0 0 256 256"><path d="M224,48V208a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48Z"/></symbol>
<symbol id="ph-share-network" viewBox="0 0 256 256"><path d="M176,160a39.89,39.89,0,0,0-28.62,12.09l-46.1-29.63a39.8,39.8,0,0,0,0-28.92l46.1-29.63a40,40,0,1,0-8.66-13.45l-46.1,29.63a40,40,0,1,0,0,55.82l46.1,29.63A40,40,0,1,0,176,160Zm0-128a24,24,0,1,1-24,24A24,24,0,0,1,176,32ZM64,152a24,24,0,1,1,24-24A24,24,0,0,1,64,152Zm112,72a24,24,0,1,1,24-24A24,24,0,0,1,176,224Z"/></symbol>
<symbol id="ph-shuffle" viewBox="0 0 256 256"><path d="M237.66,178.34a8,8,0,0,1,0,11.32l-24,24a8,8,0,0,1-11.32-11.32L212.69,192H200.94a72.12,72.12,0,0,1-58.59-30.15l-41.72-58.4A56.1,56.1,0,0,0,55.06,80H32a8,8,0,0,1,0-16H55.06a72.12,72.12,0,0,1,58.59,30.15l41.72,58.4A56.1,56.1,0,0,0,200.94,176h11.75l-10.35-10.34a8,8,0,0,1,11.32-11.32ZM143,107a8,8,0,0,0,11.16-1.86l1.2-1.67A56.1,56.1,0,0,1,200.94,80h11.75L202.34,90.34a8,8,0,0,0,11.32,11.32l24-24a8,8,0,0,0,0-11.32l-24-24a8,8,0,0,0-11.32,11.32L212.69,64H200.94a72.12,72.12,0,0,0-58.59,30.15l-1.2,1.67A8,8,0,0,0,143,107Zm-30,42a8,8,0,0,0-11.16,1.86l-1.2,1.67A56.1,56.1,0,0,1,55.06,176H32a8,8,0,0,0,0,16H55.06a72.12,72.12,0,0,0,58.59-30.15l1.2-1.67A8,8,0,0,0,113,149Z"/></symbol>
<symbol id="ph-signpost" viewBox="0 0 256 256"><path d="M246,106.65,212.33,69.3A16,16,0,0,0,200.44,64H136V32a8,8,0,0,0-16,0V64H40A16,16,0,0,0,24,80v64a16,16,0,0,0,16,16h80v64a8,8,0,0,0,16,0V160h64.44a16,16,0,0,0,11.89-5.3L246,117.35A8,8,0,0,0,246,106.65ZM200.44,144H40V80H200.44l28.8,32Z"/></symbol>
<symbol id="ph-speaker-high" viewBox="0 0 256 256"><path d="M155.51,24.81a8,8,0,0,0-8.42.88L77.25,80H32A16,16,0,0,0,16,96v64a16,16,0,0,0,16,16H77.25l69.84,54.31A8,8,0,0,0,160,224V32A8,8,0,0,0,155.51,24.81ZM32,96H72v64H32ZM144,207.64,88,164.09V91.91l56-43.55Zm54-106.08a40,40,0,0,1,0,52.88,8,8,0,0,1-12-10.58,24,24,0,0,0,0-31.72,8,8,0,0,1,12-10.58ZM248,128a79.9,79.9,0,0,1-20.37,53.34,8,8,0,0,1-11.92-10.67,64,64,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67A79.83,79.83,0,0,1,248,128Z"/></symbol>
<symbol id="ph-square" viewBox="0 0 256 256"><path d="M208,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32Zm0,176H48V48H208V208Z"/></symbol>
<symbol id="ph-star-fill" viewBox="0 0 256 256"><path d="M234.29,114.85l-45,38.83L203,211.75a16.4,16.4,0,0,1-24.5,17.82L128,198.49,77.47,229.57A16.4,16.4,0,0,1,53,211.75l13.76-58.07-45-38.83A16.46,16.46,0,0,1,31.08,86l59-4.76,22.76-55.08a16.36,16.36,0,0,1,30.27,0l22.75,55.08,59,4.76a16.46,16.46,0,0,1,9.37,28.86Z"/></symbol>
<symbol id="ph-square-fill" viewBox="0 0 256 256"><path d="M224,48V208a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48Z"/></symbol>
<symbol id="ph-star" viewBox="0 0 256 256"><path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z"/></symbol>
<symbol id="ph-star-fill" viewBox="0 0 256 256"><path d="M234.29,114.85l-45,38.83L203,211.75a16.4,16.4,0,0,1-24.5,17.82L128,198.49,77.47,229.57A16.4,16.4,0,0,1,53,211.75l13.76-58.07-45-38.83A16.46,16.46,0,0,1,31.08,86l59-4.76,22.76-55.08a16.36,16.36,0,0,1,30.27,0l22.75,55.08,59,4.76a16.46,16.46,0,0,1,9.37,28.86Z"/></symbol>
<symbol id="ph-sun" viewBox="0 0 256 256"><path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z"/></symbol>
<symbol id="ph-target" viewBox="0 0 256 256"><path d="M221.87,83.16A104.1,104.1,0,1,1,195.67,49l22.67-22.68a8,8,0,0,1,11.32,11.32l-96,96a8,8,0,0,1-11.32-11.32l27.72-27.72a40,40,0,1,0,17.87,31.09,8,8,0,1,1,16-.9,56,56,0,1,1-22.38-41.65L184.3,60.39a87.88,87.88,0,1,0,23.13,29.67,8,8,0,0,1,14.44-6.9Z"/></symbol>
<symbol id="ph-thermometer" viewBox="0 0 256 256"><path d="M212,56a28,28,0,1,0,28,28A28,28,0,0,0,212,56Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,212,96Zm-84,57V88a8,8,0,0,0-16,0v65a32,32,0,1,0,16,0Zm-8,47a16,16,0,1,1,16-16A16,16,0,0,1,120,200Zm40-66V48a40,40,0,0,0-80,0v86a64,64,0,1,0,80,0Zm-40,98a48,48,0,0,1-27.42-87.4A8,8,0,0,0,96,138V48a24,24,0,0,1,48,0v90a8,8,0,0,0,3.42,6.56A48,48,0,0,1,120,232Z"/></symbol>
<symbol id="ph-trend-up" viewBox="0 0 256 256"><path d="M240,56v64a8,8,0,0,1-16,0V75.31l-82.34,82.35a8,8,0,0,1-11.32,0L96,123.31,29.66,189.66a8,8,0,0,1-11.32-11.32l72-72a8,8,0,0,1,11.32,0L136,140.69,212.69,64H168a8,8,0,0,1,0-16h64A8,8,0,0,1,240,56Z"/></symbol>
<symbol id="ph-triangle" viewBox="0 0 256 256"><path d="M236.8,188.09,149.35,36.22a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.34,24.34,0,0,0,40.55,224h174.9a24.34,24.34,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8Z"/></symbol>
<symbol id="ph-trophy" viewBox="0 0 256 256"><path d="M232,64H208V48a8,8,0,0,0-8-8H56a8,8,0,0,0-8,8V64H24A16,16,0,0,0,8,80V96a40,40,0,0,0,40,40h3.65A80.13,80.13,0,0,0,120,191.61V216H96a8,8,0,0,0,0,16h64a8,8,0,0,0,0-16H136V191.58c31.94-3.23,58.44-25.64,68.08-55.58H208a40,40,0,0,0,40-40V80A16,16,0,0,0,232,64ZM48,120A24,24,0,0,1,24,96V80H48v32q0,4,.39,8Zm144-8.9c0,35.52-29,64.64-64,64.9a64,64,0,0,1-64-64V56H192ZM232,96a24,24,0,0,1-24,24h-.5a81.81,81.81,0,0,0,.5-8.9V80h24Z"/></symbol>
<symbol id="ph-warning" viewBox="0 0 256 256"><path d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z"/></symbol>
<symbol id="ph-wrench" viewBox="0 0 256 256"><path d="M226.76,69a8,8,0,0,0-12.84-2.88l-40.3,37.19-17.23-3.7-3.7-17.23,37.19-40.3A8,8,0,0,0,187,29.24,72,72,0,0,0,88,96,72.34,72.34,0,0,0,94,124.94L33.79,177c-.15.12-.29.26-.43.39a32,32,0,0,0,45.26,45.26c.13-.13.27-.28.39-.42L131.06,162A72,72,0,0,0,232,96,71.56,71.56,0,0,0,226.76,69ZM160,152a56.14,56.14,0,0,1-27.07-7,8,8,0,0,0-9.92,1.77L67.11,211.51a16,16,0,0,1-22.62-22.62L109.18,133a8,8,0,0,0,1.77-9.93,56,56,0,0,1,58.36-82.31l-31.2,33.81a8,8,0,0,0-1.94,7.1L141.83,108a8,8,0,0,0,6.14,6.14l26.35,5.66a8,8,0,0,0,7.1-1.94l33.81-31.2A56.06,56.06,0,0,1,160,152Z"/></symbol>
<symbol id="ph-x" viewBox="0 0 256 256"><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></symbol>
</svg>
<symbol id="ph-x-circle" viewBox="0 0 256 256"><path d="M165.66,101.66,139.31,128l26.35,26.34a8,8,0,0,1-11.32,11.32L128,139.31l-26.34,26.35a8,8,0,0,1-11.32-11.32L116.69,128,90.34,101.66a8,8,0,0,1,11.32-11.32L128,116.69l26.34-26.35a8,8,0,0,1,11.32,11.32ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></symbol>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 35 KiB

+35 -35
View File
@@ -73,7 +73,7 @@
function setObserverIataMap(m) { observerIataMap = m || {}; }
// #1189 R2 mesh-operator fix: live feed must show the observer's IATA pill
// alongside the existing 👁 N badge so operators on /live can tell SAME-
// alongside the existing observation-count badge so operators on /live can tell SAME-
// region from CROSS-region reception at a glance (same affordance as the
// /packets table). Mirrors `obsIataBadge` in public/packets.js — kept as a
// local helper for now (live.js and packets.js are separate IIFEs with no
@@ -153,8 +153,8 @@
};
const PAYLOAD_ICONS = {
ADVERT: '📡', GRP_TXT: '💬', TXT_MSG: '✉️', ACK: '✓',
REQUEST: '❓', RESPONSE: '📨', TRACE: '🔍', PATH: '🛤️'
ADVERT: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg>', GRP_TXT: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chat-circle"/></svg>', TXT_MSG: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope"/></svg>', ACK: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check"/></svg>',
REQUEST: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-question"/></svg>', RESPONSE: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope"/></svg>', TRACE: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg>', PATH: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-path"/></svg>'
};
/* ---- Panel Corner Positioning (#608 M0) ---- */
@@ -264,7 +264,7 @@
const iso = d && isFinite(d.getTime()) ? d.toISOString() : null;
const f = formatTimestampWithTooltip(iso, getTimestampMode());
const warn = f.isFuture
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed">⚠️</span>'
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></span>'
: '';
return `<span class="timestamp-text" title="${escapeHtml(f.tooltip)}">${escapeHtml(f.text)}</span>${warn}`;
}
@@ -1083,7 +1083,7 @@
</div>
<button class="live-header-toggle" data-live-header-toggle id="liveHeaderToggle"
aria-expanded="false" aria-controls="liveHeaderBody"
aria-label="Show live stats">📊</button>
aria-label="Show live stats"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg></button>
<!-- #1205: settings toggles are children of the MESH LIVE panel
(#liveHeader), not a free-floating .live-overlay. PR #1180
@@ -1103,7 +1103,7 @@
<span id="matrixDesc" class="sr-only">Animate packet hex bytes flowing along paths like the Matrix</span>
<label><input type="checkbox" id="liveMatrixRainToggle" aria-describedby="rainDesc"> Rain</label>
<span id="rainDesc" class="sr-only">Matrix rain overlay packets fall as hex columns</span>
<label><input type="checkbox" id="liveAudioToggle" aria-describedby="audioDesc"> 🎵 Audio</label>
<label><input type="checkbox" id="liveAudioToggle" aria-describedby="audioDesc"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-music-note"/></svg> Audio</label>
<span id="audioDesc" class="sr-only">Sonify packets turn raw bytes into generative music</span>
<label><input type="checkbox" id="liveFavoritesToggle" aria-describedby="favDesc"> Favorites</label>
<span id="favDesc" class="sr-only">Show only favorited and claimed nodes</span>
@@ -1132,22 +1132,22 @@
<div class="live-overlay live-feed" id="liveFeed">
<div class="panel-header">
<button class="panel-corner-btn" data-panel="liveFeed" title="Move panel to next corner" aria-label="Move panel to next corner"></button>
<button class="feed-hide-btn" id="feedHideBtn" title="Hide feed"></button>
<button class="feed-hide-btn" id="feedHideBtn" title="Hide feed" aria-label="Hide feed"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
</div>
<div class="panel-content" aria-live="polite" aria-relevant="additions" role="log">
<div class="live-feed-empty" aria-hidden="true">Waiting for packets</div>
</div>
</div>
<button class="feed-show-btn hidden" id="feedShowBtn" title="Show feed">📋</button>
<button class="feed-show-btn hidden" id="feedShowBtn" title="Show feed" aria-label="Show feed"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-clipboard-text"/></svg></button>
<div id="nodeDetailBackdrop" class="node-detail-backdrop"></div>
<div class="live-overlay live-node-detail hidden" id="liveNodeDetail">
<div class="panel-header">
<button class="panel-corner-btn" data-panel="liveNodeDetail" title="Move panel to next corner" aria-label="Move panel to next corner"></button>
<button class="feed-hide-btn" id="nodeDetailClose" title="Close"></button>
<button class="feed-hide-btn" id="nodeDetailClose" title="Close" aria-label="Close"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
</div>
<div class="panel-content" id="nodeDetailContent"></div>
</div>
<button class="legend-toggle-btn" id="legendToggleBtn" aria-label="Show legend" title="Show legend">🎨</button>
<button class="legend-toggle-btn" id="legendToggleBtn" aria-label="Show legend" title="Show legend"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-palette"/></svg></button>
<div class="live-overlay live-legend" id="liveLegend" role="region" aria-label="Map legend">
<div class="panel-header">
<button class="panel-corner-btn" data-panel="liveLegend" title="Move panel to next corner" aria-label="Move panel to next corner"></button>
@@ -1424,7 +1424,7 @@
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control live-leaflet-toggle');
const btn = L.DomUtil.create('a', '', container);
btn.href = 'javascript:void(0)';
btn.innerHTML = '';
btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-arrows-out"/></svg>';
btn.id = 'liveFullscreenToggle';
btn.title = 'Fullscreen (F)';
btn.setAttribute('aria-label', 'Toggle fullscreen');
@@ -1443,7 +1443,7 @@
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control live-leaflet-toggle');
const btn = L.DomUtil.create('a', 'live-controls-toggle', container);
btn.href = 'javascript:void(0)';
btn.innerHTML = '';
btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-gear"/></svg>';
btn.id = 'liveControlsToggle';
btn.setAttribute('data-live-controls-toggle', '');
btn.title = 'Settings';
@@ -2015,7 +2015,7 @@
// Adds `body.live-fullscreen` which CSS uses to hide header body,
// controls body, VCR controls, and bottom-nav while leaving
// .live-stats-row pinned top-right. Triggered by:
// clicking #liveFullscreenToggle ( button next to )
// * clicking #liveFullscreenToggle (fullscreen button next to gear)
// • pressing the `F` key (when focus is not in an input/textarea)
// State persists across reloads via localStorage('live-fullscreen').
(function wireLiveFullscreenToggle() {
@@ -2026,7 +2026,7 @@
document.body.classList.toggle('live-fullscreen', !!on);
if (btn) {
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
btn.textContent = on ? '⛶' : '⛶';
btn.innerHTML = on ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-arrows-out"/></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-arrows-out"/></svg>';
btn.setAttribute('aria-label', on
? 'Exit fullscreen (F)'
: 'Toggle fullscreen (F) — hide chrome, keep stats');
@@ -2087,13 +2087,13 @@
if (localStorage.getItem('live-legend-hidden') === 'true') {
legendEl.classList.add('hidden');
legendToggleBtn.setAttribute('aria-label', 'Show legend');
legendToggleBtn.textContent = '🎨';
legendToggleBtn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-palette"/></svg>';
}
} catch (_) { /* private browsing / storage disabled */ }
legendToggleBtn.addEventListener('click', () => {
const nowHidden = legendEl.classList.toggle('hidden');
legendToggleBtn.setAttribute('aria-label', nowHidden ? 'Show legend' : 'Hide legend');
legendToggleBtn.textContent = nowHidden ? '🎨' : '✕';
legendToggleBtn.innerHTML = nowHidden ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-palette"/></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg>';
try { localStorage.setItem('live-legend-hidden', String(nowHidden)); } catch (_) { /* ignore */ }
});
}
@@ -2389,7 +2389,7 @@
pinBtn.className = 'nav-pin-btn';
pinBtn.setAttribute('aria-label', 'Pin navigation open');
pinBtn.setAttribute('title', 'Pin navigation open');
pinBtn.textContent = '📌';
pinBtn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-push-pin"/></svg>';
pinBtn.addEventListener('click', (e) => {
e.stopPropagation();
_navCleanup.pinned = !_navCleanup.pinned;
@@ -2482,7 +2482,7 @@
let html = `
<div style="padding:16px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span class="${statusDot}" style="font-size:18px" aria-hidden="true"></span>
<span class="${statusDot}" style="font-size:18px" aria-hidden="true"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span>
<h3 style="margin:0;font-size:16px;font-weight:700;">${escapeHtml(n.name || 'Unknown')}</h3>
</div>
<div style="margin-bottom:12px;">
@@ -2517,7 +2517,7 @@
html += `<h4 style="font-size:12px;margin:12px 0 6px;color:var(--text-muted);">Recent Packets</h4>
<div style="font-size:11px;max-height:200px;overflow-y:auto;">` +
recent.slice(0, 10).map(p => `<div style="padding:2px 0;display:flex;justify-content:space-between;">
<a href="#/packets/${encodeURIComponent(p.hash || '')}" style="color:var(--accent);text-decoration:none;">${escapeHtml(p.payload_type || '?')}${transportBadge(p.route_type)}${p.observation_count > 1 ? ' <span class="badge badge-obs" style="font-size:9px">👁 ' + p.observation_count + '</span>' : ''}</a>
<a href="#/packets/${encodeURIComponent(p.hash || '')}" style="color:var(--accent);text-decoration:none;">${escapeHtml(p.payload_type || '?')}${transportBadge(p.route_type)}${p.observation_count > 1 ? ' <span class="badge badge-obs" style="font-size:9px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> ' + p.observation_count + '</span>' : ''}</a>
<span style="color:var(--text-muted)">${formatLiveTimestampHtml(p.timestamp)}</span>
</div>`).join('') +
'</div>';
@@ -2527,7 +2527,7 @@
html += `<div style="margin-top:12px;display:flex;gap:8px;">
<a href="#/nodes/${encodeURIComponent(n.public_key)}" style="font-size:12px;color:var(--accent);">Full Detail </a>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" style="font-size:12px;color:var(--accent);">📊 Analytics</a>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" style="font-size:12px;color:var(--accent);"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> Analytics</a>
</div></div>`;
content.innerHTML = html;
@@ -2760,7 +2760,7 @@
const header = decoded.header || {};
const payload = decoded.payload || {};
const typeName = header.payloadTypeName || 'UNKNOWN';
const icon = PAYLOAD_ICONS[typeName] || '📦';
const icon = PAYLOAD_ICONS[typeName] || '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-package"/></svg>';
const color = TYPE_COLORS[typeName] || '#6b7280';
// Find longest path across all observations for display
@@ -2779,7 +2779,7 @@
const text = payload.text || payload.name || '';
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
const hopStr = longestHops.length ? `<span class="feed-hops">${longestHops.length}⇢</span>` : '';
const obsBadge = group.count > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${group.count}</span>` : '';
const obsBadge = group.count > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> ${group.count}</span>` : '';
const iataBadge = obsIataBadgeHtml(pkt);
var _ccPayload = (pkt.decoded || {}).payload || {};
@@ -3120,7 +3120,7 @@
const header = decoded.header || {};
const payload = decoded.payload || {};
const typeName = header.payloadTypeName || 'UNKNOWN';
const icon = PAYLOAD_ICONS[typeName] || '📦';
const icon = PAYLOAD_ICONS[typeName] || '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-package"/></svg>';
const color = TYPE_COLORS[typeName] || '#6b7280';
const obsCount = packets.length;
@@ -3623,7 +3623,7 @@
container.dataset.matrixPrevTheme = currentTheme || 'light';
document.documentElement.setAttribute('data-theme', 'dark');
const dt = document.getElementById('darkModeToggle');
if (dt) { dt.textContent = '🌙'; dt.disabled = true; }
if (dt) { dt.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-moon"/></svg>'; dt.disabled = true; }
} else {
const dt = document.getElementById('darkModeToggle');
if (dt) dt.disabled = true;
@@ -3650,7 +3650,7 @@
document.documentElement.setAttribute('data-theme', prevTheme);
localStorage.setItem('meshcore-theme', prevTheme);
const dt = document.getElementById('darkModeToggle');
if (dt) { dt.textContent = prevTheme === 'dark' ? '🌙' : '☀️'; dt.disabled = false; }
if (dt) { dt.innerHTML = prevTheme === 'dark' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-moon"/></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-sun"/></svg>'; dt.disabled = false; }
delete container.dataset.matrixPrevTheme;
} else {
const dt = document.getElementById('darkModeToggle');
@@ -4124,9 +4124,9 @@
const text = payload.text || payload.name || '';
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
const hopStr = hops.length ? `<span class="feed-hops">${hops.length}⇢</span>` : '';
const obsBadge = pkt.observation_count > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${pkt.observation_count}</span>` : '';
const obsBadge = pkt.observation_count > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> ${pkt.observation_count}</span>` : '';
const iataBadge = obsIataBadgeHtml(pkt);
const anomalyIcon = (pkt.decoded && pkt.decoded.anomaly) ? '<span title="Anomaly detected" style="margin-left:4px">⚠️</span>' : '';
const anomalyIcon = (pkt.decoded && pkt.decoded.anomaly) ? '<span title="Anomaly detected" style="margin-left:4px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></span>' : '';
var _ccPayload2 = (pkt.decoded || {}).payload || {};
var _ccChan = (typeName === 'GRP_TXT' || typeName === 'CHAN') ? (_ccPayload2.channel || null) : null;
var dotHtml = _ccChan ? _feedColorDot(_ccChan) : '';
@@ -4185,7 +4185,7 @@
const ref = entry.element.querySelector('.feed-hops') || entry.element.querySelector('.feed-type');
if (ref) ref.after(badge); else entry.element.appendChild(badge);
}
badge.textContent = '👁 ' + entry.count;
badge.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> ' + entry.count;
// Flash + move to top
entry.element.classList.remove('live-feed-enter');
void entry.element.offsetWidth; // force reflow
@@ -4211,7 +4211,7 @@
const text = payload.text || payload.name || '';
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
const hopStr = hops.length ? `<span class="feed-hops">${hops.length}⇢</span>` : '';
const obsBadge = incomingObs > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${incomingObs}</span>` : '';
const obsBadge = incomingObs > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> ${incomingObs}</span>` : '';
const iataBadge = obsIataBadgeHtml(pkt);
var _ccPayload3 = (pkt.decoded || {}).payload || {};
var _ccChan3 = (typeName === 'GRP_TXT' || typeName === 'CHAN') ? (_ccPayload3.channel || null) : null;
@@ -4281,15 +4281,15 @@
<div class="panel-header" style="border-left:3px solid ${color}">
<strong>${typeName}</strong>
${sender ? `<span class="fdc-sender">${escapeHtml(sender)}</span>` : ''}
<button class="fdc-close"></button>
<button class="fdc-close" aria-label="Close"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
</div>
${text ? `<div class="fdc-text">${escapeHtml(text.length > 120 ? text.slice(0, 120) + '…' : text)}</div>` : ''}
<div class="fdc-meta">
${channel ? `<span>📻 ${escapeHtml(channel)}</span>` : ''}
${hops.length ? `<span>🔀 ${hops.length} hops</span>` : ''}
${snr != null ? `<span>📶 ${Number(snr).toFixed(1)} dB</span>` : ''}
${rssi != null ? `<span>📡 ${rssi} dBm</span>` : ''}
${observer ? `<span>👁 ${escapeHtml(observer)}</span>` : ''}
${channel ? `<span><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-radio"/></svg> ${escapeHtml(channel)}</span>` : ''}
${hops.length ? `<span><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-shuffle"/></svg> ${hops.length} hops</span>` : ''}
${snr != null ? `<span><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-cell-signal-high"/></svg> ${Number(snr).toFixed(1)} dB</span>` : ''}
${rssi != null ? `<span><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> ${rssi} dBm</span>` : ''}
${observer ? `<span><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> ${escapeHtml(observer)}</span>` : ''}
</div>
${pkt.hash ? `<a class="fdc-link" href="#/packets/${pkt.hash.toLowerCase()}">View in packets →</a>` : ''}
<button class="fdc-replay"> Replay</button>
+15 -15
View File
@@ -44,9 +44,9 @@
};
// MB_GLYPHS prefix the hash text with a non-color status carrier.
var MB_GLYPHS = {
confirmed: '\u2713', //
confirmed: '\u2713', // check
suspected: '?',
unknown: '\u2717', //
unknown: '\u2717', // x-mark
};
// Per-status CSS class (drives the colored 3px left-border in style.css).
var MB_STATUS_CLASS = {
@@ -149,7 +149,7 @@
: ('repeater hash ' + shortHash);
// Observer indicator stays a star — it is an orthogonal signal, not a status color.
var obsIndicator = isAlsoObserver
? ' <span aria-hidden="true" style="color:' + (ROLE_COLORS.observer || '#f1c40f') + ';font-size:13px;line-height:1;" title="Also an observer"></span>'
? ' <span aria-hidden="true" style="color:' + (ROLE_COLORS.observer || '#f1c40f') + ';font-size:13px;line-height:1;" title="Also an observer"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-star-fill"/></svg></span>'
: '';
// Glyph + thin-space (U+2009) + hash. Visible content is aria-hidden so AT
// reads the aria-label only (avoids "check mark 3 E" literal announcements).
@@ -182,9 +182,9 @@
<div id="mapPiResults"></div>
</div>
</div>
<button class="map-controls-toggle" id="mapControlsToggle" aria-label="Toggle map controls" aria-expanded="true"></button>
<button class="map-controls-toggle" id="mapControlsToggle" aria-label="Toggle map controls" aria-expanded="true"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-gear"/></svg></button>
<div class="map-controls" id="mapControls" role="region" aria-label="Map controls">
<h3>🗺 Map Controls</h3>
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-trifold"/></svg> Map Controls</h3>
<fieldset class="mc-section">
<legend class="mc-label">Node Types</legend>
<div id="mcRoleChecks"></div>
@@ -220,7 +220,7 @@
<label for="mcNeighbors"><input type="checkbox" id="mcNeighbors"> Show direct neighbors</label>
<div id="mcNeighborRef" style="display:none;font-size:11px;color:var(--text-muted);margin-top:2px;padding-left:20px;">Ref: <span id="mcNeighborRefName"></span></div>
<div id="mcNeighborHint" style="display:none;font-size:11px;color:var(--text-muted);margin-top:2px;padding-left:20px;">Click a node marker to set the reference node</div>
<label id="mcAffinityDebugLabel" for="mcAffinityDebug" style="display:none"><input type="checkbox" id="mcAffinityDebug"> 🔍 Affinity Debug</label>
<label id="mcAffinityDebugLabel" for="mcAffinityDebug" style="display:none"><input type="checkbox" id="mcAffinityDebug"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Affinity Debug</label>
</fieldset>
<fieldset class="mc-section">
<legend class="mc-label">Last Heard</legend>
@@ -637,7 +637,7 @@
const closeBtn = L.control({ position: 'topright' });
closeBtn.onAdd = function () {
const div = L.DomUtil.create('div', 'leaflet-bar');
div.innerHTML = '<a href="#" title="Close route" style="font-size:18px;font-weight:bold;text-decoration:none;display:block;width:36px;height:36px;line-height:36px;text-align:center;background:var(--input-bg,#1e293b);color:var(--text,#e2e8f0);border-radius:4px"></a>';
div.innerHTML = '<a href="#" title="Close route" style="font-size:18px;font-weight:bold;text-decoration:none;display:block;width:36px;height:36px;line-height:36px;text-align:center;background:var(--input-bg,#1e293b);color:var(--text,#e2e8f0);border-radius:4px" aria-label="Close route"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></a>';
L.DomEvent.on(div, 'click', function (e) {
L.DomEvent.preventDefault(e);
routeLayer.clearLayers();
@@ -667,7 +667,7 @@
// the hop wasn't in the server response. When a node MATCHES by
// prefix/pubkey but has no GPS coords, we still want its name/role/pubkey
// for the sidebar — flag it as {resolved:false, gpsless:true} so the
// renderer can label it "📍 no GPS" instead of "unresolved prefix".
// renderer can label it "no GPS" instead of "unresolved prefix".
const raw = hopKeys.map(hop => {
const hopLower = String(hop).toLowerCase();
// Try server resolution first
@@ -1297,7 +1297,7 @@
const nodePubkeys = new Set(nodes.map(n => (n.public_key || '').toLowerCase()));
const obsCount = observers.filter(o => o.lat && o.lon && !(o.id && nodePubkeys.has(o.id.toLowerCase()))).length;
const roles = ['repeater', 'companion', 'room', 'sensor', 'observer'];
const shapeMap = { repeater: '◆', companion: '●', room: '■', sensor: '▲', observer: '★' };
const shapeMap = { repeater: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-diamond"/></svg>', companion: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg>', room: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-square-fill"/></svg>', sensor: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-triangle"/></svg>', observer: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-star-fill"/></svg>' };
// Count active/stale per role from loaded nodes
const roleCounts = {};
@@ -1316,7 +1316,7 @@
const cbId = 'mcRole_' + role;
const lbl = document.createElement('label');
lbl.setAttribute('for', cbId);
const shape = shapeMap[role] || '';
const shape = shapeMap[role] || '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg>';
let countStr;
if (role === 'observer') {
countStr = `(${obsCount})`;
@@ -1710,7 +1710,7 @@
// Multi-byte support indicator for repeaters
var mbRow = '';
if (node.role === 'repeater' && node.multi_byte_status) {
var mbLabel = { confirmed: '✅ Confirmed', suspected: '⚠️ Suspected', unknown: '❌ Unknown' }[node.multi_byte_status] || node.multi_byte_status;
var mbLabel = { confirmed: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> Confirmed', suspected: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Suspected', unknown: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x-circle"/></svg> Unknown' }[node.multi_byte_status] || node.multi_byte_status;
var mbEvidence = node.multi_byte_evidence ? ' (' + node.multi_byte_evidence + ')' : '';
mbRow = '<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Multi-byte</dt>' +
'<dd style="margin-left:88px;padding:2px 0;font-size:12px;">' + mbLabel + mbEvidence + '</dd>';
@@ -1823,9 +1823,9 @@
var rowClass = c.speculative ? 'speculative-row' : '';
html += '<tr class="' + rowClass + '">';
html += '<td>' + (i + 1) + '</td>';
html += '<td class="' + (c.speculative ? 'speculative-warning' : '') + '">' + c.score.toFixed(2) + (c.speculative ? ' ' : '') + '</td>';
html += '<td class="' + (c.speculative ? 'speculative-warning' : '') + '">' + c.score.toFixed(2) + (c.speculative ? ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '') + '</td>';
html += '<td title="' + safeEsc(c.names.join(' → ')) + '">' + safeEsc(c.names.slice(0, 3).join('→')) + (c.names.length > 3 ? '…' : '') + '</td>';
html += '<td><button class="btn btn-sm" data-idx="' + i + '" title="Show on Map">📍</button></td>';
html += '<td><button class="btn btn-sm" data-idx="' + i + '" title="Show on Map" aria-label="Show on Map"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-pin"/></svg></button></td>';
html += '</tr>';
// Per-hop evidence (collapsed).
html += '<tr class="evidence-row collapsed" data-evidence="' + i + '"><td colspan="4"><div class="evidence-detail" style="font-size:10px;">';
@@ -1962,11 +1962,11 @@
if (!posA) return;
// Unresolved prefix show marker near nodeA
// Unresolved prefix - show question marker near nodeA
if (e.unresolved || (!posB && e.ambiguous)) {
if (posA) {
var marker = L.marker([posA[0] + 0.001, posA[1] + 0.001], {
icon: L.divIcon({ html: '', className: 'affinity-unresolved', iconSize: [20, 20] })
icon: L.divIcon({ html: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-question"/></svg>', className: 'affinity-unresolved', iconSize: [20, 20] })
});
marker.bindPopup('<b>Unresolved prefix:</b> ' + escapeHtml(e.prefix) + '<br>Observations: ' + e.weight);
affinityLayer.addLayer(marker);
+4 -4
View File
@@ -54,7 +54,7 @@
<div style="max-width:1000px;margin:0 auto;padding:12px 16px">
<div style="margin-bottom:12px">
<a href="#/nodes/${encodeURIComponent(n.public_key)}" style="color:var(--accent);text-decoration:none;font-size:12px"> Back to ${nodeName}</a>
<h2 style="margin:4px 0 2px;font-size:18px">📊 ${nodeName} Analytics</h2>
<h2 style="margin:4px 0 2px;font-size:18px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> ${nodeName} Analytics</h2>
<div style="color:var(--text-muted);font-size:11px">${n.role || 'Unknown role'} · ${s.totalTransmissions || s.totalPackets} packets in ${days}d window</div>
</div>
@@ -313,9 +313,9 @@
if (badge) {
const STATUS_COLOR = { ok: '#51cf66', low: '#fcc419', critical: '#ff6b6b', unknown: 'var(--text-muted)' };
const label = data && data.status === 'ok' ? '🔋 OK'
: data && data.status === 'low' ? '⚠️ Low'
: data && data.status === 'critical' ? '🪫 Critical'
const label = data && data.status === 'ok' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-battery-high"/></svg> OK'
: data && data.status === 'low' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Low'
: data && data.status === 'critical' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-battery-low"/></svg> Critical'
: 'No data';
const mv = data && data.latest_mv ? ' · ' + data.latest_mv + ' mV' : '';
badge.textContent = label + mv;
+42 -42
View File
@@ -152,7 +152,7 @@
}
const f = formatTimestampWithTooltip(isoString, getTimestampMode());
const warn = f.isFuture
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed">⚠️</span>'
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></span>'
: '';
return `<span class="timestamp-text" title="${escapeHtml(f.tooltip)}">${escapeHtml(f.text)}</span>${warn}`;
}
@@ -191,7 +191,7 @@
const lastHeardMs = lastHeardTime ? new Date(lastHeardTime).getTime() : 0;
const status = getNodeStatus(role, lastHeardMs);
const statusTooltip = getStatusTooltip(role, status);
const statusLabel = status === 'active' ? '🟢 Active' : '⚪ Stale';
const statusLabel = status === 'active' ? '<span style="color:var(--status-green)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span> Active' : '<span style="color:var(--text-muted)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span> Stale';
const statusAge = lastHeardMs ? (Date.now() - lastHeardMs) : Infinity;
let explanation = '';
@@ -226,7 +226,7 @@
html += ` <span class="badge multibyte-badge" title="Node advertises multibyte hash path (firmware Feat1/Feat2)">Multibyte: ${Number(n.hash_size)}-byte</span>`;
}
if (n.hash_size_inconsistent) {
html += ` <a href="#/nodes/${encodeURIComponent(n.public_key)}?section=node-packets" class="badge" style="background:var(--status-yellow);color:#000;font-size:10px;cursor:pointer;text-decoration:none">⚠️ variable hash size</a>`;
html += ` <a href="#/nodes/${encodeURIComponent(n.public_key)}?section=node-packets" class="badge" style="background:var(--status-yellow);color:#000;font-size:10px;cursor:pointer;text-decoration:none"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> variable hash size</a>`;
}
html += ` <span title="${info.statusTooltip}">${info.statusLabel}</span>`;
return html;
@@ -249,10 +249,10 @@
var _neighborCache = {};
function getConfidenceIndicator(entry) {
if (entry.ambiguous) return { icon: '⚠️', label: 'AMBIGUOUS', cls: 'confidence-ambiguous' };
if (entry.count <= 1) return { icon: '🔴', label: 'LOW', cls: 'confidence-low' };
if (entry.score >= 0.5 && entry.count >= 3) return { icon: '🟢', label: 'HIGH', cls: 'confidence-high' };
return { icon: '🟡', label: 'MEDIUM', cls: 'confidence-medium' };
if (entry.ambiguous) return { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>', label: 'AMBIGUOUS', cls: 'confidence-ambiguous' };
if (entry.count <= 1) return { icon: '<span style="color:var(--status-red)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span>', label: 'LOW', cls: 'confidence-low' };
if (entry.score >= 0.5 && entry.count >= 3) return { icon: '<span style="color:var(--status-green)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span>', label: 'HIGH', cls: 'confidence-high' };
return { icon: '<span style="color:var(--status-yellow)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span>', label: 'MEDIUM', cls: 'confidence-medium' };
}
function renderNeighborRows(neighbors, limit) {
@@ -276,7 +276,7 @@
? formatDistance(Number(nb.distance_km))
: '<span class="text-muted">—</span>';
var showOnMap = nb.pubkey
? ' <button class="btn-link neighbor-show-map" data-pubkey="' + escapeHtml(nb.pubkey) + '" style="font-size:11px;padding:1px 6px;white-space:nowrap">📍 Map</button>'
? ' <button class="btn-link neighbor-show-map" data-pubkey="' + escapeHtml(nb.pubkey) + '" style="font-size:11px;padding:1px 6px;white-space:nowrap"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-pin"/></svg> Map</button>'
: '';
var lastSeenVal = nb.last_seen ? new Date(nb.last_seen).getTime() : 0;
var distanceVal = nb.distance_km != null ? Number(nb.distance_km) : '';
@@ -350,10 +350,10 @@
}
var html = renderNeighborTable(data.neighbors, limit);
if (limit && data.neighbors.length > limit) {
html += '<div style="margin-top:6px;text-align:right"><button class="btn-link show-all-neighbors-btn" style="font-size:12px;cursor:pointer;background:none;border:none;color:var(--accent);padding:0">Show all ' + data.neighbors.length + ' neighbors </button></div>';
html += '<div style="margin-top:6px;text-align:right"><button class="btn-link show-all-neighbors-btn" style="font-size:12px;cursor:pointer;background:none;border:none;color:var(--accent);padding:0">Show all ' + data.neighbors.length + ' neighbors <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-down"/></svg></button></div>';
} else if (!limit && data.neighbors.length > 5) {
// Collapse toggle when expanded (#855)
html += '<div style="margin-top:6px;text-align:right"><button class="btn-link collapse-neighbors-btn" style="font-size:12px;cursor:pointer;background:none;border:none;color:var(--accent);padding:0">Show fewer </button></div>';
html += '<div style="margin-top:6px;text-align:right"><button class="btn-link collapse-neighbors-btn" style="font-size:12px;cursor:pointer;background:none;border:none;color:var(--accent);padding:0">Show fewer <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-up"/></svg></button></div>';
}
el.innerHTML = html;
@@ -401,7 +401,7 @@
if (directNode) {
// Full-screen single node view (desktop + mobile).
// Reached via the 🔍 Details link or a deep link to #/nodes/{pubkey}.
// Reached via the Details link or a deep link to #/nodes/{pubkey}.
// Row clicks use history.replaceState (no hashchange → no re-init),
// so the split-panel UX on desktop is preserved.
app.innerHTML = `<div class="node-fullscreen">
@@ -566,10 +566,10 @@
${renderHashInconsistencyWarning(n)}
<div class="node-detail-key mono" style="font-size:11px;word-break:break-all;margin-bottom:6px">${n.public_key}</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
<button class="btn-primary" id="copyUrlBtn" style="flex:0 0 auto;font-size:12px;padding:4px 10px">📋 Copy URL</button>
<button class="btn-primary" id="copyShortUrlBtn" title="Short URL using an 8-char pubkey prefix — easier to send over the mesh (issue #772)" style="flex:0 0 auto;font-size:12px;padding:4px 10px">📡 Copy short URL</button>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px">📊 Analytics</a>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/reach" class="btn-primary" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px">📡 Reach</a>
<button class="btn-primary" id="copyUrlBtn" style="flex:0 0 auto;font-size:12px;padding:4px 10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-clipboard-text"/></svg> Copy URL</button>
<button class="btn-primary" id="copyShortUrlBtn" title="Short URL using an 8-char pubkey prefix — easier to send over the mesh (issue #772)" style="flex:0 0 auto;font-size:12px;padding:4px 10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Copy short URL</button>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> Analytics</a>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/reach" class="btn-primary" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Reach</a>
</div>
</div>
@@ -584,7 +584,7 @@
<table class="node-stats-table" id="node-stats">
<tr><td>Status</td><td><span title="${si.statusTooltip}">${statusLabel}</span> <span style="font-size:11px;color:var(--text-muted);margin-left:4px">${statusExplanation}</span></td></tr>
<tr><td>Last Heard</td><td>${renderNodeTimestampHtml(lastHeard || n.last_seen)}</td></tr>
${(n.role === 'repeater' || n.role === 'room') ? `<tr><td title="Last time this repeater appeared as a relay hop in a non-advert packet observed by the network. Distinct from 'Last Heard' (which counts the repeater's own adverts). See issue #662.">Last Relayed</td><td>${n.last_relayed ? renderNodeTimestampHtml(n.last_relayed) + ' ' + (n.relay_active ? '<span style="color:var(--status-green);font-size:11px">🟢 actively relaying</span>' : '<span style="color:var(--status-yellow);font-size:11px">🟡 alive (idle)</span>') : '<span style="color:var(--text-muted)">never observed as relay hop</span> <span style="color:var(--status-yellow);font-size:11px">🟡 alive (idle)</span>'}${(n.relay_count_1h != null || n.relay_count_24h != null) ? ` <span style="color:var(--text-muted);font-size:11px;margin-left:4px">(${n.relay_count_1h || 0} relays/hr, ${n.relay_count_24h || 0} relays/24h)</span>` : ''}</td></tr>` : ''}
${(n.role === 'repeater' || n.role === 'room') ? `<tr><td title="Last time this repeater appeared as a relay hop in a non-advert packet observed by the network. Distinct from 'Last Heard' (which counts the repeater's own adverts). See issue #662.">Last Relayed</td><td>${n.last_relayed ? renderNodeTimestampHtml(n.last_relayed) + ' ' + (n.relay_active ? '<span style="color:var(--status-green);font-size:11px"><span style="color:var(--status-green)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span> actively relaying</span>' : '<span style="color:var(--status-yellow);font-size:11px"><span style="color:var(--status-yellow)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span> alive (idle)</span>') : '<span style="color:var(--text-muted)">never observed as relay hop</span> <span style="color:var(--status-yellow);font-size:11px"><span style="color:var(--status-yellow)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span> alive (idle)</span>'}${(n.relay_count_1h != null || n.relay_count_24h != null) ? ` <span style="color:var(--text-muted);font-size:11px;margin-left:4px">(${n.relay_count_1h || 0} relays/hr, ${n.relay_count_24h || 0} relays/24h)</span>` : ''}</td></tr>` : ''}
${(n.role === 'repeater' || n.role === 'room') && (n.traffic_share_score != null || n.usefulness_score != null) ? (() => {
// #1456: prefer the new traffic_share_score field; fall back
// to legacy usefulness_score for graceful degradation
@@ -631,7 +631,7 @@
${stats.avgSnr != null ? `<tr><td>Avg SNR</td><td>${Number(stats.avgSnr).toFixed(1)} dB</td></tr>` : ''}
${stats.avgHops ? `<tr><td>Avg Hops</td><td>${stats.avgHops}</td></tr>` : ''}
${hasLoc ? `<tr><td>Location</td><td>${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}</td></tr>` : ''}
<tr><td>Hash Prefix</td><td>${n.hash_size ? '<code style="font-family:var(--mono);font-weight:700">' + n.public_key.slice(0, n.hash_size * 2).toUpperCase() + '</code> (' + n.hash_size + '-byte)' : 'Unknown'}${n.hash_size_inconsistent ? ' <span style="color:var(--status-yellow);cursor:help" title="Seen: ' + (Array.isArray(n.hash_sizes_seen) ? n.hash_sizes_seen : []).join(', ') + '-byte"> varies</span>' : ''}</td></tr>
<tr><td>Hash Prefix</td><td>${n.hash_size ? '<code style="font-family:var(--mono);font-weight:700">' + n.public_key.slice(0, n.hash_size * 2).toUpperCase() + '</code> (' + n.hash_size + '-byte)' : 'Unknown'}${n.hash_size_inconsistent ? ' <span style="color:var(--status-yellow);cursor:help" title="Seen: ' + (Array.isArray(n.hash_sizes_seen) ? n.hash_sizes_seen : []).join(', ') + '-byte"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> varies</span>' : ''}</td></tr>
</table>
<div class="node-full-card" id="node-packets">
@@ -640,12 +640,12 @@
<div class="node-activity-list">
${validPackets.length ? validPackets.map(p => {
let decoded; try { decoded = JSON.parse(p.decoded_json); } catch {}
const typeLabel = p.payload_type === 4 ? '📡 Advert' : p.payload_type === 5 ? '💬 Channel' : p.payload_type === 2 ? '✉️ DM' : '📦 Packet';
const typeLabel = p.payload_type === 4 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Advert' : p.payload_type === 5 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chat-circle"/></svg> Channel' : p.payload_type === 2 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope"/></svg> DM' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-package"/></svg> Packet';
const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : decoded?.name ? ' — ' + escapeHtml(decoded.name) : '';
const obs = p.observer_name || p.observer_id;
const snr = p.snr != null ? ` · SNR ${p.snr}dB` : '';
const rssi = p.rssi != null ? ` · RSSI ${p.rssi}dBm` : '';
const obsBadge = p.observation_count > 1 ? ` <span class="badge badge-obs" title="Seen ${p.observation_count} times">👁 ${p.observation_count}</span>` : '';
const obsBadge = p.observation_count > 1 ? ` <span class="badge badge-obs" title="Seen ${p.observation_count} times"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> ${p.observation_count}</span>` : '';
// Show hash size per advert if inconsistent
let hashSizeBadge = '';
if (n.hash_size_inconsistent && p.payload_type === 4 && p.raw_hex) {
@@ -696,7 +696,7 @@
</div>
<div class="node-full-card" id="node-affinity-debug" style="display:none">
<h4 style="cursor:pointer" onclick="this.parentElement.querySelector('.affinity-debug-body').style.display=this.parentElement.querySelector('.affinity-debug-body').style.display==='none'?'block':'none'; this.querySelector('.toggle-icon').textContent=this.parentElement.querySelector('.affinity-debug-body').style.display==='none'?'▶':'▼'"><span class="toggle-icon"></span> 🔍 Affinity Debug</h4>
<h4 style="cursor:pointer" onclick="var body=this.parentElement.querySelector('.affinity-debug-body'); var hidden=body.style.display==='none'; body.style.display=hidden?'block':'none'; this.querySelector('.toggle-icon').innerHTML=body.style.display==='none'?'<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-down"/></svg>':'<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-up"/></svg>'"><span class="toggle-icon"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-down"/></svg></span> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Affinity Debug</h4>
<div class="affinity-debug-body" style="display:none">
<div id="affinityDebugContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading debug data</div></div>
</div>
@@ -726,8 +726,8 @@
document.getElementById('copyUrlBtn')?.addEventListener('click', () => {
const btn = document.getElementById('copyUrlBtn');
window.copyToClipboard(nodeUrl, () => {
btn.textContent = ' Copied!';
setTimeout(() => btn.textContent = '📋 Copy URL', 2000);
btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> Copied!';
setTimeout(() => btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-clipboard-text"/></svg> Copy URL', 2000);
});
});
@@ -737,8 +737,8 @@
document.getElementById('copyShortUrlBtn')?.addEventListener('click', () => {
const btn = document.getElementById('copyShortUrlBtn');
window.copyToClipboard(shortUrl, () => {
btn.textContent = ' Copied!';
setTimeout(() => btn.textContent = '📡 Copy short URL', 2000);
btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> Copied!';
setTimeout(() => btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Copy short URL', 2000);
});
});
@@ -810,7 +810,7 @@
} else {
neighbor = e.nodeAName || (e.nodeA || '').substring(0, 8);
}
var status = e.ambiguous ? (e.unresolved ? '❓ Unresolved' : '⚠️ Ambiguous') : (e.resolved ? '✅ Auto-resolved' : '✅ Resolved');
var status = e.ambiguous ? (e.unresolved ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-question"/></svg> Unresolved' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Ambiguous') : (e.resolved ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> Auto-resolved' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> Resolved');
html += '<tr><td>' + escapeHtml(neighbor) + '</td><td>' + (e.score || 0).toFixed(3) + '</td><td>' + e.weight + '</td><td>' + (e.lastSeen || '').substring(0, 10) + '</td><td>' + (e.observers || []).length + '</td><td>' + status + '</td></tr>';
});
html += '</tbody></table>';
@@ -825,20 +825,20 @@
html += '<div style="border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px;font-size:12px">';
html += '<b>Prefix: ' + escapeHtml(r.prefix) + '</b> → ';
if (r.method === 'auto-resolved') {
html += '<span style="color:var(--status-green)"> ' + escapeHtml(r.chosenName || r.chosen || '?') + '</span>';
html += '<span style="color:var(--status-green)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> ' + escapeHtml(r.chosenName || r.chosen || '?') + '</span>';
html += ' (Jaccard=' + r.chosenJaccard.toFixed(2) + ', ratio=' + ((isFinite(r.ratio) && r.ratio < 100) ? r.ratio.toFixed(1) + '×' : '∞') + ')';
} else {
html += '<span style="color:var(--status-yellow)">⚠️ Ambiguous</span>';
html += '<span style="color:var(--status-yellow)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Ambiguous</span>';
if (r.ratio) html += ' (ratio=' + r.ratio.toFixed(1) + '×, threshold=' + r.thresholdApplied + '×)';
}
// Show disambiguation tier used (M4 resolveWithContext)
if (r.tier) {
var tierLabels = {
'neighbor_affinity': '🏘️ Affinity',
'geo_proximity': '🌍 Geo',
'gps_preference': '📍 GPS',
'first_match': '🎲 Naive',
'unique_prefix': ' Unique',
'neighbor_affinity': '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-buildings"/></svg> Affinity',
'geo_proximity': '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-globe"/></svg> Geo',
'gps_preference': '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-pin"/></svg> GPS',
'first_match': '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-dice-five"/></svg> Naive',
'unique_prefix': '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check"/></svg> Unique',
'no_match': '∅ None'
};
html += ' <span style="font-size:11px;opacity:0.8">[tier: ' + (tierLabels[r.tier] || escapeHtml(r.tier)) + ']</span>';
@@ -1046,7 +1046,7 @@
var bimodalWarning = '';
if (cs.severity === 'bimodal_clock') {
var totalRecent = cs.recentSampleCount || 0;
var summary = '⚠️ ' + (cs.recentBadSampleCount || '?') + ' of last ' + (totalRecent || '?') + ' adverts had nonsense timestamps (likely RTC reset)';
var summary = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> ' + (cs.recentBadSampleCount || '?') + ' of last ' + (totalRecent || '?') + ' adverts had nonsense timestamps (likely RTC reset)';
var badList = '';
if (Array.isArray(cs.recentBadSamples) && cs.recentBadSamples.length) {
var items = cs.recentBadSamples.map(function(bs) {
@@ -1067,7 +1067,7 @@
'<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">' +
skewDisplay +
renderSkewBadge(cs.severity, skewVal, cs) +
(cs.calibrated ? ' <span style="font-size:10px;color:var(--text-muted)" title="Observer-calibrated"> calibrated</span>' : '') +
(cs.calibrated ? ' <span style="font-size:10px;color:var(--text-muted)" title="Observer-calibrated"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check"/></svg> calibrated</span>' : '') +
'</div>' +
driftHtml +
(sparkHtml ? '<div class="skew-sparkline-wrap" style="margin-top:8px">' + sparkHtml + '<div style="font-size:10px;color:var(--text-muted)">Skew over time (' + (cs.samples || []).length + ' samples)</div></div>' : '') +
@@ -1416,7 +1416,7 @@
const cs = _fleetSkew && _fleetSkew[n.public_key];
const skewBadgeHtml = cs && cs.severity && cs.severity !== 'ok' ? renderSkewBadge(cs.severity, window.currentSkewValue(cs), cs) : '';
return `<tr data-key="${n.public_key}" data-action="select" data-value="${n.public_key}" tabindex="0" role="row" class="${selectedKey === n.public_key ? 'selected' : ''}${isClaimed ? ' claimed-row' : ''}">
<td>${favStar(n.public_key, 'node-fav')}${isClaimed ? '<span class="claimed-badge" title="My Mesh"></span> ' : ''}<strong>${escapeHtml(n.name || '(unnamed)')}</strong>${dupNameBadge(n.name, n.public_key, dupMap)}${skewBadgeHtml}</td>
<td>${favStar(n.public_key, 'node-fav')}${isClaimed ? '<span class="claimed-badge" title="My Mesh"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-star-fill"/></svg></span> ' : ''}<strong>${escapeHtml(n.name || '(unnamed)')}</strong>${dupNameBadge(n.name, n.public_key, dupMap)}${skewBadgeHtml}</td>
<td class="mono col-pubkey">${truncate(n.public_key, 16)}</td>
<td><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span></td>
<td style="font-family:var(--mono);font-size:12px">${n.default_scope ? escapeHtml(n.default_scope) : ''}</td>
@@ -1543,13 +1543,13 @@
const dupBadge = dupNameBadge(n.name, n.public_key, dupMap);
panel.innerHTML = `
<button class="panel-close-btn" title="Close detail pane (Esc)"></button>
<button class="panel-close-btn" title="Close detail pane (Esc)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
<div class="node-detail">
<div class="node-detail-name">${escapeHtml(n.name || '(unnamed)')}${dupBadge}</div>
<div class="node-detail-role">${renderNodeBadges(n, roleColor)}
<button class="btn-primary node-detail-btn" data-pubkey="${encodeURIComponent(n.public_key)}" aria-label="View details for ${escapeHtml(n.name || n.public_key)}" style="font-size:11px;padding:2px 8px;margin-left:8px;cursor:pointer">🔍 Details</button>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px">📊 Analytics</a>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/reach" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px">📡 Reach</a>
<button class="btn-primary node-detail-btn" data-pubkey="${encodeURIComponent(n.public_key)}" aria-label="View details for ${escapeHtml(n.name || n.public_key)}" style="font-size:11px;padding:2px 8px;margin-left:8px;cursor:pointer"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Details</button>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> Analytics</a>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/reach" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Reach</a>
</div>
${renderStatusExplanation(n)}
@@ -1583,14 +1583,14 @@
let decoded;
try { decoded = JSON.parse(a.decoded_json); } catch {}
const pType = PAYLOAD_TYPES[a.payload_type] || 'Packet';
const icon = a.payload_type === 4 ? '📡' : a.payload_type === 5 ? '💬' : a.payload_type === 2 ? '✉️' : '📦';
const icon = a.payload_type === 4 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg>' : a.payload_type === 5 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chat-circle"/></svg>' : a.payload_type === 2 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope"/></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-package"/></svg>';
const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : decoded?.name ? ' — ' + escapeHtml(decoded.name) : '';
const obs = a.observer_name || a.observer_id;
return `<div class="advert-entry">
<span class="advert-dot" style="background:${roleColor}"></span>
<div class="advert-info">
<strong>${renderNodeTimestampHtml(a.timestamp)}</strong> ${icon} ${pType}${detail}
${a.observation_count > 1 ? ' <span class="badge badge-obs">👁 ' + a.observation_count + '</span>' : ''}
${a.observation_count > 1 ? ' <span class="badge badge-obs"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> ' + a.observation_count + '</span>' : ''}
${obs ? ' via ' + escapeHtml(obs) : ''}
${a.snr != null ? ` · SNR ${a.snr}dB` : ''}${a.rssi != null ? ` · RSSI ${a.rssi}dBm` : ''}
<br><a href="#/packets/${a.hash}" class="ch-analyze-link">Analyze </a>
@@ -1775,7 +1775,7 @@
window._nodesSortArrow = function(col) {
var st = _getSortState();
if (st.column !== col) return '';
return '<span class="sort-arrow">' + (st.direction === 'asc' ? '▲' : '▼') + '</span>';
return '<span class="sort-arrow">' + (st.direction === 'asc' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-up"/></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-down"/></svg>') + '</span>';
};
window._nodesGetSortState = _getSortState;
window._nodesSetSortState = function(s) {
+33 -33
View File
@@ -304,7 +304,7 @@
panel.innerHTML =
'<div class="slide-over-header">' +
'<h3 class="slide-over-title" id="slideOverTitle"></h3>' +
'<button type="button" class="slide-over-close" aria-label="Close detail (Esc)" title="Close"></button>' +
'<button type="button" class="slide-over-close" aria-label="Close detail (Esc)" title="Close"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>' +
'</div>' +
'<div class="slide-over-content"></div>';
panel.querySelector('.slide-over-close').addEventListener('mousedown', function (e) {
@@ -762,7 +762,7 @@
});
}
const PANEL_WIDTH_KEY = 'meshcore-panel-width';
const PANEL_CLOSE_HTML = '<button class="panel-close-btn" title="Close detail pane (Esc)"></button>';
const PANEL_CLOSE_HTML = '<button class="panel-close-btn" title="Close detail pane (Esc)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>';
// getParsedPath / getParsedDecoded are in shared packet-helpers.js (loaded before this file)
const getParsedPath = window.getParsedPath;
@@ -998,7 +998,7 @@
}
const f = formatTimestampWithTooltip(isoString, getTimestampMode());
const warn = f.isFuture
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed">⚠️</span>'
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></span>'
: '';
return `<span class="timestamp-text" title="${escapeHtml(f.tooltip)}">${escapeHtml(f.text)}</span>${warn}`;
}
@@ -1466,9 +1466,9 @@
<div class="page-header">
<h2>Latest Packets <span class="count">(${totalCount})</span></h2>
<div>
<button class="btn-icon" data-action="pkt-refresh" title="Refresh">🔄</button>
<button class="btn-icon" data-action="pkt-refresh" title="Refresh" aria-label="Refresh"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-arrow-clockwise"/></svg></button>
<button class="btn-icon" id="pktPauseBtn" data-action="pkt-pause" title="Pause live updates"></button>
<button class="btn-icon" data-action="pkt-byop" title="Bring Your Own Packet" aria-label="Bring Your Own Packet - paste raw packet hex for analysis" aria-haspopup="dialog">📦 BYOP</button>
<button class="btn-icon" data-action="pkt-byop" title="Bring Your Own Packet" aria-label="Bring Your Own Packet - paste raw packet hex for analysis" aria-haspopup="dialog"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-package"/></svg> BYOP</button>
</div>
</div>
<div class="filter-group pkt-filter-expr" style="flex:1;margin-bottom:8px;position:relative">
@@ -1483,7 +1483,7 @@
<button class="btn filter-toggle-btn" id="filterToggleBtn">Filters </button>
<!-- #1124 (MAJOR-3) Group 1: Filter input + Clear -->
<div class="filter-group filter-group-clear">
<button class="btn btn-clear-filters" id="clearFiltersBtn" title="Clear all filters" style="display:none;font-size:12px;padding:2px 8px;color:var(--text-muted);border:1px solid var(--border);border-radius:4px;background:transparent;cursor:pointer"> Clear</button>
<button class="btn btn-clear-filters" id="clearFiltersBtn" title="Clear all filters" style="display:none;font-size:12px;padding:2px 8px;color:var(--text-muted);border:1px solid var(--border);border-radius:4px;background:transparent;cursor:pointer"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg> Clear</button>
</div>
<!-- Group 2: Quick filters (hash, node name) -->
<div class="filter-group filter-group-quick">
@@ -1493,13 +1493,13 @@
<div class="node-filter-dropdown hidden" id="fNodeDropdown" role="listbox"></div>
</div>
</div>
<!-- Group 3: Quick toggles (time range, Group by Hash, My Nodes)
<!-- Group 3: Quick toggles (time range, Group by Hash, My Nodes)
#1128 Bug 5: placed BEFORE the Observer/Region/Type/Channel
dropdowns so the most-frequently-used controls sit next to
the search input where the eye lands first. -->
<div class="filter-group filter-group-toggles">
<button class="btn ${groupByHash ? 'active' : ''}" id="fGroup" title="Collapse duplicate observations of the same packet into expandable groups">Group by Hash</button>
<button class="btn" id="fMyNodes" title="Show only packets from your favorited/claimed nodes"> My Nodes</button>
<button class="btn" id="fMyNodes" title="Show only packets from your favorited/claimed nodes"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-star-fill"/></svg> My Nodes</button>
<select id="fTimeWindow" class="filter-select" aria-label="Time window filter">
<option value="15">Last 15 min</option>
<option value="30">Last 30 min</option>
@@ -2166,7 +2166,7 @@
const _grpHashStripe = _hashStripeStyle(p.hash);
const _grpStyle = _grpHashStripe + _grpChanStyle;
let html = `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${_grpStyle ? ' style="' + _grpStyle + '"' : ''}>
<td class="col-expand" style="text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
<td class="col-expand" style="text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-down"/></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-up"/></svg>')}</td>
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
<td class="col-time">${renderTimestampCell(p.latest)}</td>
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(p.hash || '')}">${truncate(p.hash || '—', 8)}</td>
@@ -2175,7 +2175,7 @@
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(groupTypeName || '')}">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>${transportBadge(p.route_type)}` : '—'}</td>
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsNameOnly(headerObserverId) || '')}">${isSingle ? escapeHtml(truncate(obsNameOnly(headerObserverId), 16)) + obsIataBadge(p) : escapeHtml(truncate(obsNameOnly(headerObserverId), 10)) + groupedObserverIataBadgesHtml(p)}</td>
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
<td class="col-rpt">${p.observation_count > 1 ? '<span class="badge badge-obs" title="Seen ' + p.observation_count + ' times">👁 ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
<td class="col-rpt">${p.observation_count > 1 ? '<span class="badge badge-obs" title="Seen ' + p.observation_count + ' times"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
<td class="col-details">${getDetailPreview(getParsedDecoded(p))}</td>
</tr>`;
if (isExpanded && p._children) {
@@ -2740,31 +2740,31 @@
if (decoded.type === 'CHAN' && decoded.text) {
const ch = decoded.channel ? `<span class="chan-tag">${escapeHtml(decoded.channel)}</span> ` : '';
const t = decoded.text.length > 80 ? decoded.text.slice(0, 80) + '…' : decoded.text;
return `${ch}💬 ${escapeHtml(t)}`;
return `${ch}<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chat-circle"/></svg> ${escapeHtml(t)}`;
}
// Advertisements — show node name and role
if (decoded.type === 'ADVERT' && decoded.name) {
const role = decoded.flags?.repeater ? '📡' : decoded.flags?.room ? '🏠' : decoded.flags?.sensor ? '🌡' : '📻';
const role = decoded.flags?.repeater ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg>' : decoded.flags?.room ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-house-line"/></svg>' : decoded.flags?.sensor ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-thermometer"/></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-radio"/></svg>';
return `${role} <a href="#/nodes/${encodeURIComponent(decoded.pubKey)}" class="hop-link hop-named" data-hop-link="true">${escapeHtml(decoded.name)}</a>`;
}
// Undecrypted channel messages — show channel hash and decryption status
if (decoded.type === 'GRP_TXT' && decoded.channelHash != null) {
const hashHex = decoded.channelHashHex || decoded.channelHash.toString(16).padStart(2, '0').toUpperCase();
const statusLabel = decoded.decryptionStatus === 'no_key' ? 'no key' : 'decryption failed';
return `🔒 Ch 0x${hashHex} <span class="muted">(${statusLabel})</span>`;
return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> Ch 0x${hashHex} <span class="muted">(${statusLabel})</span>`;
}
// Direct messages
if (decoded.type === 'TXT_MSG') return `✉️ ${decoded.srcHash?.slice(0,8) || '?'}${decoded.destHash?.slice(0,8) || '?'}`;
if (decoded.type === 'TXT_MSG') return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope"/></svg> ${decoded.srcHash?.slice(0,8) || '?'}${decoded.destHash?.slice(0,8) || '?'}`;
// Path updates
if (decoded.type === 'PATH') return `🔀 ${decoded.srcHash?.slice(0,8) || '?'}${decoded.destHash?.slice(0,8) || '?'}`;
if (decoded.type === 'PATH') return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-shuffle"/></svg> ${decoded.srcHash?.slice(0,8) || '?'}${decoded.destHash?.slice(0,8) || '?'}`;
// Requests/responses (encrypted)
if (decoded.type === 'REQ' || decoded.type === 'RESPONSE') return `🔒 ${decoded.srcHash?.slice(0,8) || '?'}${decoded.destHash?.slice(0,8) || '?'}`;
if (decoded.type === 'REQ' || decoded.type === 'RESPONSE') return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> ${decoded.srcHash?.slice(0,8) || '?'}${decoded.destHash?.slice(0,8) || '?'}`;
// Anonymous requests
if (decoded.type === 'ANON_REQ') return `🔒 anon → ${decoded.destHash?.slice(0,8) || '?'}`;
if (decoded.type === 'ANON_REQ') return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> anon → ${decoded.destHash?.slice(0,8) || '?'}`;
// Companion bridge text
if (decoded.text) return escapeHtml(decoded.text.length > 80 ? decoded.text.slice(0, 80) + '…' : decoded.text);
// Bare adverts with just pubkey
if (decoded.public_key) return `📡 ${decoded.public_key.slice(0, 16)}`;
if (decoded.public_key) return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> ${decoded.public_key.slice(0, 16)}`;
return '';
}
@@ -2823,7 +2823,7 @@
sheet = document.createElement('div');
sheet.id = 'mobileDetailSheet';
sheet.className = 'mobile-detail-sheet';
sheet.innerHTML = '<div class="mobile-sheet-handle"></div><button class="mobile-sheet-close" id="mobileSheetClose"></button><div class="mobile-sheet-content"></div>';
sheet.innerHTML = '<div class="mobile-sheet-handle"></div><button class="mobile-sheet-close" id="mobileSheetClose" aria-label="Close"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button><div class="mobile-sheet-content"></div>';
document.body.appendChild(sheet);
sheet.querySelector('#mobileSheetClose').addEventListener('click', () => {
sheet.classList.remove('open');
@@ -2975,7 +2975,7 @@
const hashHex = decoded.channelHashHex || decoded.channelHash.toString(16).padStart(2, '0').toUpperCase();
const statusLabel = decoded.decryptionStatus === 'no_key' ? 'no key' : 'decryption failed';
messageHtml = `<div class="detail-message" style="padding:12px;margin:8px 0;background:var(--card-bg);border-radius:8px;border-left:3px solid var(--warning, #f0ad4e)">
<div style="font-size:1.1em">🔒 Channel Hash: 0x${hashHex} <span style="color:var(--text-muted)">(${statusLabel})</span></div>
<div style="font-size:1.1em"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> Channel Hash: 0x${hashHex} <span style="color:var(--text-muted)">(${statusLabel})</span></div>
</div>`;
}
@@ -3012,7 +3012,7 @@
const nodeName = decoded.name || '';
locationHtml = `${decoded.lat.toFixed(5)}, ${decoded.lon.toFixed(5)}`;
if (nodeName) locationHtml = `${escapeHtml(nodeName)}${locationHtml}`;
if (locationNodeKey) locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" class="loc-map-link">📍map</a>`;
if (locationNodeKey) locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" class="loc-map-link"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-pin"/></svg>map</a>`;
} else {
// Try to resolve sender node location from nodes list
const senderKey = decoded.pubKey || decoded.srcPubKey;
@@ -3024,7 +3024,7 @@
locationNodeKey = nodeData.node.public_key;
locationHtml = `${nodeData.node.lat.toFixed(5)}, ${nodeData.node.lon.toFixed(5)}`;
if (nodeData.node.name) locationHtml = `${escapeHtml(nodeData.node.name)}${locationHtml}`;
locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" class="loc-map-link">📍map</a>`;
locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" class="loc-map-link"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-pin"/></svg>map</a>`;
} else if (senderName && !senderKey) {
// Search by name
const searchData = await api(`/nodes/search?q=${encodeURIComponent(senderName)}`, { ttl: 30000 }).catch(() => null);
@@ -3033,7 +3033,7 @@
locationNodeKey = match.public_key;
locationHtml = `${match.lat.toFixed(5)}, ${match.lon.toFixed(5)}`;
locationHtml = `${escapeHtml(match.name)}${locationHtml}`;
locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" class="loc-map-link">📍map</a>`;
locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" class="loc-map-link"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-pin"/></svg>map</a>`;
}
}
} catch {}
@@ -3041,7 +3041,7 @@
}
const anomalyBanner = decoded.anomaly
? `<div class="anomaly-banner" style="background:var(--warning, #f0ad4e); color:#000; padding:8px 12px; border-radius:4px; margin-bottom:8px; font-weight:600;">⚠️ Anomaly: ${escapeHtml(decoded.anomaly)}</div>`
? `<div class="anomaly-banner" style="background:var(--warning, #f0ad4e); color:#000; padding:8px 12px; border-radius:4px; margin-bottom:8px; font-weight:600;"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Anomaly: ${escapeHtml(decoded.anomaly)}</div>`
: '';
// Hop count display: use pathHops length (= effective observation's path_json).
@@ -3109,9 +3109,9 @@
${effectivePkt.direction ? `<dt>Direction</dt><dd>${escapeHtml(effectivePkt.direction)}</dd>` : ''}
</dl>
<div class="detail-actions">
<button class="copy-link-btn" data-packet-hash="${pkt.hash || ''}" data-packet-id="${pkt.id}" title="Copy link to this packet">🔗 Copy Link</button>
${pathHops.length ? `<button class="detail-map-link" id="viewRouteBtn">🗺️ View route on map</button>` : ''}
${pkt.hash ? `<a href="#/traces/${pkt.hash}" class="detail-map-link" style="text-decoration:none">🔍 Trace</a>` : ''}
<button class="copy-link-btn" data-packet-hash="${pkt.hash || ''}" data-packet-id="${pkt.id}" title="Copy link to this packet"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-link"/></svg> Copy Link</button>
${pathHops.length ? `<button class="detail-map-link" id="viewRouteBtn"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-trifold"/></svg> View route on map</button>` : ''}
${pkt.hash ? `<a href="#/traces/${pkt.hash}" class="detail-map-link" style="text-decoration:none"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Trace</a>` : ''}
<button class="replay-live-btn" title="Replay this packet on the live map"> Replay</button>
</div>
@@ -3180,8 +3180,8 @@
const obsParam = selectedObservationId ? `?obs=${selectedObservationId}` : '';
const url = pktHash ? `${location.origin}/#/packets/${pktHash}${obsParam}` : `${location.origin}/#/packets/${copyLinkBtn.dataset.packetId}${obsParam}`;
window.copyToClipboard(url, () => {
copyLinkBtn.textContent = ' Copied!';
setTimeout(() => { copyLinkBtn.textContent = '🔗 Copy Link'; }, 1500);
copyLinkBtn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> Copied!';
setTimeout(() => { copyLinkBtn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-link"/></svg> Copy Link'; }, 1500);
});
});
}
@@ -3405,7 +3405,7 @@
}
if (decoded.anomaly) {
rows += `<tr class="anomaly-row" style="background:var(--warning, #f0ad4e); color:#000; font-weight:600;"><td colspan="2">⚠️ Anomaly</td><td colspan="2">${escapeHtml(decoded.anomaly)}</td></tr>`;
rows += `<tr class="anomaly-row" style="background:var(--warning, #f0ad4e); color:#000; font-weight:600;"><td colspan="2"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Anomaly</td><td colspan="2">${escapeHtml(decoded.anomaly)}</td></tr>`;
}
return `<table class="field-table">
@@ -3428,7 +3428,7 @@
const overlay = document.createElement('div');
overlay.className = 'modal-overlay byop-overlay';
overlay.innerHTML = '<div class="modal byop-modal" role="dialog" aria-label="Decode a Packet" aria-modal="true">'
+ '<div class="byop-header"><h3>📦 Decode a Packet</h3><button class="btn-icon byop-x" title="Close" aria-label="Close dialog"></button></div>'
+ '<div class="byop-header"><h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-package"/></svg> Decode a Packet</h3><button class="btn-icon byop-x" title="Close" aria-label="Close dialog"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button></div>'
+ '<p class="text-muted" style="margin:0 0 12px;font-size:.85rem">Paste raw hex bytes from your radio or MQTT feed:</p>'
+ '<textarea id="byopHex" class="byop-input" aria-label="Packet hex data" placeholder="e.g. 15C31A8D4674FEAE37..." spellcheck="false"></textarea>'
+ '<button class="btn-primary byop-go" id="byopDecode" style="width:100%;margin:8px 0">Decode</button>'
@@ -3486,7 +3486,7 @@
if (data.error) throw new Error(data.error);
result.innerHTML = renderDecodedPacket(data.decoded, hex);
} catch (e) {
result.innerHTML = '<p class="byop-err" role="alert"> ' + e.message + '</p>';
result.innerHTML = '<p class="byop-err" role="alert"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x-circle"/></svg> ' + e.message + '</p>';
}
}
}
@@ -3501,7 +3501,7 @@
// Anomaly banner
if (d.anomaly) {
html += '<div class="anomaly-banner" style="background:var(--warning, #f0ad4e); color:#000; padding:8px 12px; border-radius:4px; margin-bottom:8px; font-weight:600;">⚠️ Anomaly: ' + escapeHtml(d.anomaly) + '</div>';
html += '<div class="anomaly-banner" style="background:var(--warning, #f0ad4e); color:#000; padding:8px 12px; border-radius:4px; margin-bottom:8px; font-weight:600;"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Anomaly: ' + escapeHtml(d.anomaly) + '</div>';
}
// Header section
+9 -9
View File
@@ -88,7 +88,7 @@ function renderVersionCard(health) {
let interval = null;
async function render(app) {
app.innerHTML = '<div id="perfWrapper" style="padding:16px 24px;"><h2> Performance Dashboard</h2><div id="perfContent">Loading...</div></div>';
app.innerHTML = '<div id="perfWrapper" style="padding:16px 24px;"><h2><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lightning"/></svg> Performance Dashboard</h2><div id="perfContent">Loading...</div></div>';
await refresh();
}
@@ -124,7 +124,7 @@ function renderVersionCard(health) {
if (isGo && server.goRuntime) {
const gr = server.goRuntime;
const gcColor = gr.lastPauseMs > 5 ? 'var(--status-red)' : gr.lastPauseMs > 1 ? 'var(--status-yellow)' : 'var(--status-green)';
html += `<h3>🔧 Go Runtime</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
html += `<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-wrench"/></svg> Go Runtime</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${gr.goroutines}</div><div class="perf-label">Goroutines</div></div>
<div class="perf-card"><div class="perf-num">${gr.numGC}</div><div class="perf-label">GC Collections</div></div>
<div class="perf-card"><div class="perf-num" style="color:${gcColor}">${(+gr.pauseTotalMs).toFixed(1)}ms</div><div class="perf-label">GC Pause Total</div></div>
@@ -158,11 +158,11 @@ function renderVersionCard(health) {
if (bps >= 1024) return (bps / 1024).toFixed(1) + ' KB/s';
return Math.round(bps) + ' B/s';
};
const writeWarn = ioStats.writeBytesPerSec > 10 * 1048576 ? ' ⚠️' : '';
const writeWarn = ioStats.writeBytesPerSec > 10 * 1048576 ? ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '';
const cancelled = ioStats.cancelledWriteBytesPerSec || 0;
// Cancelled writes warn at >1 MB/s — sustained cancellation usually
// means truncate/unlink racing with active writers (#1119-shaped bug).
const cancelledWarn = cancelled > 1048576 ? ' ⚠️' : '';
const cancelledWarn = cancelled > 1048576 ? ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '';
html += `<h3>Disk I/O (server process)</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${fmtRate(ioStats.readBytesPerSec || 0)}</div><div class="perf-label">Read</div></div>
<div class="perf-card"><div class="perf-num">${fmtRate(ioStats.writeBytesPerSec || 0)}${writeWarn}</div><div class="perf-label">Write</div></div>
@@ -175,9 +175,9 @@ function renderVersionCard(health) {
// surfaced via the stats file (#1120: "Both ingestor and server").
if (ioStats.ingestor) {
const ing = ioStats.ingestor;
const ingWriteWarn = (ing.writeBytesPerSec || 0) > 10 * 1048576 ? ' ⚠️' : '';
const ingWriteWarn = (ing.writeBytesPerSec || 0) > 10 * 1048576 ? ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '';
const ingCancelled = ing.cancelledWriteBytesPerSec || 0;
const ingCancelledWarn = ingCancelled > 1048576 ? ' ⚠️' : '';
const ingCancelledWarn = ingCancelled > 1048576 ? ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '';
html += `<h3>Disk I/O (Ingestor process)</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${fmtRate(ing.readBytesPerSec || 0)}</div><div class="perf-label">Read</div></div>
<div class="perf-card"><div class="perf-num">${fmtRate(ing.writeBytesPerSec || 0)}${ingWriteWarn}</div><div class="perf-label">Write</div></div>
@@ -215,7 +215,7 @@ function renderVersionCard(health) {
const v = src[k] || 0;
const rate = anom.rates[k];
const base = anom.baselineRates[k];
const flag = anom.flags[k] ? ' ⚠️' : '';
const flag = anom.flags[k] ? ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '';
const rateStr = (rate != null && isFinite(rate)) ? rate.toFixed(2) : '—';
const baseStr = (base != null && isFinite(base)) ? base.toFixed(2) : '—';
html += `<tr><td><code>${k}</code></td><td>${v.toLocaleString()}</td><td>${rateStr}</td><td>${baseStr}</td><td>${flag}</td></tr>`;
@@ -230,9 +230,9 @@ function renderVersionCard(health) {
// SQLite perf (separate from existing SQLite block — focused on WAL + cache hit) (#1120)
if (sqliteStats) {
const walMB = sqliteStats.walSizeMB || 0;
const walFlag = walMB > 100 ? ' ⚠️' : '';
const walFlag = walMB > 100 ? ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '';
const hitRate = (sqliteStats.cacheHitRate || 0) * 100;
const hitFlag = hitRate > 0 && hitRate < 90 ? ' ⚠️' : '';
const hitFlag = hitRate > 0 && hitRate < 90 ? ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '';
html += `<h3>SQLite (WAL + Cache Hit)</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${walMB.toFixed(1)}MB${walFlag}</div><div class="perf-label">WAL Size</div></div>
<div class="perf-card"><div class="perf-num">${(sqliteStats.pageCount || 0).toLocaleString()}</div><div class="perf-label">Page Count</div></div>
+1 -1
View File
@@ -771,7 +771,7 @@
if (!severity) return '';
var cls = 'skew-badge skew-badge--' + severity;
if (severity === 'no_clock') {
return '<span class="' + cls + '" title="Uninitialized RTC — no valid clock">🚫 No Clock</span>';
return '<span class="' + cls + '" title="Uninitialized RTC — no valid clock"><svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-prohibit"/></svg> No Clock</span>';
}
if (severity === 'bimodal_clock' && cs) {
var badPct = cs.goodFraction != null ? Math.round((1 - cs.goodFraction) * 100) : '?';
+7 -4
View File
@@ -2277,13 +2277,16 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
/* Favorite stars */
/* Favorite stars — Phosphor sprite, 44×44 hit target (WCAG 2.5.5) */
.fav-star {
background: none; border: none; cursor: pointer; font-size: 1.1rem;
color: var(--text-muted); padding: 2px 4px; line-height: 1;
background: none; border: none; cursor: pointer;
color: var(--text-muted); padding: 0; line-height: 1;
transition: color .15s, transform .15s;
min-width: 44px; min-height: 44px;
display: inline-flex; align-items: center; justify-content: center;
}
.fav-star:hover { transform: scale(1.2); }
.fav-star .ph-icon { width: 22px; height: 22px; }
.fav-star:hover { transform: scale(1.1); }
.fav-star.on { color: var(--status-yellow); }
/* BYOP Decode Modal */
+2 -1
View File
@@ -157,7 +157,8 @@ window.TableSort = (function() {
th.setAttribute('aria-sort', state.direction === 'asc' ? 'ascending' : 'descending');
var span = document.createElement('span');
span.className = 'sort-arrow';
span.textContent = state.direction === 'asc' ? ' ▲' : ' ▼';
// #1648 M2 — Phosphor caret-up/caret-down via sprite (no emoji).
span.innerHTML = ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-' + (state.direction === 'asc' ? 'up' : 'down') + '"/></svg>';
th.appendChild(span);
} else {
th.classList.remove('sort-active');
+1 -1
View File
@@ -18,7 +18,7 @@
app.innerHTML = `
<div class="traces-page">
<div class="page-header">
<h2>🔍 Packet Trace</h2>
<h2><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Packet Trace</h2>
</div>
<div class="trace-search">
<input type="text" id="traceHashInput" placeholder="Enter packet hash…" value="${escapeHtml(urlHash)}" aria-label="Packet hash to trace">
+1
View File
@@ -29,6 +29,7 @@ node test-issue-1409-no-encrypted-flood.js
node test-analytics-channels-integration.js
node test-observers-headings.js
node test-issue-1648-m1-emoji-scan.js
node test-issue-1648-m2-emoji-scan.js
node test-traces.js
# #1418 — route-view v2 (Tufte) coverage
+12 -8
View File
@@ -3942,7 +3942,9 @@ console.log('\n=== nodes.js: toggleSort / sortNodes / sortArrow ===');
const ctx = makeNodesSandbox();
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
const html = ctx.window._nodesSortArrow('name');
assert.ok(html.includes('▲'));
// #1648 M2 — Phosphor sprite caret-up replaces ▲.
assert.ok(html.includes('#ph-caret-up') || html.includes('▲'),
'should contain ascending arrow (▲ or ph-caret-up)');
assert.ok(html.includes('sort-arrow'));
});
@@ -3950,7 +3952,9 @@ console.log('\n=== nodes.js: toggleSort / sortNodes / sortArrow ===');
const ctx = makeNodesSandbox();
ctx.window._nodesSetSortState({ column: 'name', direction: 'desc' });
const html = ctx.window._nodesSortArrow('name');
assert.ok(html.includes('▼'));
// #1648 M2 — Phosphor sprite caret-down replaces ▼.
assert.ok(html.includes('#ph-caret-down') || html.includes('▼'),
'should contain descending arrow (▼ or ph-caret-down)');
});
test('sortArrow returns empty for inactive column', () => {
@@ -5237,21 +5241,21 @@ console.log('\n=== analytics.js: renderMultiByteCapability ===');
test('renders confirmed status with green indicator', () => {
const html = render([{ pubkey: 'aabb', name: 'RepA', role: 'repeater', status: 'confirmed', evidence: 'advert', maxHashSize: 2, lastSeen: '' }]);
assert.ok(html.includes('✅'), 'should contain confirmed icon');
assert.ok(html.includes('#ph-check-circle') || html.includes('✅'), 'should contain confirmed icon (✅ or ph-check-circle)');
assert.ok(html.includes('Confirmed'), 'should contain Confirmed label');
assert.ok(html.includes('--success'), 'should use --success CSS var for green');
});
test('renders suspected status with yellow indicator', () => {
const html = render([{ pubkey: 'ccdd', name: 'RepB', role: 'repeater', status: 'suspected', evidence: 'path', maxHashSize: 2, lastSeen: '' }]);
assert.ok(html.includes('⚠️'), 'should contain suspected icon');
assert.ok(html.includes('#ph-warning') || html.includes('⚠️'), 'should contain suspected icon (⚠️ or ph-warning)');
assert.ok(html.includes('Suspected'), 'should contain Suspected label');
assert.ok(html.includes('--warning'), 'should use --warning CSS var for yellow');
});
test('renders unknown status with gray indicator', () => {
const html = render([{ pubkey: 'eeff', name: 'RepC', role: 'repeater', status: 'unknown', evidence: '', maxHashSize: 1, lastSeen: '' }]);
assert.ok(html.includes('❓'), 'should contain unknown icon');
assert.ok(html.includes('#ph-question') || html.includes('❓'), 'should contain unknown icon (❓ or ph-question)');
assert.ok(html.includes('Unknown'), 'should contain Unknown label');
assert.ok(html.includes('--text-muted'), 'should use --text-muted CSS var for gray');
});
@@ -5339,7 +5343,7 @@ console.log('\n=== analytics.js: renderMultiByteAdopters ===');
{ pubkey: 'aa11', name: 'NodeA', role: 'repeater', status: 'confirmed', evidence: 'advert', maxHashSize: 2, lastSeen: '' },
];
const html = renderAdopters(nodes, caps);
assert.ok(html.includes('✅'), 'should show confirmed icon');
assert.ok(html.includes('#ph-check-circle') || html.includes('✅'), 'should show confirmed icon');
assert.ok(html.includes('Confirmed'), 'should show Confirmed label');
assert.ok(html.includes('2-byte'), 'should show hash size badge');
});
@@ -5365,7 +5369,7 @@ console.log('\n=== analytics.js: renderMultiByteAdopters ===');
{ name: 'Orphan', pubkey: 'zz99', role: 'repeater', hashSize: 2, packets: 1, lastSeen: '' },
];
const html = renderAdopters(nodes, []); // no caps
assert.ok(html.includes('❓'), 'should show unknown icon');
assert.ok(html.includes('#ph-question') || html.includes('❓'), 'should show unknown icon');
assert.ok(html.includes('Unknown'), 'should show Unknown label');
});
@@ -5818,7 +5822,7 @@ console.log('\n=== analytics.js: hashStatCardsHtml collision details ===');
assert.ok(html.includes('onclick='), 'should have onclick when collisions > 0');
assert.ok(html.includes('collisionRiskSection'), 'should scroll to collisionRiskSection');
assert.ok(html.includes('cursor:pointer'), 'should show pointer cursor');
assert.ok(html.includes('▼'), 'should show expand indicator');
assert.ok(html.includes('#ph-caret-down') || html.includes('▼'), 'should show expand indicator');
});
test('collision count 0 renders non-clickable card', () => {
+3 -2
View File
@@ -160,8 +160,9 @@ console.log('\n=== #1356 Round-1 coverage adds: dual-marker star, null mbStatus,
// makeRepeaterLabelIcon's obsIndicator branch MUST carry aria-hidden="true",
// otherwise the AT announcement is polluted with "black star" / "star" on
// top of the meaningful aria-label. Round-1 (Kent + adversarial) flagged.
// Match the exact obsIndicator construction shape: `isAlsoObserver ? ' <span aria-hidden="true" ... ★`.
assert(/isAlsoObserver[\s\S]{0,40}\?\s*['"][^'"]*<span\s+aria-hidden="true"[^>]*>[^<]*★/.test(mapSrc),
// Match either the legacy `★` glyph form OR the Phosphor SVG form
// (issue #1648 M2 swap) — both rendered inside an aria-hidden="true" span.
assert(/isAlsoObserver[\s\S]{0,40}\?\s*['"][^'"]*<span\s+aria-hidden="true"[^>]*>[^<]*(?:★|<svg[^>]*class="ph-icon"[^>]*>[\s\S]{0,200}?#ph-star)/.test(mapSrc),
'observer-also-repeater star span carries aria-hidden="true" (no AT pollution)');
// COV-2 — makeRepeaterLabelIcon with no multi_byte_status field must NOT emit
+34 -5
View File
@@ -46,10 +46,15 @@ async function main() {
process.exit(0);
}
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
// Viewport 1920px keeps every top-nav link inline (per nav-priority-1311
// spec: ≥1920px → all 11 high-pri links visible, More menu empty). At 1280
// Perf/Audio-Lab collapse into the More dropdown (hidden parent → 0×0
// child icon), and #hamburger is mobile-only (display:none on desktop),
// so those surfaces are asserted at the appropriate viewport below.
const ctx = await browser.newContext({ viewport: { width: 1920, height: 900 } });
const page = await ctx.newPage();
// ── (a) top-nav: Live, Perf, Audio-Lab, Search toggle, Customize, theme, hamburger ──
// ── (a) top-nav: Live, Perf, Audio-Lab, Search toggle, Customize, theme ──
await page.goto(`${BASE}/#/`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.top-nav', { timeout: 5000 });
@@ -60,7 +65,6 @@ async function main() {
['Audio-Lab', '.top-nav a[data-route="audio-lab"]'],
['Search btn', '#searchToggle'],
['Custom btn', '#customizeToggle'],
['Hamburger', '#hamburger'],
];
return sels.map(([label, sel]) => {
const el = document.querySelector(sel);
@@ -91,6 +95,28 @@ async function main() {
const m = await ctxMobile.newPage();
await m.goto(`${BASE}/#/`, { waitUntil: 'domcontentloaded' });
await m.waitForSelector('[data-bottom-nav]', { timeout: 5000 });
// Hamburger element exists in the DOM but is rendered display:none at
// all widths (≥768px: style.css ".hamburger { display:none }"; ≤768px:
// bottom-nav.css "#hamburger { display:none !important }" — replaced by
// the bottom-nav "More" tab per #1174). So we can't assert getBBox size,
// but we MUST still assert (i) it has a .ph-icon child and (ii) zero
// emoji codepoints in its text — that catches a revert of the M1 swap.
const hamCheck = await m.evaluate(() => {
const el = document.querySelector('#hamburger');
if (!el) return { found: false };
const ph = el.querySelector('svg.ph-icon, .ph-icon');
return {
found: true,
hasPhIcon: !!ph,
text: el.textContent || '',
};
});
if (!hamCheck.found) fail('(a) Hamburger: selector #hamburger not found');
else if (!hamCheck.hasPhIcon) fail(`(a) Hamburger: no .ph-icon child (text="${hamCheck.text.slice(0,40)}")`);
else pass('(a) Hamburger: .ph-icon child present (element is display:none by design)');
if (EMOJI_RE.test(hamCheck.text)) fail('(a) Hamburger: still contains emoji codepoint in text');
const bn = await m.evaluate(() => {
const tabs = ['home','packets','live','map','channels','more'];
return tabs.map(route => {
@@ -120,8 +146,11 @@ async function main() {
// Give the page a moment to render observers list.
await m.waitForTimeout(1500);
const obs = await m.evaluate(() => {
// Compare observers heading: span containing "Compare" near top of /observers
const headings = Array.from(document.querySelectorAll('h2, h3, .observers-compare-heading, [data-role="compare-heading"]'));
// Compare observers entry-point: M1 made this a <button data-action="compare-observers">
// with a Phosphor ph-magnifying-glass child (was an h2/h3 emoji header pre-M1).
const headings = Array.from(document.querySelectorAll(
'h2, h3, .observers-compare-heading, [data-role="compare-heading"], [data-action="compare-observers"]'
));
const compareHeading = headings.find(h => /Compare\s+observers/i.test(h.textContent || ''));
const compareBtn = document.querySelector('[data-action="compare"], button[data-role="compare-btn"], .compare-selected-btn')
|| Array.from(document.querySelectorAll('button')).find(b => /Compare\s+selected/i.test(b.textContent || ''));
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env node
/* Issue #1648 M2: emoji Phosphor sprite migration (static scan).
*
* M2 covers page headers + table chrome surfaces:
* analytics.js, nodes.js, packets.js, live.js, map.js,
* node-analytics.js, traces.js, perf.js, audio-lab.js
*
* Asserts (per file):
* 1. Zero UI-iconography codepoints (U+1F3001FAFF, U+260027BF, and
* Misc-Symbols: ) outside an allowlist of contexts that are
* not UI iconography (CSS comments, JS comments referencing prior
* glyphs, console.log/debug strings, and explicitly tagged
* // EMOJI-OK lines).
* 2. At least N <use href="#ph- references where N is roughly the
* historical swap count (i.e., the swaps actually landed).
*
* Anti-tautology: this test FAILS today (pre-fix) by construction each
* M2 file currently contains many of the offending codepoints.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const ROOT = path.resolve(__dirname, 'public');
const M2_FILES = [
'analytics.js',
'nodes.js',
'packets.js',
'live.js',
'map.js',
'node-analytics.js',
'traces.js',
'perf.js',
'audio-lab.js',
];
// Minimum number of Phosphor <use href> swaps we expect per file after M2.
// Floor values, intentionally conservative so we don't over-fit.
const MIN_USE_REFS = {
'analytics.js': 60,
'nodes.js': 15,
'packets.js': 18,
'live.js': 15,
'map.js': 10,
'node-analytics.js': 4,
'traces.js': 1,
'perf.js': 9,
'audio-lab.js': 5,
};
// Codepoint ranges (emoji proper).
const EMOJI = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u;
// Misc-symbols used as iconography per #1648.
const MISC_ICON = /[◆●■▲★☆○✓✗⚠✉]/u;
// Allowlist substrings: lines containing any of these are exempt from the
// scan (they reference prior emoji as data, not UI). Keep this tight.
const ALLOW_SUBSTRINGS = [
'EMOJI-OK', // explicit per-line opt-out tag
'// alongside the', // live.js code comment referencing the old 👁 badge
'// renderer can label', // map.js comment
'// Unresolved prefix', // map.js comment
'// clicking #liveFullscreenToggle', // live.js comment
];
function scanFile(rel) {
const abs = path.join(ROOT, rel);
const txt = fs.readFileSync(abs, 'utf8');
const lines = txt.split('\n');
const hits = [];
lines.forEach((line, idx) => {
if (ALLOW_SUBSTRINGS.some(s => line.includes(s))) return;
const e = EMOJI.test(line);
const m = MISC_ICON.test(line);
if (e || m) {
hits.push({ file: rel, line: idx + 1, kind: e && m ? 'both' : (e ? 'emoji' : 'misc'), text: line.trim().slice(0, 200) });
}
});
return hits;
}
function countUseRefs(rel) {
const abs = path.join(ROOT, rel);
const txt = fs.readFileSync(abs, 'utf8');
return (txt.match(/<use href="\/icons\/phosphor-sprite\.svg#ph-/g) || []).length;
}
function assertSpriteHasM2Icons() {
const sp = path.join(ROOT, 'icons', 'phosphor-sprite.svg');
const txt = fs.readFileSync(sp, 'utf8');
const need = [
// M2-added icons that were not in M1
'ph-bomb', 'ph-buildings', 'ph-caret-down', 'ph-caret-up',
'ph-cell-signal-high', 'ph-chats', 'ph-check-circle', 'ph-clipboard-text',
'ph-clock', 'ph-crosshair', 'ph-envelope', 'ph-flame', 'ph-gear',
'ph-globe', 'ph-graph', 'ph-handshake', 'ph-info', 'ph-key', 'ph-link',
'ph-lock-open', 'ph-map-pin', 'ph-path', 'ph-piano-keys', 'ph-prohibit',
'ph-question', 'ph-radio', 'ph-repeat', 'ph-ruler', 'ph-share-network',
'ph-shuffle', 'ph-signpost', 'ph-speaker-high', 'ph-target',
'ph-thermometer', 'ph-trend-up', 'ph-trophy', 'ph-x-circle',
'ph-battery-high', 'ph-battery-low', 'ph-arrows-out', 'ph-pulse',
'ph-chart-line', 'ph-list-numbers', 'ph-dice-five', 'ph-book-open',
'ph-microphone', 'ph-house-line', 'ph-push-pin',
];
const missing = need.filter(id => !txt.includes(`id="${id}"`));
if (missing.length) throw new Error(`sprite missing M2 symbols: ${missing.join(', ')}`);
}
function main() {
let failed = 0;
console.log('— Issue #1648 M2 — emoji/misc-icon scan');
try {
assertSpriteHasM2Icons();
console.log(' ✓ sprite has required M2 symbols');
} catch (e) {
console.error(`${e.message}`);
failed++;
}
for (const rel of M2_FILES) {
const hits = scanFile(rel);
if (hits.length === 0) {
console.log(`${rel} clean (no emoji / misc-icon iconography)`);
} else {
console.error(`${rel} has ${hits.length} emoji/misc-icon hit(s):`);
for (const h of hits.slice(0, 30)) console.error(` ${h.file}:${h.line} [${h.kind}] ${h.text}`);
if (hits.length > 30) console.error(` … (+${hits.length - 30} more)`);
failed++;
}
const useRefs = countUseRefs(rel);
const min = MIN_USE_REFS[rel] || 1;
if (useRefs < min) {
console.error(`${rel} has only ${useRefs} <use href="…#ph-…"> refs (expected ≥${min})`);
failed++;
} else {
console.log(`${rel} has ${useRefs} Phosphor <use> refs (≥${min})`);
}
// Hard assertion (separate from logging) — file must be clean AND have enough swaps.
assert.strictEqual(hits.length, 0,
`${rel} must contain zero emoji/misc-icon iconography (got ${hits.length} hit(s))`);
assert.ok(useRefs >= min,
`${rel} must have ≥${min} <use href="…#ph-…"> refs (got ${useRefs})`);
}
if (failed) {
console.error(`\nFAIL: ${failed} M2 check(s) failed`);
process.exit(1);
}
console.log('\nPASS: all M2 surfaces icon-free and Phosphor-swapped');
}
main();
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env node
/* Issue #1648 M2: emoji Phosphor sprite migration (E2E behavioral).
*
* Asserts (in a real Chromium against a running server):
* (a) /analytics renders the Mesh Analytics h2 with a .ph-icon child and
* zero emoji codepoints in the rendered heading text.
* (b) /packets filter row renders Refresh / BYOP / Clear / My Nodes
* buttons each with a .ph-icon child and no emoji text.
* (c) /nodes table renders sort-arrow indicators using .ph-icon (no
* legacy in the rendered DOM) and the panel-close button has a
* .ph-icon.
* (d) /live renders the audio-toggle label with a .ph-icon, feed
* hide/show buttons each have .ph-icon.
* (e) /map renders the Map Controls h3 with .ph-icon and the
* map-controls-toggle has .ph-icon (no emoji).
* (f) /traces renders Packet Trace h2 with .ph-icon.
* (g) /perf renders Performance Dashboard h2 with .ph-icon.
* (h) /audio-lab renders Packet Data h3 with .ph-icon.
* (i) NO .notdef glyph appears in any rendered SVG sprite ref.
*
* CI gating: CHROMIUM_REQUIRE=1 makes Chromium-launch failure a HARD FAIL.
*/
'use strict';
const { chromium } = require('playwright');
const assert = require('assert');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}◆●■▲★☆○✓✗⚠✉]/u;
let passes = 0, failures = 0;
function pass(msg) { console.log(`${msg}`); passes++; }
function fail(msg) { console.error(`${msg}`); failures++; }
async function checkHeading(page, route, headingPattern, label) {
await page.goto(`${BASE}/#${route}`, { waitUntil: 'domcontentloaded' });
// Wait for the SPA to render. The headings live inside #app after route resolution.
try {
await page.waitForFunction(
(pat) => {
const hs = Array.from(document.querySelectorAll('h2, h3'));
return hs.some(h => new RegExp(pat, 'i').test(h.textContent || ''));
},
headingPattern.source,
{ timeout: 8000 },
);
} catch {
fail(`${label}: heading matching /${headingPattern.source}/i did not render`);
return;
}
const res = await page.evaluate((pat) => {
const hs = Array.from(document.querySelectorAll('h2, h3'));
const h = hs.find(e => new RegExp(pat, 'i').test(e.textContent || ''));
if (!h) return null;
const ph = h.querySelector('svg.ph-icon, .ph-icon');
return { has: !!ph, text: h.textContent || '' };
}, headingPattern.source);
if (!res) { fail(`${label}: heading went away after wait`); return; }
if (!res.has) fail(`${label}: heading has no .ph-icon (text="${res.text.slice(0,60)}")`);
else pass(`${label}: heading has .ph-icon`);
if (EMOJI_RE.test(res.text)) fail(`${label}: heading text still contains emoji`);
}
async function spriteRefsResolve(page, label) {
const r = await page.evaluate(() => {
const uses = Array.from(document.querySelectorAll('svg.ph-icon use'));
return {
count: uses.length,
// We assume any rendered .ph-icon use is OK if sprite loaded; verify by
// fetching the sprite URL & confirming the symbol id exists.
refs: uses.slice(0, 5).map(u => u.getAttribute('href') || u.getAttribute('xlink:href') || ''),
};
});
if (r.count === 0) fail(`${label}: zero <svg.ph-icon use> elements rendered`);
else pass(`${label}: ${r.count} sprite refs rendered (samples: ${r.refs.join(', ')})`);
}
async function main() {
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (requireChromium) {
console.error(`test-issue-1648-m2-icons-e2e.js: HARD FAIL — Chromium unavailable: ${err.message}`);
process.exit(1);
}
console.warn(`SKIP — Chromium unavailable: ${err.message}`);
process.exit(0);
}
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
// (a) /analytics
await checkHeading(page, '/analytics', /Mesh Analytics/, '(a) /analytics');
await spriteRefsResolve(page, '(a) /analytics sprite');
// (b) /packets — Refresh / BYOP / My Nodes buttons (visible without any data
// dependency since they're in the static filter chrome).
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
try {
await page.waitForSelector('[data-action="pkt-refresh"]', { timeout: 8000 });
} catch {
fail('(b) /packets: pkt-refresh button not rendered');
}
const pkt = await page.evaluate(() => {
function has(sel) {
const el = document.querySelector(sel);
if (!el) return { sel, found: false };
return {
sel, found: true,
ph: !!el.querySelector('svg.ph-icon, .ph-icon'),
text: el.textContent || '',
};
}
return {
refresh: has('[data-action="pkt-refresh"]'),
byop: has('[data-action="pkt-byop"]'),
myNodes: has('#fMyNodes'),
};
});
for (const [name, r] of Object.entries(pkt)) {
if (!r.found) { fail(`(b) /packets: ${name} not found`); continue; }
if (!r.ph) fail(`(b) /packets: ${name} has no .ph-icon`);
else pass(`(b) /packets: ${name} has .ph-icon`);
if (EMOJI_RE.test(r.text)) fail(`(b) /packets: ${name} text still has emoji`);
}
// (c) /nodes — sort-arrow indicators (sort-arrow class) — exercise click on a
// header to trigger sort indicator. To avoid flakiness when there's no data,
// we just assert NO emoji codepoints appear in the rendered table chrome
// (filter bar, search input area, panel-close-btn template).
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => !!document.querySelector('table, .nodes-empty, .empty-state, #nodesList'),
null, { timeout: 8000 },
).catch(() => {});
const nodesBody = await page.evaluate(() => {
const app = document.getElementById('app') || document.body;
return app.textContent || '';
});
// Sort-arrow ▲▼ → caret-up/caret-down; assert no triangle/down-triangle.
if (/[▲▼]/.test(nodesBody)) fail('(c) /nodes: rendered DOM still has ▲ or ▼ chars');
else pass('(c) /nodes: no ▲ or ▼ in rendered DOM');
await spriteRefsResolve(page, '(c) /nodes sprite');
// (d) /live — audio label + feed buttons. Live page has many controls; assert
// sprite refs > 0 and audio toggle present.
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!document.querySelector('#liveAudioToggle, #feedHideBtn'),
null, { timeout: 8000 }).catch(() => {});
const live = await page.evaluate(() => {
function has(sel) {
const el = document.querySelector(sel);
if (!el) return { sel, found: false };
// Audio toggle is an <input> whose <label> wraps it; walk up.
const host = el.tagName === 'INPUT' ? el.closest('label') : el;
return {
sel, found: true,
ph: !!(host && host.querySelector('svg.ph-icon, .ph-icon')),
text: host ? host.textContent : '',
};
}
return {
audio: has('#liveAudioToggle'),
hide: has('#feedHideBtn'),
};
});
for (const [name, r] of Object.entries(live)) {
if (!r.found) { fail(`(d) /live: ${name} not found`); continue; }
if (!r.ph) fail(`(d) /live: ${name} has no .ph-icon`);
else pass(`(d) /live: ${name} has .ph-icon`);
if (EMOJI_RE.test(r.text)) fail(`(d) /live: ${name} text still has emoji`);
}
// (e) /map — Map Controls h3 + toggle
await checkHeading(page, '/map', /Map Controls/, '(e) /map');
const mapToggle = await page.evaluate(() => {
const el = document.querySelector('#mapControlsToggle');
if (!el) return { found: false };
return { found: true, ph: !!el.querySelector('svg.ph-icon, .ph-icon'), text: el.textContent || '' };
});
if (!mapToggle.found) fail('(e) /map: #mapControlsToggle not found');
else {
if (!mapToggle.ph) fail('(e) /map: #mapControlsToggle missing .ph-icon');
else pass('(e) /map: #mapControlsToggle has .ph-icon');
if (EMOJI_RE.test(mapToggle.text)) fail('(e) /map: #mapControlsToggle text still has emoji');
}
// (f) /traces
await checkHeading(page, '/traces', /Packet Trace/, '(f) /traces');
// (g) /perf
await checkHeading(page, '/perf', /Performance Dashboard/, '(g) /perf');
// (h) /audio-lab — Loop button has .ph-icon (visible immediately on init, no
// packet selection required). Section headings (Packet Data, Sound Mapping,
// etc.) only render after a packet is selected — gated by data availability.
await page.goto(`${BASE}/#/audio-lab`, { waitUntil: 'domcontentloaded' });
try {
await page.waitForSelector('#alabLoop', { timeout: 8000 });
const al = await page.evaluate(() => {
const el = document.querySelector('#alabLoop');
return { found: !!el, ph: !!(el && el.querySelector('svg.ph-icon, .ph-icon')), text: el ? el.textContent : '' };
});
if (!al.found) fail('(h) /audio-lab: #alabLoop not found');
else if (!al.ph) fail('(h) /audio-lab: #alabLoop has no .ph-icon');
else pass('(h) /audio-lab: #alabLoop has .ph-icon');
if (EMOJI_RE.test(al.text)) fail('(h) /audio-lab: #alabLoop text still has emoji');
} catch {
fail('(h) /audio-lab: #alabLoop did not render');
}
// (i) No .notdef glyph anywhere. We approximate this by checking that every
// <use href> resolves to a defined symbol id in the loaded sprite document.
const undef = await page.evaluate(async () => {
// Fetch sprite once.
const resp = await fetch('/icons/phosphor-sprite.svg').catch(() => null);
if (!resp || !resp.ok) return { error: 'sprite fetch failed' };
const text = await resp.text();
const ids = new Set();
for (const m of text.matchAll(/id="(ph-[a-z-]+)"/g)) ids.add(m[1]);
const uses = Array.from(document.querySelectorAll('svg.ph-icon use'));
const missing = [];
for (const u of uses) {
const href = u.getAttribute('href') || u.getAttribute('xlink:href') || '';
const m = href.match(/#(ph-[a-z-]+)/);
if (!m) { missing.push(href); continue; }
if (!ids.has(m[1])) missing.push(m[1]);
}
return { count: uses.length, ids: ids.size, missing };
});
if (undef.error) fail(`(i) sprite fetch: ${undef.error}`);
else if (undef.missing && undef.missing.length) fail(`(i) ${undef.missing.length} sprite ref(s) not resolved: ${undef.missing.slice(0,5).join(', ')}`);
else pass(`(i) all ${undef.count} sprite refs resolve to one of ${undef.ids} defined symbols`);
// (j) /nodes — favorite-toggle button: aria-label + ≥44×44 hit area + ph-star sprite (not ☆ text)
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!document.querySelector('.fav-star'),
null, { timeout: 8000 }).catch(() => {});
const favProbe = await page.evaluate(() => {
const btn = document.querySelector('.fav-star');
if (!btn) return { found: false };
const r = btn.getBoundingClientRect();
return {
found: true,
aria: btn.getAttribute('aria-label') || '',
w: Math.round(r.width), h: Math.round(r.height),
hasPh: !!btn.querySelector('svg.ph-icon use'),
hasStarText: /[★☆]/.test(btn.textContent || ''),
ariaPressed: btn.getAttribute('aria-pressed'),
};
});
if (!favProbe.found) fail('(j) /nodes: no .fav-star rendered (need fixture data)');
else {
if (favProbe.aria !== 'Toggle favorite') fail(`(j) /nodes fav-star: aria-label missing/wrong (got "${favProbe.aria}")`);
else pass('(j) /nodes fav-star has aria-label="Toggle favorite"');
if (favProbe.w < 44 || favProbe.h < 44) fail(`(j) /nodes fav-star bbox ${favProbe.w}x${favProbe.h} < 44x44 (WCAG 2.5.5)`);
else pass(`(j) /nodes fav-star bbox ${favProbe.w}x${favProbe.h} ≥ 44x44`);
if (!favProbe.hasPh) fail('(j) /nodes fav-star missing ph-icon sprite');
else pass('(j) /nodes fav-star uses ph-icon sprite');
if (favProbe.hasStarText) fail('(j) /nodes fav-star still contains ★ or ☆ text');
}
// (k) /nodes — No-Clock skew badge uses ph-prohibit (not 🚫 emoji)
// Probe by injecting via window.renderSkewBadge directly (no fixture dep).
const noClock = await page.evaluate(() => {
if (typeof window.renderSkewBadge !== 'function') return { fnFound: false };
const html = window.renderSkewBadge('no_clock', 0, null);
return {
fnFound: true,
html: html,
usesProhibit: /#ph-prohibit/.test(html),
hasProhibitEmoji: /🚫/.test(html),
};
});
if (!noClock.fnFound) fail('(k) /nodes: window.renderSkewBadge not found');
else {
if (!noClock.usesProhibit) fail(`(k) No-Clock badge missing ph-prohibit (html=${noClock.html.slice(0,200)})`);
else pass('(k) No-Clock badge uses #ph-prohibit sprite');
if (noClock.hasProhibitEmoji) fail('(k) No-Clock badge still contains 🚫 emoji');
}
// (l) /packets — "Saved" filter chip uses separate ph-star-fill + ph-caret-down (no ★ ▾ text)
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#filterSavedTrigger', { timeout: 8000 }).catch(() => {});
const saved = await page.evaluate(() => {
const el = document.querySelector('#filterSavedTrigger');
if (!el) return { found: false };
const uses = Array.from(el.querySelectorAll('svg.ph-icon use'))
.map(u => (u.getAttribute('href') || '').replace(/^.*#/, ''));
return {
found: true,
uses,
text: el.textContent || '',
hasStarText: /[★☆]/.test(el.textContent || ''),
hasCaretText: /[▾▴]/.test(el.textContent || ''),
};
});
if (!saved.found) fail('(l) /packets: #filterSavedTrigger not rendered');
else {
if (!saved.uses.includes('ph-star-fill')) fail(`(l) Saved chip missing ph-star-fill (uses=${saved.uses.join(',')})`);
else pass('(l) Saved chip has ph-star-fill');
if (!saved.uses.includes('ph-caret-down')) fail(`(l) Saved chip missing ph-caret-down (uses=${saved.uses.join(',')})`);
else pass('(l) Saved chip has ph-caret-down');
if (saved.hasStarText) fail('(l) Saved chip still has ★/☆ unicode text');
if (saved.hasCaretText) fail('(l) Saved chip still has ▾/▴ unicode text');
}
await browser.close();
console.log(`\ntest-issue-1648-m2-icons-e2e.js: ${passes} passed, ${failures} failed`);
assert.strictEqual(failures, 0, `${failures} M2 icon-render assertions failed`);
process.exit(0);
}
main().catch((err) => {
console.error('test-issue-1648-m2-icons-e2e.js: FAIL —', err);
process.exit(1);
});