From 2b6809cd2886bf1eed4f60989234d6428483ec9e Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Thu, 11 Jun 2026 03:58:29 -0700 Subject: [PATCH] =?UTF-8?q?M4:=20emoji=20=E2=86=92=20Phosphor=20Icons=20?= =?UTF-8?q?=E2=80=94=20map=20&=20route=20overlays=20(#1648)=20(#1652)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draft for milestone 4 of #1648 β€” emoji β†’ Phosphor Icons (map & route overlays). Currently at the red commit (failing test only). Implementation follows. Partial fix for #1648 (M4 of 6). Do NOT close the tracking issue. --------- Co-authored-by: bot --- .github/workflows/deploy.yml | 2 + public/analytics.js | 2 +- public/area-map.html | 6 +- public/icons/phosphor-sprite.svg | 6 + public/map.js | 8 +- public/packets.js | 8 +- public/route-render.js | 36 +++-- public/route-view.css | 9 +- public/route-view.js | 10 +- test-issue-1374-route-map-a11y-e2e.js | 15 +- test-issue-1648-m4-emoji-scan.js | 170 ++++++++++++++++++++ test-issue-1648-m4-icons-e2e.js | 223 ++++++++++++++++++++++++++ 12 files changed, 463 insertions(+), 32 deletions(-) create mode 100644 test-issue-1648-m4-emoji-scan.js create mode 100644 test-issue-1648-m4-icons-e2e.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2553f6e1..272751a5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -139,6 +139,7 @@ jobs: node test-xss-escape-sinks.js node test-preflight-xss-gate.js node test-traces.js + node test-issue-1648-m4-emoji-scan.js - name: πŸ›‘οΈ Preflight XSS gate β€” actual --diff check (PR only) # The fixture self-test above (test-preflight-xss-gate.js) only @@ -401,6 +402,7 @@ jobs: 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 + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1648-m4-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 diff --git a/public/analytics.js b/public/analytics.js index d9cab35b..300848f3 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -3026,7 +3026,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = el.innerHTML = `
- β–Ά +

Network Overview

- - + +
0 points β€” need β‰₯ 3
Fill key + label, then draw polygon points…
diff --git a/public/icons/phosphor-sprite.svg b/public/icons/phosphor-sprite.svg index 6472c8b3..07d3c4d6 100644 --- a/public/icons/phosphor-sprite.svg +++ b/public/icons/phosphor-sprite.svg @@ -1,5 +1,6 @@
-
β—€
+

Path Inspector

Hex prefixes (1-3 bytes), comma or space separated.

@@ -1760,7 +1760,9 @@ toggle.addEventListener('click', function () { pane.classList.toggle('expanded'); - toggle.textContent = pane.classList.contains('expanded') ? 'β–Ά' : 'β—€'; + // #1648 M4: Phosphor sprite glyph for pane toggle (was β–Ά/β—€). // EMOJI-OK: comment + toggle.innerHTML = ''; // Invalidate map size after transition. setTimeout(function () { if (map) map.invalidateSize(); }, 220); }); @@ -1777,7 +1779,7 @@ var prefixParam = params.get('prefixes'); if (prefixParam && input) { pane.classList.add('expanded'); - toggle.textContent = 'β–Ά'; + toggle.innerHTML = ''; input.value = prefixParam; setTimeout(function () { if (map) map.invalidateSize(); }, 220); mapPiSubmit(prefixParam); diff --git a/public/packets.js b/public/packets.js index df1ac928..b661ce29 100644 --- a/public/packets.js +++ b/public/packets.js @@ -1107,7 +1107,9 @@ packetsPaused = !packetsPaused; const pauseBtn = document.getElementById('pktPauseBtn'); if (pauseBtn) { - pauseBtn.textContent = packetsPaused ? 'β–Ά' : '⏸'; + // #1648 M4: Phosphor sprite glyph for play/pause (was β–Ά/⏸). // EMOJI-OK: comment + pauseBtn.innerHTML = ''; pauseBtn.title = packetsPaused ? 'Resume live updates' : 'Pause live updates'; pauseBtn.classList.toggle('active', packetsPaused); } @@ -1155,7 +1157,7 @@ pauseBuffer.push(...msgs); if (pauseBuffer.length > 2000) pauseBuffer = pauseBuffer.slice(-2000); const btn = document.getElementById('pktPauseBtn'); - if (btn) btn.textContent = 'β–Ά ' + pauseBuffer.length; + if (btn) btn.innerHTML = ' ' + pauseBuffer.length; return; } const newPkts = msgs @@ -3112,7 +3114,7 @@ ${pathHops.length ? `` : ''} ${pkt.hash ? ` Trace` : ''} - +
${(hasRawHex || Object.keys(decoded).length) ? `
480) ? ' open' : ''}> diff --git a/public/route-render.js b/public/route-render.js index 43e431df..7bae757c 100644 --- a/public/route-render.js +++ b/public/route-render.js @@ -58,8 +58,9 @@ /** * Build the role-aware marker SVG for a hop. Origin and destination get a - * larger outline + a glyph (β–Ά / βš‘) layered on the standard role shape so - * the role information remains visible. + * larger outline + a Phosphor sprite glyph (play/flag) layered on the + * standard role shape so the role information remains visible. + * #1648 M4: prior glyphs were inline chars (\u25B6 / \u2691). // EMOJI-OK: comment */ function buildHopSVG(p, opts) { var size = opts.size || 22; @@ -78,13 +79,18 @@ var ringDash = opts.unresolved ? '4 3' : 'none'; var ringFill = opts.unresolved ? 'rgba(150,150,150,0.15)' : 'none'; + // Phosphor sprite overlaid on the role marker. Sized to ~55% of + // outer ring, centered on the marker. fill="#0f172a" preserves the + // dark-on-light glyph contrast of the prior implementation. var glyph = ''; - if (opts.isOrigin) { - glyph = ''; - } else if (opts.isDest) { - glyph = ''; + if (opts.isOrigin || opts.isDest) { + var gid = opts.isOrigin ? 'ph-play' : 'ph-flag'; + var gSize = Math.round(outerSize * 0.55); + var gOff = (outerSize - gSize) / 2; + glyph = ''; } // Strip outer from inner SVG, re-wrap with outer ring + glyph @@ -103,11 +109,15 @@ } function buildBadge(idx, total, opts) { - var txt; - if (opts.isOrigin) txt = '\u25B6'; // β–Ά - else if (opts.isDest) txt = '\u2691'; // βš‘ - else txt = String(idx); // intermediate hop number - return ''; + // Intermediate hops render the hop number; origin/destination render a + // Phosphor sprite glyph (play/flag) in place of the prior \u25B6 / \u2691. // EMOJI-OK: comment + if (opts.isOrigin || opts.isDest) { + var gid = opts.isOrigin ? 'ph-play' : 'ph-flag'; + return ''; + } + return ''; } function buildPopupHtml(p, hopNum, total) { diff --git a/public/route-view.css b/public/route-view.css index a5edf3f3..91e136df 100644 --- a/public/route-view.css +++ b/public/route-view.css @@ -115,13 +115,14 @@ body.mc-route-active .leaflet-overlay-pane svg path:not(.mc-rt-edge) { display: font-weight: 600; } .mc-rt-paths-header::-webkit-details-marker { display: none; } -.mc-rt-paths-header::before { - content: 'β–Ύ'; +/* #1648 M4: chevron is now an inline Phosphor sprite (ph-caret-down) injected + by route-view.js. CSS only handles the rotation when the
is + closed. Replaces prior ::before content with a Misc-Symbols char. */ +.mc-rt-paths-chevron { margin-right: 4px; - font-size: 9px; transition: transform 120ms; } -.mc-rt-paths:not([open]) .mc-rt-paths-header::before { transform: rotate(-90deg); } +.mc-rt-paths:not([open]) .mc-rt-paths-chevron { transform: rotate(-90deg); } .mc-rt-path-clear { background: transparent; border: 1px solid var(--border, #444); diff --git a/public/route-view.js b/public/route-view.js index f5e68ac4..d3a03d49 100644 --- a/public/route-view.js +++ b/public/route-view.js @@ -355,6 +355,7 @@ ''; }).join(''); pathPicker = '
' + + '' + uniquePathsCount + ' unique paths Β· click to isolate' + '' + '
    ' + pickerRows + '
'; @@ -448,7 +449,7 @@ sidebar.innerHTML = // Desktop: resize handle on the right edge + collapse button. '' + - '' + + '' + '' + // Mobile: bottom-sheet header (summary + chevron). No drag-grip β€” // conflicted with browser pull-to-refresh and CoreScope's own pull-to- @@ -466,7 +467,10 @@ var collapsed = sidebar.classList.toggle('mc-rt-collapsed'); collapseBtn.setAttribute('aria-label', collapsed ? 'Expand route panel' : 'Collapse route panel'); collapseBtn.setAttribute('title', collapsed ? 'Expand route panel' : 'Collapse route panel'); - collapseBtn.textContent = collapsed ? 'β–Ά' : 'β—€'; + // #1648 M4: swap Phosphor sprite glyph (caret-right when collapsed, + // caret-left when expanded). Replaces prior β–Ά/β—€ Misc-Symbols chars. // EMOJI-OK: comment + collapseBtn.innerHTML = ''; setTimeout(function () { if (mapRef && mapRef.invalidateSize) mapRef.invalidateSize(); }, 280); }); } @@ -478,7 +482,7 @@ sidebar.classList.remove('mc-rt-collapsed'); if (collapseBtn) { collapseBtn.setAttribute('aria-label', 'Collapse route panel'); - collapseBtn.textContent = 'β—€'; + collapseBtn.innerHTML = ''; } setTimeout(function () { if (mapRef && mapRef.invalidateSize) mapRef.invalidateSize(); }, 280); } diff --git a/test-issue-1374-route-map-a11y-e2e.js b/test-issue-1374-route-map-a11y-e2e.js index 05fd634a..bd48f796 100644 --- a/test-issue-1374-route-map-a11y-e2e.js +++ b/test-issue-1374-route-map-a11y-e2e.js @@ -109,12 +109,21 @@ async function runViewport(browser, width, height, label) { await step(label + ': sequence-number badge present beside each marker (not in label text)', async () => { const data = await page.evaluate(() => { const badges = Array.from(document.querySelectorAll('.mc-route-seq-badge')); - return badges.map(b => b.textContent.trim()); + return badges.map(b => ({ + text: b.textContent.trim(), + // #1648 M4: origin/dest badges now contain a Phosphor sprite + // ( or "#ph-flag") instead of a glyph char. + spriteId: (b.querySelector('use') || {}).getAttribute && + (b.querySelector('use').getAttribute('href') || '').replace(/^.*#/, ''), + })); }); assert(data.length >= 5, 'expected >=5 sequence badges, got ' + data.length); - // Badges should be numeric or numbered glyphs. + // Badges should be numeric, a numbered glyph, OR a Phosphor sprite ref + // (ph-play for origin, ph-flag for destination). for (const b of data) { - assert(/^[\dβ‘ β‘‘β‘’β‘£β‘€β‘₯β‘¦β‘§β‘¨β‘©β–Άβš‘]+$/.test(b), 'badge "' + b + '" not numeric/glyph'); + if (b.text && /^[\dβ‘ β‘‘β‘’β‘£β‘€β‘₯β‘¦β‘§β‘¨β‘©β–Άβš‘]+$/.test(b.text)) continue; + if (b.spriteId && /^ph-(play|flag)$/.test(b.spriteId)) continue; + assert(false, 'badge "' + JSON.stringify(b) + '" not numeric/glyph/sprite'); } }); diff --git a/test-issue-1648-m4-emoji-scan.js b/test-issue-1648-m4-emoji-scan.js new file mode 100644 index 00000000..2da43d93 --- /dev/null +++ b/test-issue-1648-m4-emoji-scan.js @@ -0,0 +1,170 @@ +#!/usr/bin/env node +/* Issue #1648 β€” M4: emoji β†’ Phosphor sprite migration (static scan). + * + * M4 covers map & route overlays: + * map.js, area-map.html, analytics.js (route-jump residual), + * packets.js (route/replay residual), route-view.js, + * route-view-utils.js, route-view.css, route-render.js + * + * Asserts (per file): + * 1. Zero UI-iconography codepoints (emoji + the Misc-Symbols set used + * historically as icons, including pane-toggle carets β–Ά/β—€, dropdown + * caret β–Ύ, βœ• close, βš‘ destination flag) outside an allowlist + * (// EMOJI-OK lines, or comments mentioning prior glyphs). + * 2. At least N !txt.includes(`id="${id}"`)); + if (missing.length) throw new Error(`sprite missing M4 symbols: ${missing.join(', ')}`); +} + +// Route-render.js builds its sprite href with runtime concat. Verify the +// sprite IDs and the sprite path prefix both appear in source. +function assertRouteRenderSpriteRefs() { + const txt = fs.readFileSync(path.join(ROOT, 'route-render.js'), 'utf8'); + for (const id of ['ph-play', 'ph-flag']) { + if (!txt.includes("'" + id + "'") && !txt.includes('"' + id + '"')) { + throw new Error(`route-render.js must reference sprite id ${id}`); + } + } + if (!txt.includes('/icons/phosphor-sprite.svg#')) { + throw new Error('route-render.js must reference the Phosphor sprite path'); + } +} + +function main() { + let failed = 0; + console.log('β€” Issue #1648 M4 β€” emoji/misc-icon scan'); + + try { + assertSpriteHasM4Icons(); + console.log(' βœ“ sprite has required M4 symbols'); + } catch (e) { + console.error(` βœ— ${e.message}`); + failed++; + } + + try { + assertRouteRenderSpriteRefs(); + console.log(' βœ“ route-render.js threads ph-play / ph-flag sprite refs'); + } catch (e) { + console.error(` βœ— ${e.message}`); + failed++; + } + + for (const rel of M4_FILES) { + const hits = scanFile(rel); + if (hits.length === 0) { + console.log(` βœ“ ${rel} clean`); + } 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.text}`); + if (hits.length > 30) console.error(` … (+${hits.length - 30} more)`); + failed++; + } + const useRefs = countUseRefs(rel); + const min = MIN_USE_REFS[rel] || 0; + if (useRefs < min) { + console.error(` βœ— ${rel} has only ${useRefs} refs (expected β‰₯${min})`); + failed++; + } else { + console.log(` βœ“ ${rel} has ${useRefs} Phosphor refs (β‰₯${min})`); + } + } + + if (failed) { + console.error(`\nFAIL: ${failed} M4 check(s) failed`); + process.exit(1); + } + // Hard asserts for CI + for (const rel of M4_FILES) { + const hits = scanFile(rel); + assert.strictEqual(hits.length, 0, + `${rel} must contain zero emoji/misc-icon iconography (got ${hits.length} hit(s))`); + const useRefs = countUseRefs(rel); + const min = MIN_USE_REFS[rel] || 0; + assert.ok(useRefs >= min, + `${rel} must have β‰₯${min} refs (got ${useRefs})`); + } + console.log('\nPASS: all M4 surfaces icon-free and Phosphor-swapped'); +} + +main(); diff --git a/test-issue-1648-m4-icons-e2e.js b/test-issue-1648-m4-icons-e2e.js new file mode 100644 index 00000000..4d2a4ae9 --- /dev/null +++ b/test-issue-1648-m4-icons-e2e.js @@ -0,0 +1,223 @@ +#!/usr/bin/env node +/* Issue #1648 β€” M4: emoji β†’ Phosphor sprite migration (E2E behavioral). + * + * Asserts (in a real Chromium against a running server): + * (a) /map β€” Path Inspector pane toggle renders a Phosphor sprite (no + * Misc-Symbols caret), and overall map page is icon-free. + * (b) /map?packet=&obs= (multi-path CHAN fixture) β€” route + * overlay sidebar collapse button renders Phosphor caret. + * (c) /packets β€” replay button renders Phosphor play sprite (no β–Ά char). + * (d) /analytics β€” distance map-jump buttons render ph-map-trifold and + * the Network Overview chevron renders a sprite. + * (e) /area-map standalone β€” clear/undo buttons render sprites. + * (f) NO .notdef anywhere β€” every resolves to a defined symbol id. + * + * 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'; +// Forbidden Misc-Symbols icon chars in rendered M4 surfaces: +const M4_FORBIDDEN_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 gotoSpa(page, route) { + await page.goto(`${BASE}/#${route}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => !!document.querySelector('#app'), null, { timeout: 8000 }).catch(() => {}); + await page.waitForTimeout(700); +} + +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-m4-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) /map β€” pane toggle + general sprite presence + await gotoSpa(page, '/map'); + const mapState = await page.evaluate(() => { + const toggle = document.getElementById('mapPaneToggle'); + return { + paneToggleText: toggle ? toggle.textContent : null, + paneToggleSprites: toggle ? toggle.querySelectorAll('svg.ph-icon use').length : 0, + allSprites: document.querySelectorAll('svg.ph-icon use').length, + bodyText: (document.getElementById('app') || document.body).textContent || '', + }; + }); + if (mapState.paneToggleSprites === 0) fail('(a) /map: pane toggle has no Phosphor sprite'); + else pass(`(a) /map: pane toggle has ${mapState.paneToggleSprites} sprite(s)`); + if (mapState.paneToggleText && /[β–Άβ—€]/.test(mapState.paneToggleText)) { + fail(`(a) /map: pane toggle still contains β–Ά/β—€ text (got ${JSON.stringify(mapState.paneToggleText)})`); + } else { + pass('(a) /map: pane toggle text has no Misc-Symbols carets'); + } + if (mapState.allSprites < 3) fail(`(a) /map: only ${mapState.allSprites} sprite refs total (expected β‰₯3)`); + else pass(`(a) /map: ${mapState.allSprites} sprite refs on page`); + + // (b) /map?packet=&obs= β€” multi-path CHAN fixture renders + // route overlay sidebar with a collapse button. We don't require the + // fixture to exist in staging β€” only assert the sprite scheme if the + // sidebar shows up. + await gotoSpa(page, '/map?packet=305b678c9394b964&obs=10591318'); + await page.waitForTimeout(1200); + const route = await page.evaluate(() => { + const sidebar = document.querySelector('.mc-rt-sidebar, [class*="mc-rt-"]'); + const collapseBtn = document.querySelector('.mc-rt-collapse-btn'); + return { + sidebarPresent: !!sidebar, + collapseSprites: collapseBtn ? collapseBtn.querySelectorAll('svg.ph-icon use').length : 0, + collapseText: collapseBtn ? collapseBtn.textContent : '', + mapSprites: document.querySelectorAll('svg.ph-icon use').length, + }; + }); + if (route.sidebarPresent && route.collapseSprites === 0) { + fail('(b) /map?packet=…: route collapse button has no sprite'); + } else if (route.sidebarPresent) { + pass(`(b) /map?packet=…: route collapse button has ${route.collapseSprites} sprite(s)`); + } else { + console.warn(' ⚠ (b) /map?packet=…: route sidebar not present (fixture may be missing in this env)'); + } + if (route.collapseText && /[β–Άβ—€]/.test(route.collapseText)) { + fail('(b) /map?packet=…: collapse btn still has β–Ά/β—€ char'); + } + + // (c) /packets β€” replay button renders Phosphor play sprite + await gotoSpa(page, '/packets'); + await page.waitForTimeout(1500); + // Click first packet row to render detail with replay button (if any). + await page.evaluate(() => { + const row = document.querySelector('table tbody tr, .pkt-row, .packet-row'); + if (row) row.click(); + }); + await page.waitForTimeout(800); + const packets = await page.evaluate(() => { + const replay = document.querySelector('.replay-live-btn'); + const viewRoute = document.querySelector('#viewRouteBtn, .detail-map-link'); + return { + replaySprite: replay ? replay.querySelectorAll('svg.ph-icon use').length : null, + replayText: replay ? replay.textContent : null, + viewRouteSprites: viewRoute ? viewRoute.querySelectorAll('svg.ph-icon use').length : null, + pageSprites: document.querySelectorAll('svg.ph-icon use').length, + }; + }); + if (packets.replaySprite === 0) { + fail('(c) /packets: replay button has no sprite'); + } else if (packets.replaySprite > 0) { + pass(`(c) /packets: replay button has ${packets.replaySprite} sprite(s)`); + } else { + console.warn(' ⚠ (c) /packets: replay button not present (no packet detail open)'); + } + if (packets.replayText && /[▢◀⏸]/.test(packets.replayText)) { + fail(`(c) /packets: replay button still has play/pause char (${JSON.stringify(packets.replayText)})`); + } + if (packets.viewRouteSprites === 0) fail('(c) /packets: View-route button missing sprite'); + else if (packets.viewRouteSprites > 0) pass('(c) /packets: View-route button has sprite'); + if (packets.pageSprites < 5) fail(`(c) /packets: only ${packets.pageSprites} sprite refs (expected β‰₯5)`); + else pass(`(c) /packets: ${packets.pageSprites} sprite refs on page`); + + // (d) /analytics β€” Network Overview chevron + distance map-jump buttons + await gotoSpa(page, '/analytics?tab=prefix-tool'); + await page.waitForTimeout(1500); + const analytics = await page.evaluate(() => { + const chev = document.getElementById('ptOverviewChevron'); + return { + chevText: chev ? chev.textContent : null, + chevSprites: chev ? chev.querySelectorAll('svg.ph-icon use').length : 0, + pageSprites: document.querySelectorAll('svg.ph-icon use').length, + }; + }); + if (analytics.chevSprites === 0) { + fail('(d) /analytics: Network Overview chevron has no sprite'); + } else { + pass(`(d) /analytics: Network Overview chevron has ${analytics.chevSprites} sprite(s)`); + } + if (analytics.chevText && /[β–Άβ—€]/.test(analytics.chevText)) { + fail('(d) /analytics: chevron still contains β–Ά text'); + } + if (analytics.pageSprites < 10) fail(`(d) /analytics: only ${analytics.pageSprites} sprite refs`); + else pass(`(d) /analytics: ${analytics.pageSprites} sprite refs on page`); + + // Also check distance tab if reachable + await gotoSpa(page, '/analytics?tab=distance'); + await page.waitForTimeout(2000); + const dist = await page.evaluate(() => { + const btn = document.querySelector('.dist-map-hop, .dist-map-path'); + return { + mapBtnSprites: btn ? btn.querySelectorAll('svg.ph-icon use').length : null, + }; + }); + if (dist.mapBtnSprites === 0) fail('(d) /analytics distance: map-jump button missing sprite'); + else if (dist.mapBtnSprites > 0) pass('(d) /analytics distance: map-jump button has sprite'); + else console.warn(' ⚠ (d) /analytics distance: no map-jump button rendered (empty dataset)'); + + // (e) /area-map (standalone HTML) + await page.goto(`${BASE}/area-map.html`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(500); + const area = await page.evaluate(() => { + const clear = document.getElementById('btn-clear-draw'); + const undo = document.getElementById('btn-undo'); + return { + clearSprites: clear ? clear.querySelectorAll('svg.ph-icon use').length : 0, + clearText: clear ? clear.textContent : '', + undoSprites: undo ? undo.querySelectorAll('svg.ph-icon use').length : 0, + }; + }); + if (area.clearSprites === 0) fail('(e) /area-map: clear button has no sprite'); + else pass(`(e) /area-map: clear button has ${area.clearSprites} sprite(s)`); + if (area.undoSprites === 0) fail('(e) /area-map: undo button has no sprite'); + else pass(`(e) /area-map: undo button has ${area.undoSprites} sprite(s)`); + if (/[βœ•β†©]/.test(area.clearText)) fail('(e) /area-map: clear button still has βœ• char'); + + // (f) No unresolved sprite refs anywhere we've visited + await gotoSpa(page, '/map'); + 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) unresolved: ${undef.missing.slice(0,5).join(', ')}`); + else pass(`(f) all ${undef.count} sprite refs resolve to one of ${undef.ids} defined symbols`); + + await browser.close(); + console.log(`\ntest-issue-1648-m4-icons-e2e.js: ${passes} passed, ${failures} failed`); + assert.strictEqual(failures, 0, `${failures} M4 icon-render assertions failed`); + process.exit(0); +} + +main().catch((err) => { + console.error('test-issue-1648-m4-icons-e2e.js: FAIL β€”', err); + process.exit(1); +});