M3: emoji → Phosphor Icons — detail panes & badges (#1648) (#1651)

Red commit: 537fbbc6b0 (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2F1648-m3-details-badges)

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

M3 covers detail panes, status pills, role/payload-type badges per the
tracking-issue M3 checklist. Builds on M1 sprite + M2 chrome.

## Per-file swap counts

| file | swaps (sprite refs) |
| --- | --- |
| public/home.js | 26 |
| public/channels.js | 27 |
| public/route-view-utils.js | 11 |
| public/route-view.js | 4 |
| public/app.js | 4 |
| public/hop-display.js | 3 |
| public/path-inspector.js | 1 |
| **total** | **76** |

Plus 6 new Phosphor SVG symbols vendored into
`public/icons/phosphor-sprite.svg` (regular weight, alphabetical):
`ph-bluetooth`, `ph-camera`, `ph-hexagon`, `ph-paper-plane-tilt`,
`ph-plant`, `ph-rocket`.

## Status-token integration (design decision 3)

`.status-{ok,warn,err,muted}` rules added in `public/style.css` (lines
22-35). Each threads a `--status-*` color via `color:` and Phosphor
sprites inside inherit via `currentColor` — no fill colors baked into
sprite refs. Verified by an emoji-scan assertion
(`test-issue-1648-m3-emoji-scan.js` `assertStatusTokenCss()`) and an E2E
computed-style probe.

## TDD evidence

- Red commit `537fbbc6` adds the failing scan
(`test-issue-1648-m3-emoji-scan.js`) alone — see branch CI history.
- Green commit `4a0cd89a` implements the swaps.
- Anti-tautology: reverting one sprite swap in `path-inspector.js`
reproduces the assertion failure; restored.

## E2E assertions added

`test-issue-1648-m3-icons-e2e.js:1-188` — Playwright behavioral checks
for `/home` (welcome cards), `/channels` (sidebar + modal),
`/nodes/<pk>` (detail pane), `/analytics`, `/live`, plus `.notdef`
resolver and `.status-ok` computed-color probe. Registered in
`.github/workflows/deploy.yml`.

## Test updates (drift)

`test-frontend-helpers.js` updated 6 assertions to match sprite-rendered
HTML: hop-display unreliable-badge (`#ph-warning`), `#1504`
`PATH_SYMBOLS_LEGEND` (`glyph` → `glyphHtml`), `#781`/`#811`
channel-lock affordance (`#ph-lock`). Pre-existing 2 `favStar` failures
from M2 baseline remain unchanged (out-of-scope here).

## Out of scope (next milestones)

- `public/customize.js` / `public/customize-v2.js` operator-customizable
emoji config → **M5**
- `cmd/server/routes.go` server-rendered onboarding config → **M6**
- `public/route-view-v2.js` route-overlay glyph logic → **M4** (this PR
touches only `route-view-utils.js` payload taxonomy + `route-view.js`
sidebar, not the overlay)
This commit is contained in:
Kpa-clawbot
2026-06-11 02:06:32 -07:00
committed by GitHub
parent 3062745437
commit b812a98a71
15 changed files with 514 additions and 106 deletions
+1
View File
@@ -400,6 +400,7 @@ jobs:
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
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1648-m3-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
+10 -9
View File
@@ -737,11 +737,11 @@ function _showPullToast(msg, ok) {
}
function pullReconnect() {
// If WS is connected (readyState OPEN), give a brief "Connected"
// If WS is connected (readyState OPEN), give a brief "Connected"
// confirmation but still cycle so the user sees fresh data.
const wasOpen = ws && ws.readyState === 1;
if (wasOpen) {
_showPullToast('Connected', true);
_showPullToast('Connected', true);
// Fast cycle: close and let onclose reconnect immediately
try { ws.close(); } catch (e) {}
} else {
@@ -903,8 +903,8 @@ registerPage('tools-landing', {
'<div class="tools-landing">' +
'<h2>Tools</h2>' +
'<div class="tools-menu">' +
'<a href="#/tools/path-inspector" class="tools-card"><h3>🔍 Path Inspector</h3><p>Resolve prefix paths to candidate full-pubkey routes with confidence scoring.</p></a>' +
'<a href="#/tools/trace/" class="tools-card"><h3>📡 Trace Viewer</h3><p>View detailed packet traces by hash.</p></a>' +
'<a href="#/tools/path-inspector" class="tools-card"><h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Path Inspector</h3><p>Resolve prefix paths to candidate full-pubkey routes with confidence scoring.</p></a>' +
'<a href="#/tools/trace/" class="tools-card"><h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Trace Viewer</h3><p>View detailed packet traces by hash.</p></a>' +
'</div>' +
'</div>';
},
@@ -1375,7 +1375,7 @@ window.addEventListener('DOMContentLoaded', () => {
}
// #1139 Bug B: floor the More menu at >=2 items. The greedy
// fits() loop above is happy to stop after pushing exactly ONE
// link into overflow (commonly "🎵 Lab" at ~1600px viewports),
// link into overflow (commonly "🎵 Lab" at ~1600px viewports), // EMOJI-OK: comment referencing prior nav label
// producing a degenerate single-item dropdown. If exactly one
// link overflowed, promote one more from the queue so the user
// sees a useful menu instead of a one-item fragment. Skip when
@@ -1493,7 +1493,7 @@ window.addEventListener('DOMContentLoaded', () => {
async function renderFavDropdown() {
const favs = getFavorites();
if (!favs.length) {
favDropdown.innerHTML = '<div class="fav-dd-empty">No favorites yet.<br><small>Click on any node to add it.</small></div>';
favDropdown.innerHTML = '<div class="fav-dd-empty">No favorites yet.<br><small>Click the star on any node to add it.</small></div>';
return;
}
favDropdown.innerHTML = '<div class="fav-dd-loading">Loading...</div>';
@@ -1501,16 +1501,17 @@ window.addEventListener('DOMContentLoaded', () => {
try {
const h = await api('/nodes/' + pk + '/health', { ttl: CLIENT_TTL.nodeHealth });
const age = h.stats.lastHeard ? Date.now() - new Date(h.stats.lastHeard).getTime() : null;
const status = age === null ? '🔴' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? '🟢' : age < HEALTH_THRESHOLDS.nodeSilentMs ? '🟡' : '🔴';
const statusCls = age === null ? 'status-err' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? 'status-ok' : age < HEALTH_THRESHOLDS.nodeSilentMs ? 'status-warn' : 'status-err';
const statusLabel = age === null ? 'unknown' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? 'healthy' : age < HEALTH_THRESHOLDS.nodeSilentMs ? 'degraded' : 'silent';
return '<a href="#/nodes/' + pk + '" class="fav-dd-item" data-key="' + pk + '">'
+ '<span class="fav-dd-status">' + status + '</span>'
+ '<span class="fav-dd-status ' + statusCls + '" title="' + statusLabel + '" aria-label="' + statusLabel + '"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span>'
+ '<span class="fav-dd-name">' + (h.node.name || truncate(pk, 12)) + '</span>'
+ '<span class="fav-dd-meta">' + (h.stats.lastHeard ? timeAgo(h.stats.lastHeard) : 'never') + '</span>'
+ favStar(pk, 'fav-dd-star')
+ '</a>';
} catch {
return '<a href="#/nodes/' + pk + '" class="fav-dd-item" data-key="' + pk + '">'
+ '<span class="fav-dd-status">❓</span>'
+ '<span class="fav-dd-status status-muted" title="not found" aria-label="not found"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-question"/></svg></span>'
+ '<span class="fav-dd-name">' + truncate(pk, 16) + '</span>'
+ '<span class="fav-dd-meta">not found</span>'
+ favStar(pk, 'fav-dd-star')
+31 -29
View File
@@ -174,7 +174,7 @@
tip.className = 'ch-node-tooltip';
tip.setAttribute('role', 'tooltip');
const roleKey = node.role || (node.is_repeater ? 'repeater' : node.is_room ? 'room' : node.is_sensor ? 'sensor' : 'companion');
const role = (ROLE_EMOJI[roleKey] || '') + ' ' + (ROLE_LABELS[roleKey] || roleKey);
const role = (ROLE_EMOJI[roleKey] || '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg>') + ' ' + (ROLE_LABELS[roleKey] || roleKey);
const lastActivity = node.last_heard || node.last_seen;
const lastSeen = lastActivity ? timeAgo(lastActivity) : 'unknown';
tip.innerHTML = `<div class="ch-tooltip-name">${escapeHtml(node.name)}</div>
@@ -237,7 +237,7 @@
if (!node) {
panel.innerHTML = `<div class="ch-node-panel-header">
<strong>${escapeHtml(name)}</strong>
<button class="ch-node-close" data-action="ch-close-node" aria-label="Close"></button>
<button class="ch-node-close" data-action="ch-close-node" aria-label="Close"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
</div>
<div class="ch-node-panel-body">
<div class="ch-node-field" style="color:var(--text-muted)">No node record found — this sender has only been seen in channel messages, not via adverts.</div>
@@ -252,13 +252,13 @@
const n = detail.node;
const adverts = detail.recentAdverts || [];
const roleKey = n.role || (n.is_repeater ? 'repeater' : n.is_room ? 'room' : n.is_sensor ? 'sensor' : 'companion');
const role = (ROLE_EMOJI[roleKey] || '') + ' ' + (ROLE_LABELS[roleKey] || roleKey);
const role = (ROLE_EMOJI[roleKey] || '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg>') + ' ' + (ROLE_LABELS[roleKey] || roleKey);
const lastActivity = n.last_heard || n.last_seen;
const lastSeen = lastActivity ? timeAgo(lastActivity) : 'unknown';
panel.innerHTML = `<div class="ch-node-panel-header">
<strong>${escapeHtml(n.name || 'Unknown')}</strong>
<button class="ch-node-close" data-action="ch-close-node" aria-label="Close"></button>
<button class="ch-node-close" data-action="ch-close-node" aria-label="Close"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
</div>
<div class="ch-node-panel-body">
<div class="ch-node-field"><span class="ch-node-label">Role</span> ${role}</div>
@@ -274,7 +274,7 @@
_focusTrapCleanup = trapFocus(panel);
panel.querySelector('.ch-node-close')?.focus();
} catch (e) {
panel.innerHTML = `<div class="ch-node-panel-header"><strong>${escapeHtml(name)}</strong><button class="ch-node-close" data-action="ch-close-node"></button></div><div class="ch-node-panel-body ch-empty">Failed to load</div>`;
panel.innerHTML = `<div class="ch-node-panel-header"><strong>${escapeHtml(name)}</strong><button class="ch-node-close" data-action="ch-close-node"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button></div><div class="ch-node-panel-body ch-empty">Failed to load</div>`;
_focusTrapCleanup = trapFocus(panel);
panel.querySelector('.ch-node-close')?.focus();
}
@@ -495,7 +495,7 @@
// Merge user-stored keys into the channel list.
// If a stored key matches a server-known channel, mark that channel as
// userAdded so the button appears — otherwise the user has no way to
// userAdded so the close button appears // EMOJI-OK: prior glyph reference — otherwise the user has no way to
// remove a key they added but that the server already knows about.
function mergeUserChannels() {
var keys = ChannelDecrypt.getStoredKeys();
@@ -721,7 +721,7 @@
app.innerHTML = `<div class="ch-layout">
<div class="ch-sidebar" aria-label="Channel list">
<div class="ch-sidebar-header">
<div class="ch-sidebar-title"><span class="ch-icon">💬</span> Channels</div>
<div class="ch-sidebar-title"><span class="ch-icon"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chats"/></svg></span> Channels</div>
<div id="chRegionFilter" class="region-filter-container ch-header-region"></div>
<button type="button" id="chAddChannelBtn" class="ch-add-channel-btn"
aria-label="Add channel" title="Add a channel — generate, paste a key, or monitor a hashtag">+ Add</button>
@@ -735,10 +735,10 @@
<!-- #1034 PR1: Add Channel modal -->
<div id="chAddChannelModal" class="modal-overlay ch-modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="chModalTitle" hidden>
<div class="modal ch-modal" role="document">
<button type="button" class="modal-close ch-modal-close" id="chModalClose" data-action="ch-modal-close" aria-label="Close"></button>
<button type="button" class="modal-close ch-modal-close" id="chModalClose" data-action="ch-modal-close" aria-label="Close"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
<h3 id="chModalTitle">Add Channel</h3>
<div class="ch-modal-callout" role="note">
⚠️ Channels are saved to <strong>THIS browser only</strong>. They won't appear on other devices or browsers, and clearing browser data will remove them.
<span class="status-warn"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></span> Channels are saved to <strong>THIS browser only</strong>. They won't appear on other devices or browsers, and clearing browser data will remove them.
</div>
<section class="ch-modal-section" aria-labelledby="chSecGenTitle">
@@ -760,7 +760,7 @@
pattern="[0-9a-fA-F]{32}"
maxlength="32"
aria-label="32-character hex PSK key" spellcheck="false" autocomplete="off">
<button type="button" id="scan-qr-btn" class="ch-modal-btn-secondary" title="Scan a meshcore:// channel QR with your camera">📷 Scan QR</button>
<button type="button" id="scan-qr-btn" class="ch-modal-btn-secondary" title="Scan a meshcore:// channel QR with your camera"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-camera"/></svg> Scan QR</button>
</div>
<div class="ch-modal-row">
<input type="text" id="chPskName" class="ch-modal-input" placeholder="Display name (optional)" aria-label="Optional display name" spellcheck="false">
@@ -779,11 +779,11 @@
aria-label="Hashtag channel name (without #)" spellcheck="false" autocomplete="off">
<button type="button" id="chHashtagBtn" class="btn-primary">Monitor</button>
</div>
<div class="ch-modal-warn"> Case-sensitive — <code>#meshcore</code> ≠ <code>#MeshCore</code></div>
<div class="ch-modal-warn"><span class="status-warn"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></span> Case-sensitive — <code>#meshcore</code> ≠ <code>#MeshCore</code></div>
</section>
<div class="ch-modal-footer">
🔒 Keys stay in your browser — CoreScope is a passive observer that monitors and decrypts traffic but cannot transmit over RF. Use to remove individual channels.
<span class=""><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg></span> Keys stay in your browser — CoreScope is a passive observer that monitors and decrypts traffic but cannot transmit over RF. Use the close button to remove individual channels.
</div>
</div>
</div>
@@ -795,7 +795,7 @@
QR section bleed into the Add submit flow. -->
<div id="chShareModal" class="modal-overlay ch-modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="chShareModalTitle" hidden>
<div class="modal ch-modal ch-share-modal" role="document">
<button type="button" class="modal-close ch-modal-close" id="chShareModalClose" data-action="ch-share-modal-close" aria-label="Close"></button>
<button type="button" class="modal-close ch-modal-close" id="chShareModalClose" data-action="ch-share-modal-close" aria-label="Close"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
<h3 id="chShareModalTitle" class="ch-share-modal-title">Share Channel</h3>
<div class="ch-share-modal-body">
<div id="chShareQr" class="ch-share-qr" aria-live="polite"></div>
@@ -803,11 +803,11 @@
<label class="ch-share-label" for="chShareKey">Hex Key</label>
<div class="ch-share-row">
<input type="text" id="chShareKey" data-share-field="key" class="ch-modal-input ch-modal-input--mono" readonly aria-label="Channel hex key">
<button type="button" class="ch-modal-btn-secondary" data-share-copy="key" aria-label="Copy hex key">📋 Copy</button>
<button type="button" class="ch-modal-btn-secondary" data-share-copy="key" aria-label="Copy hex key"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-clipboard-text"/></svg> Copy</button>
</div>
</div>
<div class="ch-modal-warn" role="note">
Privacy: only share with trusted people. Anyone with this key can read all messages on this channel.
<span class="status-warn"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></span> Privacy: only share with trusted people. Anyone with this key can read all messages on this channel.
</div>
</div>
</div>
@@ -997,7 +997,7 @@
try { src.select(); } catch (e2) {}
var doneCopy = function () {
var orig = copyBtn.textContent;
copyBtn.textContent = ' Copied';
copyBtn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check"/></svg> Copied';
setTimeout(function () { copyBtn.textContent = orig; }, 1200);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
@@ -1254,7 +1254,7 @@
}
} else if (ch) {
// Server-known channel: keep the row, just unmark as user-added so
// the disappears until they re-add a key.
// the close button disappears until they re-add a key. // EMOJI-OK: prior glyph reference
ch.userAdded = false;
// If this was the selected channel, clear decrypted messages since
// the key is gone — they can't be re-decrypted without re-adding it.
@@ -1707,7 +1707,7 @@
const encClass = isUserAdded
? ' ch-user-added'
: (isEncrypted ? ' ch-encrypted' : '');
const badgeIcon = isUserAdded ? '🔓' : (isEncrypted ? '🔒' : null);
const badgeIcon = isUserAdded ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock-open"/></svg>' : (isEncrypted ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg>' : null);
const abbr = badgeIcon || (name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase());
const chColor = window.ChannelColors ? window.ChannelColors.get(ch.hash) : null;
const dotStyle = chColor ? ` style="background:${chColor}"` : '';
@@ -1727,14 +1727,14 @@
+ ' aria-label="' + ariaVerb + ' ' + escapeHtml(name) + '">' + glyph + '</span>';
}
const removeBtn = isUserAdded
? iconBtn('ch-remove-btn', 'data-remove-channel', ch.hash, name, '',
? iconBtn('ch-remove-btn', 'data-remove-channel', ch.hash, name, '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg>',
'Remove channel and clear saved key', 'Remove', '')
: '';
const shareBtn = isUserAdded
? iconBtn('ch-share-btn', 'data-share-channel', ch.hash, name, '📤 Share',
? iconBtn('ch-share-btn', 'data-share-channel', ch.hash, name, '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-share-network"/></svg> Share',
'Share channel key (QR + URL)', 'Share', ' aria-haspopup="dialog"')
: '';
const userBadge = isUserAdded ? ' <span class="ch-user-badge" title="You added this key" aria-label="Your key">🔑</span>' : '';
const userBadge = isUserAdded ? ' <span class="ch-user-badge" title="You added this key" aria-label="Your key"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-key"/></svg></span>' : '';
const unreadBadge = (ch.unread && ch.unread > 0)
? ' <span class="ch-unread-badge" data-unread-channel="' + escapeHtml(ch.hash) + '" title="' + ch.unread + ' new" aria-label="' + ch.unread + ' unread">' + (ch.unread > 99 ? '99+' : ch.unread) + '</span>'
: '';
@@ -1744,7 +1744,7 @@
<div class="ch-item-body">
<div class="ch-item-top">
<span class="ch-item-name">${escapeHtml(name)}</span>${userBadge}${unreadBadge}
<span class="ch-color-dot" data-channel="${escapeHtml(ch.hash)}"${dotStyle} title="Change channel color" aria-label="Change color for ${escapeHtml(name)}"></span>${chColor ? '<span class="ch-color-clear" data-channel="' + escapeHtml(ch.hash) + '" title="Clear color" aria-label="Clear color for ' + escapeHtml(name) + '"></span>' : ''}
<span class="ch-color-dot" data-channel="${escapeHtml(ch.hash)}"${dotStyle} title="Change channel color" aria-label="Change color for ${escapeHtml(name)}"></span>${chColor ? '<span class="ch-color-clear" data-channel="' + escapeHtml(ch.hash) + '" title="Clear color" aria-label="Clear color for ' + escapeHtml(name) + '"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></span>' : ''}
<span class="ch-item-time" data-channel-hash="${ch.hash}">${time}</span>${shareBtn}${removeBtn}
</div>
<div class="ch-item-preview">${escapeHtml(preview)}</div>
@@ -1762,8 +1762,8 @@
function avatarTextForChannel(ch) {
const name = ch && ch.name ? String(ch.name) : '';
if (name.charAt(0) === '#') return name.slice(0, 3); // "#wa"
if (ch && ch.encrypted && !ch.userAdded) return '🔒';
if (ch && ch.userAdded) return '🔑';
if (ch && ch.encrypted && !ch.userAdded) return '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg>';
if (ch && ch.userAdded) return '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-key"/></svg>';
// Fallback: 2-char uppercase abbreviation.
return name.replace(/[^A-Za-z0-9]/g, '').slice(0, 2).toUpperCase() ||
String(ch && ch.hash || '?').slice(0, 2).toUpperCase();
@@ -1789,12 +1789,14 @@
preview = ch.messageCount + ' messages';
}
const abbr = avatarTextForChannel(ch);
// abbr may be a Phosphor sprite (HTML) or plain text — detect & emit raw vs escaped.
const isHtmlAbbr = typeof abbr === 'string' && abbr.startsWith('<svg');
const sel = selectedHash === ch.hash ? ' selected' : '';
return '<button type="button" class="ch-row' + sel + '" data-hash="' + escapeHtml(ch.hash) +
'" role="option" aria-selected="' + (selectedHash === ch.hash ? 'true' : 'false') +
'" aria-label="' + escapeHtml(name) + '">' +
'<div class="ch-avatar ch-row-avatar" style="background:' + color +
'" aria-hidden="true">' + escapeHtml(abbr) + '</div>' +
'" aria-hidden="true">' + (isHtmlAbbr ? abbr : escapeHtml(abbr)) + '</div>' +
'<div class="ch-row-body">' +
'<div class="ch-row-line1">' +
'<span class="ch-row-name">' + escapeHtml(name) + '</span>' +
@@ -1833,7 +1835,7 @@
if (mine.length > 0) {
sections.push(
`<div class="ch-section ch-section-mychannels" data-section="mychannels">
<div class="ch-section-header">My Channels <span class="ch-section-locality" title="Saved only in this browser on this device">🖥️ (this browser)</span></div>
<div class="ch-section-header">My Channels <span class="ch-section-locality" title="Saved only in this browser on this device"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-monitor"/></svg> (this browser)</span></div>
${mine.map(renderChannelRow).join('')}
</div>`
);
@@ -1914,7 +1916,7 @@
});
if (isStaleMessageRequest(request)) return { stale: true };
if (result.wrongKey) {
msgEl.innerHTML = '<div class="ch-empty ch-wrong-key">🔒 Key does not match — no messages could be decrypted</div>';
msgEl.innerHTML = '<div class="ch-empty ch-wrong-key"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> Key does not match — no messages could be decrypted</div>';
return { wrongKey: true, messageCount: 0 };
}
if (result.error) {
@@ -1963,7 +1965,7 @@
}
}
// #781: No matching key found — show lock message instead of fetching gibberish
msgEl.innerHTML = '<div class="ch-empty">🔒 This channel is encrypted and no decryption key is configured</div>';
msgEl.innerHTML = '<div class="ch-empty"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> This channel is encrypted and no decryption key is configured</div>';
return;
}
@@ -1992,7 +1994,7 @@
if (isStaleMessageRequest(request)) return;
var foundCh = (allCh.channels || []).find(function (c) { return c.hash === hash; });
if (foundCh && foundCh.encrypted === true) {
msgEl.innerHTML = '<div class="ch-empty">🔒 This channel is encrypted and no decryption key is configured</div>';
msgEl.innerHTML = '<div class="ch-empty"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> This channel is encrypted and no decryption key is configured</div>';
return;
}
// Unencrypted (or unknown) — fall through to the REST fetch below.
+25 -24
View File
@@ -49,12 +49,12 @@
<p>How familiar are you with MeshCore?</p>
<div class="chooser-options">
<button class="chooser-btn new" id="chooseNew">
<span class="chooser-icon">🌱</span>
<span class="chooser-icon" aria-hidden="true"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-plant"/></svg></span>
<strong>I\u2019m new</strong>
<span>Show me setup guides and tips</span>
</button>
<button class="chooser-btn exp" id="chooseExp">
<span class="chooser-icon"></span>
<span class="chooser-icon" aria-hidden="true"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lightning"/></svg></span>
<strong>I know what I\u2019m doing</strong>
<span>Just the analyzer, skip the guides</span>
</button>
@@ -98,7 +98,7 @@
${!hasNodes ? `
<div class="onboarding-prompt">
<div class="onboard-icon">📡</div>
<div class="onboard-icon" aria-hidden="true"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg></div>
<h2>Claim your first node</h2>
<p>Search for your node above, or paste your public key. Once claimed, you'll see live status, signal quality, and who's hearing you.</p>
</div>
@@ -113,18 +113,18 @@
${exp ? '' : `
<section class="home-checklist">
<h2>🚀 Getting on the mesh${homeCfg?.steps ? '' : ' — SF Bay Area'}</h2>
<h2><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-rocket"/></svg> Getting on the mesh${homeCfg?.steps ? '' : ' — SF Bay Area'}</h2>
${checklist(homeCfg)}
</section>`}
<section class="home-footer">
<div class="home-footer-links">
${homeCfg?.footerLinks ? homeCfg.footerLinks.map(l => `<a href="${escapeAttr(l.url)}" class="home-footer-link" target="_blank" rel="noopener">${escapeHtml(l.label)}</a>`).join('') : `
<a href="#/packets" class="home-footer-link">📦 Packets</a>
<a href="#/map" class="home-footer-link">🗺️ Network Map</a>
<a href="#/live" class="home-footer-link">🔴 Live</a>
<a href="#/nodes" class="home-footer-link">📡 All Nodes</a>
<a href="#/channels" class="home-footer-link">💬 Channels</a>`}
<a href="#/packets" class="home-footer-link"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-package"/></svg> Packets</a>
<a href="#/map" class="home-footer-link"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-trifold"/></svg> Network Map</a>
<a href="#/live" class="home-footer-link"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Live</a>
<a href="#/nodes" class="home-footer-link"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> All Nodes</a>
<a href="#/channels" class="home-footer-link"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chats"/></svg> Channels</a>`}
</div>
<div class="home-level-toggle">
<small>${exp ? 'Want setup guides? ' : 'Already know MeshCore? '}
@@ -183,7 +183,7 @@
<div class="suggest-actions">
<span class="suggest-role badge-${n.role || 'unknown'}">${n.role || '?'}</span>
<button class="suggest-claim ${claimed ? 'claimed' : ''}" data-key="${n.public_key}" data-name="${escapeAttr(n.name || '')}" title="${claimed ? 'Remove from My Mesh' : 'Add to My Mesh'}">
${claimed ? ' Mine' : '+ Claim'}
${claimed ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check"/></svg> Mine' : '+ Claim'}
</button>
</div>
</div>`;
@@ -202,11 +202,11 @@
if (isMyNode(pk)) {
removeMyNode(pk);
btn.classList.remove('claimed');
btn.textContent = '+ Claim';
btn.innerHTML = '+ Claim';
} else {
addMyNode(pk, nm);
btn.classList.add('claimed');
btn.textContent = ' Mine';
btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check"/></svg> Mine';
}
loadMyNodes();
});
@@ -277,7 +277,8 @@
const age = stats.lastHeard ? Date.now() - new Date(stats.lastHeard).getTime() : null;
const status = age === null ? 'silent' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? 'healthy' : age < HEALTH_THRESHOLDS.nodeSilentMs ? 'degraded' : 'silent';
const statusDot = status === 'healthy' ? '🟢' : status === 'degraded' ? '🟡' : '🔴';
const statusCls = status === 'healthy' ? 'status-ok' : status === 'degraded' ? 'status-warn' : 'status-err';
const statusDot = '<span class="' + statusCls + '" aria-label="' + status + '"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span>';
const statusText = status === 'healthy' ? 'Active' : status === 'degraded' ? 'Degraded' : 'Silent';
const name = node.name || mn.name || truncate(mn.pubkey, 12);
@@ -294,7 +295,7 @@
<div class="mnc-status">${statusDot}</div>
<div class="mnc-name">${escapeHtml(name)}</div>
<div class="mnc-role">${node.role || '?'}</div>
<button class="mnc-remove" data-key="${mn.pubkey}" title="Remove from My Mesh" aria-label="Remove ${escapeAttr(name)} from My Mesh"></button>
<button class="mnc-remove" data-key="${mn.pubkey}" title="Remove from My Mesh" aria-label="Remove ${escapeAttr(name)} from My Mesh"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
</div>
<div class="mnc-status-text">${statusText}${stats.lastHeard ? ' · ' + timeAgo(stats.lastHeard) : ''}</div>
<div class="mnc-metrics">
@@ -324,7 +325,7 @@
</div>`;
} catch (err) {
const is404 = err && err.message && err.message.includes('404');
const statusIcon = is404 ? '📡' : '❓';
const statusIcon = is404 ? '<span class="status-warn"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg></span>' : '<span class="status-muted"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-question"/></svg></span>';
const statusMsg = is404
? 'Waiting for first advert — this node has been seen in channel messages but hasn\u2019t advertised yet'
: 'Could not load data';
@@ -332,7 +333,7 @@
<div class="mnc-header">
<div class="mnc-status">${statusIcon}</div>
<div class="mnc-name">${escapeHtml(mn.name || truncate(mn.pubkey, 12))}</div>
<button class="mnc-remove" data-key="${mn.pubkey}" title="Remove" aria-label="Remove ${escapeAttr(mn.name || truncate(mn.pubkey, 12))} from My Mesh"></button>
<button class="mnc-remove" data-key="${mn.pubkey}" title="Remove" aria-label="Remove ${escapeAttr(mn.name || truncate(mn.pubkey, 12))} from My Mesh"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
</div>
<div class="mnc-status-text">${statusMsg}</div>
</div>`;
@@ -441,7 +442,7 @@
card.innerHTML = `
<div class="health-banner ${color}">
<span>${status === 'healthy' ? '✅' : status === 'degraded' ? '⚠️' : '❌'}</span>
<span class="${status === 'healthy' ? 'status-ok' : status === 'degraded' ? 'status-warn' : 'status-err'}" aria-label="${status}">${status === 'healthy' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg>' : status === 'degraded' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x-circle"/></svg>'}</span>
<span><strong>${escapeHtml(node.name || truncate(pubkey, 16))}</strong> — ${statusMsg}</span>
${!claimed ? `<button class="health-claim" data-key="${pubkey}" data-name="${escapeAttr(node.name || '')}">+ Add to My Mesh</button>` : ''}
</div>
@@ -543,23 +544,23 @@
}
// Render FAQ/checklist (additional Q&A)
if (homeCfg?.checklist?.length) {
if (html) html += '<h3 style="margin:24px 0 12px;font-size:16px"> FAQ</h3>';
if (html) html += '<h3 style="margin:24px 0 12px;font-size:16px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-question"/></svg> FAQ</h3>';
html += homeCfg.checklist.map(i => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${escapeHtml(i.question)}</div><div class="checklist-a">${window.miniMarkdown ? miniMarkdown(i.answer) : escapeHtml(i.answer)}</div></div>`).join('');
}
// Fallback: Bay Area defaults when no config at all
if (!html) {
const items = [
{ q: '💬 First: Join the Bay Area MeshCore Discord',
{ q: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chats"/></svg> First: Join the Bay Area MeshCore Discord',
a: '<p>The community Discord is the best place to get help and find local mesh enthusiasts.</p><p><a href="https://discord.gg/q59JzsYTst" target="_blank" rel="noopener" style="color:var(--accent);font-weight:600">Join the Discord ↗</a></p><p>Start with <strong>#intro-to-meshcore</strong> — it has detailed setup instructions.</p>' },
{ q: '🔵 Step 1: Connect via Bluetooth',
{ q: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-bluetooth"/></svg> Step 1: Connect via Bluetooth',
a: '<p>Flash <strong>BLE companion</strong> firmware from <a href="https://flasher.meshcore.io/" target="_blank" rel="noopener" style="color:var(--accent)">MeshCore Flasher</a>.</p><ul><li>Screenless devices: default PIN <code>123456</code></li><li>Screen devices: random PIN shown on display</li><li>If pairing fails: forget device, reboot, re-pair</li></ul>' },
{ q: '📻 Step 2: Set the right frequency preset',
{ q: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-radio"/></svg> Step 2: Set the right frequency preset',
a: '<p><strong>US Recommended:</strong></p><div style="margin:8px 0;padding:8px 12px;background:var(--surface-1);border-radius:6px;font-family:var(--mono);font-size:.85rem">910.525 MHz · BW 62.5 kHz · SF 7 · CR 5</div><p>Select <strong>"US Recommended"</strong> in the app or flasher.</p>' },
{ q: '📡 Step 3: Advertise yourself',
{ q: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Step 3: Advertise yourself',
a: '<p>Tap the signal icon → <strong>Flood</strong> to broadcast your node to the mesh. Companions only advert when you trigger it manually.</p>' },
{ q: '🔁 Step 4: Check "Heard N repeats"',
{ q: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-repeat"/></svg> Step 4: Check "Heard N repeats"',
a: '<ul><li><strong>"Sent"</strong> = transmitted, no confirmation</li><li><strong>"Heard 0 repeats"</strong> = no repeater picked it up</li><li><strong>"Heard 1+ repeats"</strong> = you\'re on the mesh!</li></ul>' },
{ q: '📍 Repeaters near you?',
{ q: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-pin"/></svg> Repeaters near you?',
a: '<p><a href="#/map" style="color:var(--accent)">Check the network map</a> to see active repeaters.</p>' }
];
html = items.map(i => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${i.q}</div><div class="checklist-a">${i.a}</div></div>`).join('');
+12 -8
View File
@@ -82,10 +82,10 @@ window.HopDisplay = (function() {
const badgeCount = regionalConflicts.length > 0 ? regionalConflicts.length : (globalFallback ? conflicts.length : 0);
const conflictData = escapeHtml(JSON.stringify({ h, conflicts, globalFallback }));
const conflictBadge = badgeCount > 1
? ` <button class="hop-conflict-btn" data-conflict='${conflictData}' onclick="event.preventDefault();event.stopPropagation();HopDisplay._showFromBtn(this)" title="${badgeCount} candidates — click for details">${badgeCount}</button>`
? ` <button class="hop-conflict-btn status-warn" data-conflict='${conflictData}' onclick="event.preventDefault();event.stopPropagation();HopDisplay._showFromBtn(this)" title="${badgeCount} candidates — click for details"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>${badgeCount}</button>`
: '';
const unreliableBadge = unreliable
? ' <button class="hop-unreliable-btn" aria-label="Unreliable name resolution" title="Unreliable name resolution — this hash\u2192name match is geographically inconsistent with the surrounding path hops. The repeater itself may be fine; this specific hop assignment is uncertain.">⚠️</button>'
? ' <button class="hop-unreliable-btn status-warn" aria-label="Unreliable name resolution" title="Unreliable name resolution — this hash\u2192name match is geographically inconsistent with the surrounding path hops. The repeater itself may be fine; this specific hop assignment is uncertain."><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></button>'
: '';
const warnBadge = conflictBadge + unreliableBadge;
@@ -123,20 +123,24 @@ window.HopDisplay = (function() {
// #1504 — Path symbols legend (shared by Packets + Nodes pages).
// Tufte: integrate words and graphics — small, on-data, dismissible.
// Glyph strings here MUST match exactly what hop-display.js emits in renderHop()
// (the yellow ⚠N button + the bare ⚠ unreliable button + dashed-underline class).
// PATH_SYMBOLS_LEGEND mirrors what renderHop() emits (yellow warning+N
// button, bare warning button, dashed-underline class). Glyphs are
// pre-rendered Phosphor sprite HTML so the legend visually matches the
// actual hop chrome (#1648 M3).
const WARN_PH = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>';
const PATH_SYMBOLS_LEGEND = [
{ glyph: '⚠N',
{ glyphHtml: '<span class="status-warn">' + WARN_PH + 'N</span>',
description: 'Yellow button next to a hop — N regional candidates share this hop\u2019s prefix. Click for the candidate list.' },
{ glyph: '',
{ glyphHtml: '<span class="status-warn">' + WARN_PH + '</span>',
description: 'Warning icon alone (no number) — unreliable name resolution: the best-guess pubkey couldn\u2019t be confirmed against surrounding path hops.' },
{ glyph: 'dashed underline',
{ glyphHtml: 'dashed underline',
description: 'Ambiguous or global-fallback resolution — the name matched outside the current region.' },
];
function renderPathSymbolsLegend() {
const items = PATH_SYMBOLS_LEGEND.map(function(e) {
return '<li><span class="path-legend-glyph">' + escapeHtml(e.glyph) + '</span> — ' + escapeHtml(e.description) + '</li>';
// glyphHtml is trusted (constant strings above), description is escaped.
return '<li><span class="path-legend-glyph">' + e.glyphHtml + '</span> — ' + escapeHtml(e.description) + '</li>';
}).join('');
return '<details class="path-symbols-legend"><summary>Path symbols</summary>' +
'<ul class="path-legend-list">' + items + '</ul></details>';
+6
View File
@@ -3,12 +3,14 @@
<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-bluetooth" viewBox="0 0 256 256"><path d="M196.8,169.6,141.33,128,196.8,86.4a8,8,0,0,0,0-12.8l-64-48A8,8,0,0,0,120,32v80L68.8,73.6a8,8,0,0,0-9.6,12.8L114.67,128,59.2,169.6a8,8,0,1,0,9.6,12.8L120,144v80a8,8,0,0,0,12.8,6.4l64-48a8,8,0,0,0,0-12.8ZM136,48l42.67,32L136,112Zm0,160V144l42.67,32Z"/></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-camera" viewBox="0 0 256 256"><path d="M208,56H180.28L166.65,35.56A8,8,0,0,0,160,32H96a8,8,0,0,0-6.65,3.56L75.71,56H48A24,24,0,0,0,24,80V192a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V80A24,24,0,0,0,208,56Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V80a8,8,0,0,1,8-8H80a8,8,0,0,0,6.66-3.56L100.28,48h55.43l13.63,20.44A8,8,0,0,0,176,72h32a8,8,0,0,1,8,8ZM128,88a44,44,0,1,0,44,44A44.05,44.05,0,0,0,128,88Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,128,160Z"/></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>
@@ -33,6 +35,7 @@
<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-hexagon" viewBox="0 0 256 256"><path d="M223.68,66.15,135.68,18h0a15.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.15ZM216,175.82,128,224,40,175.82V80.18L128,32h0l88,48.17Z"/></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>
@@ -53,14 +56,17 @@
<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-paper-plane-tilt" viewBox="0 0 256 256"><path d="M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z"/></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-plant" viewBox="0 0 256 256"><path d="M247.63,47.89a8,8,0,0,0-7.52-7.52c-51.76-3-93.32,12.74-111.18,42.22-11.8,19.49-11.78,43.16-.16,65.74a71.34,71.34,0,0,0-14.17,27L98.33,159c7.82-16.33,7.52-33.35-1-47.49-13.2-21.79-43.67-33.47-81.5-31.25a8,8,0,0,0-7.52,7.52c-2.23,37.83,9.46,68.3,31.25,81.5A45.82,45.82,0,0,0,63.44,176,54.58,54.58,0,0,0,87,170.33l25,25V224a8,8,0,0,0,16,0V194.51a55.61,55.61,0,0,1,12.27-35,73.91,73.91,0,0,0,33.31,8.4,60.9,60.9,0,0,0,31.83-8.86C234.89,141.21,250.67,99.65,247.63,47.89ZM47.81,155.6C32.47,146.31,23.79,124.32,24,96c28.32-.24,50.31,8.47,59.6,23.81,4.85,8,5.64,17.33,2.46,26.94L61.65,122.34a8,8,0,0,0-11.31,11.31l24.41,24.41C65.14,161.24,55.82,160.45,47.81,155.6Zm149.31-10.22c-13.4,8.11-29.15,8.73-45.15,2l53.69-53.7a8,8,0,0,0-11.31-11.31L140.65,136c-6.76-16-6.15-31.76,2-45.15,13.94-23,47-35.82,89.33-34.83C232.94,98.34,220.14,131.44,197.12,145.38Z"/></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-rocket" viewBox="0 0 256 256"><path d="M152,224a8,8,0,0,1-8,8H112a8,8,0,0,1,0-16h32A8,8,0,0,1,152,224ZM128,112a12,12,0,1,0-12-12A12,12,0,0,0,128,112Zm95.62,43.83-12.36,55.63a16,16,0,0,1-25.51,9.11L158.51,200h-61L70.25,220.57a16,16,0,0,1-25.51-9.11L32.38,155.83a16.09,16.09,0,0,1,3.32-13.71l28.56-34.26a123.07,123.07,0,0,1,8.57-36.67c12.9-32.34,36-52.63,45.37-59.85a16,16,0,0,1,19.6,0c9.34,7.22,32.47,27.51,45.37,59.85a123.07,123.07,0,0,1,8.57,36.67l28.56,34.26A16.09,16.09,0,0,1,223.62,155.83ZM99.43,184h57.14c21.12-37.54,25.07-73.48,11.74-106.88C156.55,47.64,134.49,29,128,24c-6.51,5-28.57,23.64-40.33,53.12C74.36,110.52,78.31,146.46,99.43,184Zm-15,5.85Q68.28,160.5,64.83,132.16L48,152.36,60.36,208l.18-.13ZM208,152.36l-16.83-20.2q-3.42,28.28-19.56,57.69l23.85,18,.18.13Z"/></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-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>

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+1 -1
View File
@@ -119,7 +119,7 @@
html += '<td>' + (i + 1) + '</td>';
html += '<td class="' + (c.speculative ? 'speculative-warning' : '') + '">' +
c.score.toFixed(3) +
(c.speculative ? ' <span class="speculative-badge" title="Low evidence; may be wrong"></span>' : '') +
(c.speculative ? ' <span class="speculative-badge status-warn" title="Low evidence; may be wrong"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></span>' : '') +
'</td>';
html += '<td>' + escapeHtml(c.names.join(' → ')) + '</td>';
html += '<td><button class="btn btn-sm" data-idx="' + i + '">Show on Map</button></td>';
+20 -11
View File
@@ -32,18 +32,24 @@
if (!pktCtx || !pktCtx.type) return '';
var t = pktCtx.type;
var d = pktCtx.decoded || {};
// #1648 M3: payload-type taxonomy glyphs are inline Phosphor sprite refs
// (was: 📡/🔀/✉/🔒/🔓/⌖/#/·). // EMOJI-OK: comment referencing prior glyphs
var glyph, label, factsHtml = '';
switch (t) {
case 'ADVERT':
glyph = '📡'; label = 'ADVERT';
glyph = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg>'; label = 'ADVERT';
var name = d.adName || d.name || (d.pubKey ? d.pubKey.slice(0, 8) : '?');
var role = (d.flags && (d.flags.repeater ? 'repeater' : d.flags.room ? 'room' : d.flags.sensor ? 'sensor' : d.flags.chat ? 'companion' : 'unknown')) || 'unknown';
// no fabricated fields. Battery isn't decoded into the advert
// JSON — adverts carry lat/lon + name + flags, not battery. If a
// future advert version exposes it, re-add then.
var sig = (d.signatureValid === true) ? '✓' : (d.signatureValid === false ? '✗' : null);
var sigHtml = (d.signatureValid === true)
? '<span class="status-ok">' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check"/></svg>' + '</span>'
: (d.signatureValid === false
? '<span class="status-err">' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg>' + '</span>'
: null);
var line1 = '<b>' + escapeHtml(name) + '</b> · ' + escapeHtml(role);
if (sig) line1 += ' · sig ' + sig;
if (sigHtml) line1 += ' · sig ' + sigHtml;
// Self-reported GPS if present
if (d.lat != null && d.lon != null) {
line1 += ' · ' + d.lat.toFixed(3) + ', ' + d.lon.toFixed(3);
@@ -53,7 +59,7 @@
if (pkPrefix) factsHtml += '<div class="mc-rt-ctx-line mc-rt-ctx-mono">' + escapeHtml(pkPrefix) + '</div>';
break;
case 'PATH':
glyph = '🔀'; label = 'PATH';
glyph = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-shuffle"/></svg>'; label = 'PATH';
var psrc = pktCtx.srcResolvedName || (d.srcHash ? 'unknown (hash ' + d.srcHash + ')' : '?');
var pdst = pktCtx.destResolvedName || (d.destHash ? 'unknown (hash ' + d.destHash + ')' : '?');
factsHtml = '<div class="mc-rt-ctx-line"><b>' + escapeHtml(psrc) + '</b> <span class="mc-rt-ctx-arrow">→</span> <b>' + escapeHtml(pdst) + '</b></div>';
@@ -62,21 +68,24 @@
case 'REQ':
case 'RESPONSE':
case 'ANON_REQ':
var typeGlyphs = { 'TXT_MSG': '', 'REQ': '🔒', 'RESPONSE': '🔓', 'ANON_REQ': '🔒' };
var typeGlyphNames = { 'TXT_MSG': 'ph-envelope', 'REQ': 'ph-lock', 'RESPONSE': 'ph-lock-open', 'ANON_REQ': 'ph-lock' };
var typeLabels = { 'TXT_MSG': 'DM', 'REQ': 'REQUEST', 'RESPONSE': 'RESPONSE', 'ANON_REQ': 'ANON REQ' };
glyph = typeGlyphs[t] || '·';
glyph = (typeGlyphNames[t] ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#' + typeGlyphNames[t] + '"/></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle"/></svg>');
label = typeLabels[t] || t;
var src = pktCtx.srcResolvedName || (d.srcHash ? 'unknown (hash ' + d.srcHash + ')' : (t === 'ANON_REQ' ? 'anon' : '?'));
var dst = pktCtx.destResolvedName || (d.destHash ? 'unknown (hash ' + d.destHash + ')' : '?');
factsHtml = '<div class="mc-rt-ctx-line"><b>' + escapeHtml(src) + '</b> <span class="mc-rt-ctx-arrow">→</span> <b>' + escapeHtml(dst) + '</b></div>';
factsHtml += '<div class="mc-rt-ctx-line mc-rt-ctx-meta">🔒 encrypted</div>';
factsHtml += '<div class="mc-rt-ctx-line mc-rt-ctx-meta">' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg>' + ' encrypted</div>';
break;
case 'GRP_TXT':
case 'CHAN':
glyph = '#'; label = 'CHANNEL MSG';
glyph = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-hash"/></svg>'; label = 'CHANNEL MSG';
var chName = pktCtx.channelName || d.channel || (d.channelHashHex ? 'channel 0x' + d.channelHashHex : 'channel ?');
var contentText = pktCtx.decryptedText || d.text || d.plainText || null;
var encStatus = contentText ? '🔓 decrypted' : (d.decryptionStatus === 'decrypted' ? '🔓 decrypted' : '🔒 no key');
var decrypted = !!contentText || d.decryptionStatus === 'decrypted';
var encStatus = decrypted
? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock-open"/></svg>' + ' decrypted'
: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg>' + ' no key';
factsHtml = '<div class="mc-rt-ctx-line"><b>' + escapeHtml(chName) + '</b></div>';
factsHtml += '<div class="mc-rt-ctx-line mc-rt-ctx-meta">' + encStatus + '</div>';
if (contentText) {
@@ -88,7 +97,7 @@
if (senderName) factsHtml += '<div class="mc-rt-ctx-line mc-rt-ctx-meta">from <b>' + escapeHtml(senderName) + '</b></div>';
break;
case 'TRACE':
glyph = ''; label = 'TRACE';
glyph = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-crosshair"/></svg>'; label = 'TRACE';
var officialHops = (d.routeTaken && d.routeTaken.length) || (d.route && d.route.length) || null;
var observed = (pktCtx.observedHops != null) ? pktCtx.observedHops : null;
if (officialHops != null && observed != null) {
@@ -99,7 +108,7 @@
if (pktCtx.issuedBy) factsHtml += '<div class="mc-rt-ctx-line mc-rt-ctx-meta">issued by <b>' + escapeHtml(pktCtx.issuedBy) + '</b></div>';
break;
default:
glyph = '·'; label = (t || 'OTHER').toUpperCase();
glyph = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle"/></svg>'; label = (t || 'OTHER').toUpperCase();
if (pktCtx.payloadSize != null) {
factsHtml = '<div class="mc-rt-ctx-line mc-rt-ctx-meta">' + pktCtx.payloadSize + ' bytes</div>';
}
+13 -7
View File
@@ -134,7 +134,7 @@
var titleTxt = node.multi_byte_status === 'suspected'
? 'Conflicting evidence about this node\u2019s hash-prefix size (multi-byte not confirmed)'
: 'No advert sample yet to confirm hash-prefix size';
suspectedWarn = '<span class="mc-rt-detail-warn" title="' + escapeHtml(titleTxt) + '"> ' + lbl + '</span>';
suspectedWarn = '<span class="mc-rt-detail-warn status-warn" title="' + escapeHtml(titleTxt) + '"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> ' + lbl + '</span>';
}
var rel = node.last_seen ? relativeTime(node.last_seen) : '';
var snr = buildSnrSparkline(ana.snrTrend || []);
@@ -218,7 +218,13 @@
}
function roleGlyph(role) {
return ({repeater:'●', companion:'■', room:'⬢', sensor:'▲', observer:'◆'})[role] || '○';
// #1648 M3: role shape glyphs → Phosphor sprite refs.
// Map preserves prior visual intent (●→circle-fill, ■→square-fill, // EMOJI-OK: comment
// ⬢→hexagon, ▲→triangle, ◆→diamond) and falls back to a hollow circle. // EMOJI-OK: comment
var name = ({ repeater: 'ph-circle-fill', companion: 'ph-square-fill',
room: 'ph-hexagon', sensor: 'ph-triangle',
observer: 'ph-diamond' })[role] || 'ph-circle';
return '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#' + name + '"/></svg>';
}
function buildSidebar(positions, mapRef, layer, edges, markers, opts) {
@@ -266,14 +272,14 @@
var glyph = roleGlyph(p.role);
var name = escapeHtml(p.name || (p.pubkey ? String(p.pubkey).slice(0,8) : '?'));
// Show a status badge for unresolved hops:
// - gpsless: node identified but missing GPS → "📍 no GPS"
// - else: couldn't resolve prefix → "🔍 unknown"
// - gpsless: node identified but missing GPS → no-GPS pin chip
// - else: couldn't resolve prefix → unknown chip
var statusBadge = '';
if (p.resolved === false) {
if (p.gpsless) {
statusBadge = ' <span class="mc-rt-status-chip mc-rt-status-nogps" title="Node identified but has no GPS coordinates">📍 no GPS</span>';
statusBadge = ' <span class="mc-rt-status-chip mc-rt-status-nogps" title="Node identified but has no GPS coordinates"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-pin"/></svg> no GPS</span>';
} else {
statusBadge = ' <span class="mc-rt-status-chip mc-rt-status-unknown" title="Could not resolve this hop prefix to a known node">🔍 unknown</span>';
statusBadge = ' <span class="mc-rt-status-chip mc-rt-status-unknown" title="Could not resolve this hop prefix to a known node"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> unknown</span>';
}
}
// hops derived from the packet's PAYLOAD (sender/recipient
@@ -387,7 +393,7 @@
multiPathChip +
pathPicker +
'<div class="mc-rt-spark-wrap">' + spark + '</div>' +
'<button class="mc-rt-close" aria-label="Close route view" type="button"></button>' +
'<button class="mc-rt-close" aria-label="Close route view" type="button"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>' +
'</div>';
// origin row pinned at top, dest at bottom; middle scrollable
+17
View File
@@ -18,6 +18,23 @@
flex-shrink: 0;
}
/* Status-token color classes (#1648 M3).
* Replace 🟢🟡🔴⚪/✅⚠️❌ emoji dots with Phosphor sprites that inherit
* their color via currentColor. Each class threads the existing
* --status-* color token so dark/light theming continues to work.
*
* Pattern:
* <span class="status-ok"><svg class="ph-icon"><use href="…#ph-circle-fill"/></svg></span>
*/
.status-ok { color: var(--status-green); }
.status-warn { color: var(--status-yellow); }
.status-err { color: var(--status-red); }
.status-muted { color: var(--text-muted, #94a3b8); }
.status-ok .ph-icon,
.status-warn .ph-icon,
.status-err .ph-icon,
.status-muted .ph-icon { fill: currentColor; }
/* Aldrich webfont — used by the navbar logo SVG (issue #1137 follow-up).
* Self-hosted woff2 (latin subset from Google Fonts, ~16KB). Only weight
* available is 400; the SVG's font-weight="700" synthesizes bold. */
+1
View File
@@ -30,6 +30,7 @@ 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-issue-1648-m3-emoji-scan.js
node test-traces.js
# #1418 — route-view v2 (Tufte) coverage
+3 -2
View File
@@ -87,8 +87,9 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
!document.getElementById('chEncryptedBody').hasAttribute('hidden'));
const lockBadge = await page.$('.ch-section-encrypted .ch-badge');
assert(lockBadge, 'encrypted section should render badges');
const txt = await page.textContent('.ch-section-encrypted .ch-badge');
assert(/🔒/.test(txt), 'encrypted badge should show lock glyph: ' + txt);
// #1648 M3: lock badge is a Phosphor sprite ref (was 🔒 emoji)
const html = await page.$eval('.ch-section-encrypted .ch-badge', el => el.innerHTML);
assert(/#ph-lock/.test(html), 'encrypted badge should reference ph-lock sprite: ' + html);
});
await step('Network row preview shows last sender:message', async () => {
+17 -15
View File
@@ -2868,7 +2868,7 @@ console.log('\n=== channels.js: encrypted channel without key shows lock message
// Should show lock message, NOT fetch messages API
const msgEl = dom['chMessages'];
assert.ok(msgEl.innerHTML.includes('🔒'), 'should show lock emoji for encrypted channel without key');
assert.ok(msgEl.innerHTML.includes('#ph-lock'), 'should show lock sprite for encrypted channel without key'); // #1648 M3
assert.ok(msgEl.innerHTML.includes('no decryption key'), 'should mention no decryption key');
const messageApiFetched = apiCallPaths.some(p => p.indexOf('/messages') !== -1);
assert.ok(!messageApiFetched, 'should NOT fetch messages API for encrypted channel without key');
@@ -2967,7 +2967,7 @@ console.log('\n=== channels.js: encrypted channel without key shows lock message
includeEncryptedChannels: [{ hash: '#test', name: '#test', messageCount: 3, lastActivity: null, encrypted: null }],
storedKey: null,
});
assert.ok(!r.msgHtml.includes('🔒'), 'unencrypted #channel must NOT show lock affordance');
assert.ok(!r.msgHtml.includes('#ph-lock'), 'unencrypted #channel must NOT show lock affordance'); // #1648 M3
const messageApiFetched = r.apiCallPaths.some(p => p.indexOf('/messages') !== -1);
assert.ok(messageApiFetched, 'unencrypted #channel must fetch messages REST endpoint');
});
@@ -2978,7 +2978,7 @@ console.log('\n=== channels.js: encrypted channel without key shows lock message
includeEncryptedChannels: [{ hash: '#private', name: '#private', messageCount: 5, lastActivity: null, encrypted: true }],
storedKey: null,
});
assert.ok(r.msgHtml.includes('🔒'), 'encrypted #channel without key must show lock affordance');
assert.ok(r.msgHtml.includes('#ph-lock'), 'encrypted #channel without key must show lock affordance'); // #1648 M3
assert.ok(r.msgHtml.includes('no decryption key'), 'lock should mention no decryption key');
const messageApiFetched = r.apiCallPaths.some(p => p.indexOf('/messages') !== -1);
assert.ok(!messageApiFetched, 'must NOT fetch /messages REST for encrypted channel without key');
@@ -6295,7 +6295,7 @@ console.log('\n=== analytics.js: renderCollisionsFromServer collision table ==='
}, {});
// Must contain unreliable warning badge button
assert.ok(html.includes('hop-unreliable-btn'), 'should have unreliable badge button');
assert.ok(html.includes('⚠️'), 'should have ⚠️ icon');
assert.ok(html.includes('#ph-warning'), 'should have ph-warning sprite icon'); // #1648 M3
assert.ok(html.includes('Unreliable name resolution'), 'should have tooltip text');
// Must NOT contain line-through in inline style (CSS class no longer has it)
assert.ok(!html.includes('line-through'), 'should not contain line-through');
@@ -6440,7 +6440,9 @@ console.log('\n=== roles.js: Map Tile Config Parsing ===');
test('#1504: each legend entry has glyph + description', () => {
HD.PATH_SYMBOLS_LEGEND.forEach((e, i) => {
assert.ok(e && typeof e.glyph === 'string' && e.glyph.length > 0, 'entry ' + i + ' needs non-empty glyph');
// #1648 M3: glyph → glyphHtml (Phosphor sprite markup), text fallback OK
const g = (e && (e.glyphHtml || e.glyph)) || '';
assert.ok(typeof g === 'string' && g.length > 0, 'entry ' + i + ' needs non-empty glyph/glyphHtml');
assert.ok(typeof e.description === 'string' && e.description.length > 0, 'entry ' + i + ' needs non-empty description');
});
});
@@ -6454,7 +6456,7 @@ console.log('\n=== roles.js: Map Tile Config Parsing ===');
const html = HD.renderPathSymbolsLegend();
assert.ok(html.includes('<details'), 'must render a <details> element');
assert.ok(html.includes('<summary>Path symbols</summary>'), 'must have summary text "Path symbols"');
assert.ok(html.includes('⚠'), 'must contain warning glyph');
assert.ok(html.includes('#ph-warning') || html.includes('⚠'), 'must contain warning glyph (sprite or unicode)'); // #1648 M3
assert.ok(/dashed/i.test(html), 'must describe the dashed underline convention');
});
@@ -6502,24 +6504,24 @@ console.log('\n=== roles.js: Map Tile Config Parsing ===');
test('#1504: legend glyphs match what hop-display.js actually renders (no documented-but-missing glyphs)', () => {
const hopSrc = fs.readFileSync(__dirname + '/public/hop-display.js', 'utf8');
HD.PATH_SYMBOLS_LEGEND.forEach(entry => {
const g = entry.glyph;
// #1648 M3: glyphs are now sprite HTML (glyphHtml); the legend uses
// ph-warning sprite refs which appear inline in hop-display.js too.
const g = entry.glyphHtml || entry.glyph || '';
if (g === 'dashed underline') {
// documented as a CSS convention; class hop-ambiguous uses border-bottom: dashed
assert.ok(/hop-ambiguous|hop-global-fallback/.test(hopSrc),
'legend mentions "dashed underline" but hop-display.js has no ambiguous/global-fallback class');
return;
}
if (g === '⚠N') {
// Real template literal in hop-display.js: ⚠${badgeCount}
assert.ok(hopSrc.includes('⚠${badgeCount}') || hopSrc.includes('\u26a0${badgeCount}'),
'legend documents ⚠N but hop-display.js does not emit ⚠${badgeCount}');
// Sprite legend entries reference ph-warning; hop-display.js must
// also reference ph-warning (it's what renderHop emits).
if (/ph-warning/.test(g)) {
assert.ok(/ph-warning/.test(hopSrc),
'legend uses ph-warning sprite but hop-display.js does not reference it');
return;
}
// Otherwise the literal glyph must appear in the file
assert.ok(hopSrc.includes(g),
'legend glyph ' + JSON.stringify(g) + ' (codepoints ' +
[...g].map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase()).join(',') +
') not found in hop-display.js');
'legend glyph ' + JSON.stringify(g.slice(0, 60)) + ' not found in hop-display.js');
});
});
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env node
/* Issue #1648 M3: emoji Phosphor sprite migration (static scan).
*
* M3 covers detail panes, status pills, role/payload-type badges:
* home.js, channels.js, route-view-utils.js, route-view.js,
* app.js, hop-display.js, path-inspector.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.
*
* Anti-tautology: this test FAILS pre-implementation by construction.
*
* Also asserts status-token CSS pattern: .status-{ok,warn,err,muted} rules
* exist in style.css and set color via var(--status-*).
*/
'use strict';
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const ROOT = path.resolve(__dirname, 'public');
const M3_FILES = [
'home.js',
'channels.js',
'route-view-utils.js',
'route-view.js',
'app.js',
'hop-display.js',
'path-inspector.js',
];
const MIN_USE_REFS = {
'home.js': 12,
'channels.js': 10,
'route-view-utils.js': 6,
'route-view.js': 3,
'app.js': 4,
'hop-display.js': 2,
'path-inspector.js': 1,
};
const EMOJI = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u;
const MISC_ICON = /[◆●■▲★☆○✓✗⚠✉]/u;
// Allowlist substrings: lines containing any of these are exempt from the
// scan.
const ALLOW_SUBSTRINGS = [
'EMOJI-OK',
// home.js: getting-started Q&A items are user-facing copy with emoji
// baked into the *text* content (Discord link names, step labels).
// They are not UI icons — they are part of the prose. Allowlist by the
// EMOJI-OK tag on each retained line.
// hop-display.js / channels.js / route-view*.js comments referring to
// prior glyphs:
'// alongside',
'// renderer can label',
'// Unresolved prefix',
'// clicking',
'// (the yellow',
'// PATH_SYMBOLS_LEGEND',
'// the ✕ disappears',
'// userAdded so the',
];
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 assertSpriteHasM3Icons() {
const sp = path.join(ROOT, 'icons', 'phosphor-sprite.svg');
const txt = fs.readFileSync(sp, 'utf8');
const need = [
'ph-rocket', 'ph-camera', 'ph-plant', 'ph-bluetooth',
'ph-paper-plane-tilt', 'ph-shuffle', 'ph-hexagon',
];
const missing = need.filter(id => !txt.includes(`id="${id}"`));
if (missing.length) throw new Error(`sprite missing M3 symbols: ${missing.join(', ')}`);
}
function assertStatusTokenCss() {
const css = fs.readFileSync(path.join(ROOT, 'style.css'), 'utf8');
const needed = ['.status-ok', '.status-warn', '.status-err', '.status-muted'];
const missing = needed.filter(sel => !new RegExp(sel.replace('.', '\\.') + '\\b').test(css));
if (missing.length) throw new Error(`style.css missing status-token rules: ${missing.join(', ')}`);
// Each must thread a --status-* var via color
const block = css.match(/\.status-ok[\s\S]{0,400}\}/);
if (!block || !/var\(--status-/.test(block[0])) {
throw new Error('.status-ok rule must set color via var(--status-*)');
}
}
function main() {
let failed = 0;
console.log('— Issue #1648 M3 — emoji/misc-icon scan');
try {
assertSpriteHasM3Icons();
console.log(' ✓ sprite has required M3 symbols');
} catch (e) {
console.error(`${e.message}`);
failed++;
}
try {
assertStatusTokenCss();
console.log(' ✓ style.css has .status-{ok,warn,err,muted} threading var(--status-*)');
} catch (e) {
console.error(`${e.message}`);
failed++;
}
for (const rel of M3_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})`);
}
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} M3 check(s) failed`);
process.exit(1);
}
console.log('\nPASS: all M3 surfaces icon-free and Phosphor-swapped');
}
main();
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
/* Issue #1648 M3: emoji Phosphor sprite migration (E2E behavioral).
*
* Asserts (in a real Chromium against a running server):
* (a) /home welcome cards render Phosphor icons (chooser, FAQ heading,
* step glyphs) and the rendered DOM has zero emoji codepoints.
* (b) /channels modal title/help renders Phosphor sprites (no 💬 / 🔒
* emoji in modal chrome).
* (c) /nodes/<one-pubkey> detail pane at minimum, sprite refs render
* and no .notdef glyphs leak through.
* (d) /analytics page renders sprite refs (M2+M3 surfaces combined).
* (e) /live page renders sprite refs.
* (f) NO .notdef glyph anywhere every <use> resolves to a defined
* sprite symbol id.
* (g) The .status-{ok,warn,err,muted} rules in style.css resolve to
* actual --status-* color values (computed color reflects token).
*
* 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 spriteRefsResolve(page, label, min) {
min = min || 1;
const r = await page.evaluate(() => {
const uses = Array.from(document.querySelectorAll('svg.ph-icon use'));
return {
count: uses.length,
refs: uses.slice(0, 5).map(u => u.getAttribute('href') || u.getAttribute('xlink:href') || ''),
};
});
if (r.count < min) fail(`${label}: only ${r.count} sprite refs (expected ≥${min})`);
else pass(`${label}: ${r.count} sprite refs (≥${min})`);
}
async function noEmojiInRender(page, route, label) {
await page.goto(`${BASE}/#${route}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!document.querySelector('#app'),
null, { timeout: 8000 }).catch(() => {});
// Give the SPA a tick to render
await page.waitForTimeout(400);
const txt = await page.evaluate(() => (document.getElementById('app') || document.body).textContent || '');
if (EMOJI_RE.test(txt)) {
const sample = txt.match(EMOJI_RE);
fail(`${label}: rendered DOM contains emoji (sample: ${JSON.stringify(sample && sample[0])})`);
} else {
pass(`${label}: rendered DOM is emoji-free`);
}
}
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-m3-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) /home welcome cards
await noEmojiInRender(page, '/home', '(a) /home');
// /home should render at least the chooser/onboard sprites
const home = await page.evaluate(() => {
return {
uses: Array.from(document.querySelectorAll('svg.ph-icon use'))
.map(u => (u.getAttribute('href') || '').replace(/^.*#/, '')),
hasChooser: !!document.querySelector('.chooser-icon, .onboard-icon, .home-footer-link'),
};
});
if (!home.hasChooser) fail('(a) /home: chooser/onboard elements missing');
else pass('(a) /home: chooser/onboard surfaces present');
if (home.uses.length === 0) fail('(a) /home: no sprite refs rendered');
else pass(`(a) /home: ${home.uses.length} sprite refs (sample: ${home.uses.slice(0,4).join(',')})`);
// (b) /channels modal + sidebar
await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
const channels = await page.evaluate(() => {
return {
sidebarText: (document.querySelector('.ch-sidebar-title') || {}).textContent || '',
sidebarSprites: (document.querySelectorAll('.ch-sidebar-title svg.ph-icon') || []).length,
bodyText: (document.getElementById('app') || document.body).textContent || '',
allSprites: document.querySelectorAll('svg.ph-icon use').length,
};
});
if (EMOJI_RE.test(channels.sidebarText)) fail('(b) /channels: sidebar title still has emoji');
else pass('(b) /channels: sidebar title is emoji-free');
if (channels.sidebarSprites === 0) fail('(b) /channels: sidebar title has no sprite icon');
else pass(`(b) /channels: sidebar title has ${channels.sidebarSprites} sprite(s)`);
if (channels.allSprites === 0) fail('(b) /channels: zero sprite refs on page');
else pass(`(b) /channels: ${channels.allSprites} sprite refs on page`);
// (c) /nodes detail — fetch one pubkey from /api/nodes
const pk = await page.evaluate(async () => {
try {
const r = await fetch('/api/nodes?limit=1');
const j = await r.json();
const list = j.nodes || j.data || j || [];
const n = Array.isArray(list) ? list[0] : null;
return n && (n.public_key || n.pubkey || n.pubKey) || null;
} catch { return null; }
});
if (!pk) {
console.warn(' ⚠ (c) /nodes/<id>: no node from API; falling back to /nodes list page');
await noEmojiInRender(page, '/nodes', '(c) /nodes');
} else {
await page.goto(`${BASE}/#/nodes/${pk}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
await spriteRefsResolve(page, '(c) /nodes/<pk> sprite refs', 1);
}
// (d) /analytics — exercise multiple tabs casually
await noEmojiInRender(page, '/analytics', '(d) /analytics');
await spriteRefsResolve(page, '(d) /analytics sprite refs', 5);
// (e) /live
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
await spriteRefsResolve(page, '(e) /live sprite refs', 3);
// (f) No .notdef anywhere — every <use> resolves to a defined symbol id.
const undef = await page.evaluate(async () => {
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(`(f) sprite fetch: ${undef.error}`);
else if (undef.missing && undef.missing.length) fail(`(f) ${undef.missing.length} sprite ref(s) not resolved: ${undef.missing.slice(0,5).join(', ')}`);
else pass(`(f) all ${undef.count} sprite refs resolve to one of ${undef.ids} defined symbols`);
// (g) status-token CSS resolves to a real color
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(200);
const status = await page.evaluate(() => {
const probe = document.createElement('span');
probe.className = 'status-ok';
probe.style.position = 'absolute'; probe.style.left = '-9999px';
document.body.appendChild(probe);
const cs = getComputedStyle(probe);
const color = cs.color;
probe.remove();
return color;
});
if (!/rgb/.test(status)) fail(`(g) .status-ok did not resolve to an rgb color (got "${status}")`);
else pass(`(g) .status-ok resolves to ${status} (currentColor token threaded)`);
await browser.close();
console.log(`\ntest-issue-1648-m3-icons-e2e.js: ${passes} passed, ${failures} failed`);
assert.strictEqual(failures, 0, `${failures} M3 icon-render assertions failed`);
process.exit(0);
}
main().catch((err) => {
console.error('test-issue-1648-m3-icons-e2e.js: FAIL —', err);
process.exit(1);
});