feat(mobile): packets UX overhaul + nav surface + map inset + channel synthesis fixes (#1471)

## Summary

Mobile UX overhaul for the packets surface plus two discoverable defects
found along the way. All UI changes are mobile-only (`@media (max-width:
900px)` or `isMobile()` gates) — desktop unchanged.

## Closes
- #1415 — packets layout cross-viewport jank
- #1458 — Tufte mobile packets critique (P0s)
- #1461 — Tufte v2 mobile packets critique (P0/P1)
- #1467 — Favorites/Search/Customize unreachable on mobile
- #1468 — client-side "unknown" channel synthesis
- #1470 — node-detail map inset doesn't honor customizer dark provider

## Commits

1. `fix(#1468): drop client-side "unknown" channel synthesis` —
`channels.js`
2. `feat(#1470): node-detail map inset honors customizer dark-tile
provider` — `nodes.js`, `roles.js`
3. `feat(mobile): packets UX overhaul + bottom-nav More controls (#1415,
#1458, #1461, #1467)` — `style.css`, `index.html`,
`mobile-page-actions.js` (new)

## Mobile-list view changes
- Kill empty chevron rail
- Slim sticky THEAD (24px, retains sort affordance per operator
preference)
- Hide entire page-header on mobile
- Mirror pause + Filters pill into navbar via new
`mobile-page-actions.js`
- Convert group-header `toggle-select` → `select-hash` on mobile (no
dead-end expand)

## Mobile detail-panel changes
- Drop redundant src→dst line (identity already in sticky header)
- Hide boxed "decoded message" duplication card
- Hide PAYLOAD TYPE row (already in header badge)
- 2-col label/value grid (cuts panel height ~40%)
- Sticky in-sheet header for packet identity
- Kill iOS-style drag handle (conflicts with browser pull-to-refresh)
- Make ✕ close visible + always reachable
- Outer sheet `overflow:hidden`, inner content `overflow-y:auto`
(scrollable region distinct, scrollbar visible)
- Bottom-nav clearance (`padding-bottom: 60px`)
- Close detail sheet on route change away from /packets
- Tap-to-toast popovers for score tooltips (`title=` doesn't fire on
touch)

## Mobile nav surface
- Mirror Favorites ⭐ / Search 🔍 / Customize 🎨 into bottom-nav More sheet
(#1467)
- Brand stays in top nav; per-page controls (pause, Filters) injected
into `.nav-left`

## Other fixes shipped together
- **#1468**: drop CHAN messages with no decoded channel name (eliminates
fake "unknown" channel row)
- **#1470**: `_applyTilesToNodeMap` helper — node-detail inset map reads
from `MC_TILE_PROVIDERS[active]` instead of hardcoded OSM; honors
customizer's dark-tile provider pick + applies invert filter for
inverted variants
- `getTileUrl()` + new `getActiveTileProvider()` in `roles.js` now
consult `MC_TILE_PROVIDERS`

## CDP verification (local chromium)

Tested on staging at viewport 390×844 + 1206×928.

| Surface | Before | After |
|---|---|---|
| Chrome above first data row | 231px (27% viewport) | ~80px (10%
viewport) |
| Packets visible above fold | 10 | 17 |
| Detail panel duplications | 3× identity | 1× (header only) |
| Mobile group-expand UX | dead-end (no chevron) | converts to
select-hash |
| Score tooltips on touch | broken (title= silent) | tap → toast popover
|
| Node detail map inset (dark mode) | always OSM light tiles | honors
customizer provider + invert filter |
| Bottom-nav More controls | Dark mode only | + Favorites, Search,
Customize |

## What's NOT in this PR
- Paths-through-node sort fix lives in #1431 (parallel PR for #1145)
- Detail-panel hex byte-grid behind disclosure — operator wants it;
follow-up
- Group-header row sizing (some render 200–700px tall) — existing
behavior, follow-up

## Test plan
- [ ] Existing frontend tests stay green
(`test-issue-1415-packets-layout.js`,
`test-issue-1420-tile-providers.js`,
`test-issue-1454-channels-toggle.js` all pass locally on this branch)
- [ ] Existing Playwright E2E stays green
- [ ] CDP on local chromium: 390×844 mobile + 1024×768 tablet + 1440×900
desktop — no regressions

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
This commit is contained in:
Kpa-clawbotandopenclaw-bot authored and GitHub committed 2026-05-28 16:11:25 -07:00
1 parent fe997fefb2
commit d964c27964
10 files changed
+1079 -4

No files matched your search

+7 -1
View File
@@ -1332,7 +1332,13 @@
var payload = m.data?.decoded?.payload;
if (!payload) continue;
var channelName = payload.channel || 'unknown';
// #1468: drop CHAN messages with no decoded channel name instead of
// synthesizing a literal "unknown" row that renders as a fake channel
// in the sidebar. Server-side (#1373/#1377) already filters these from
// /api/channels; the live WebSocket router was the remaining offender.
if (!payload.channel) continue;
var channelName = payload.channel;
// For live-decrypted user-added (PSK) channels, decryptLivePSKBatch
// also stamps payload.channelKey ("user:<name>") so we route the
// message to the correct sidebar row and to the open chat view.
+1
View File
@@ -170,5 +170,6 @@
<script src="compare.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="mobile-page-actions.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+194
View File
@@ -0,0 +1,194 @@
/* #1461 mobile page-actions: mirror per-page header buttons (pause, filter
* toggle) into the top nav's .nav-right area on mobile so the page-header
* row can be hidden entirely. On desktop this script is a no-op. */
(function () {
'use strict';
const MOBILE_BP = 600;
const SLOT_ID = 'navPageActions';
function isMobile() { return window.innerWidth <= MOBILE_BP; }
function ensureSlot() {
let slot = document.getElementById(SLOT_ID);
if (slot) return slot;
// On mobile, .nav-right is display:none — use .nav-left so the slot is
// visible. Append after the brand link.
const navLeft = document.querySelector('.nav-left');
if (!navLeft) return null;
slot = document.createElement('div');
slot.id = SLOT_ID;
// Layout-only styles inline; visual tokens (border/colors) come from
// the mobile @media block in style.css so the customizer can theme us.
slot.style.cssText = 'display:inline-flex;gap:4px;align-items:center;margin-left:8px;';
navLeft.appendChild(slot);
return slot;
}
function clearSlot() {
const slot = document.getElementById(SLOT_ID);
if (slot) slot.innerHTML = '';
}
function makeBtn(label, title, onClick) {
const b = document.createElement('button');
b.className = 'nav-btn';
b.type = 'button';
b.title = title;
b.textContent = label;
b.addEventListener('click', onClick);
return b;
}
function syncForRoute() {
// #1461 #6: close mobile detail sheet on route change away from packets
try {
const sheet = document.getElementById('mobileDetailSheet');
if (sheet && !/^#\/packets/.test(location.hash || '')) {
sheet.classList.remove('open');
}
} catch (_e) {}
if (!isMobile()) { clearSlot(); return; }
const hash = location.hash || '';
const slot = ensureSlot();
if (!slot) return;
slot.innerHTML = '';
if (/^#\/packets(\/|$|\?)/.test(hash)) {
// Mirror pause button (icon only — small)
const pause = makeBtn('⏸', 'Pause live updates', function () {
const real = document.getElementById('pktPauseBtn');
if (real) real.click();
});
pause.classList.add('mpa-btn-icon');
slot.appendChild(pause);
// Mirror filter toggle as a labeled "Filters ▾" pill (matches inline style)
const filt = makeBtn('Filters ▾', 'Toggle filters', function () {
const real = document.querySelector('.filter-bar .filter-toggle-btn, #filterToggleBtn');
if (real) real.click();
});
filt.className = 'nav-btn filter-toggle-btn-mirror mpa-btn-pill';
slot.appendChild(filt);
}
}
window.addEventListener('hashchange', syncForRoute);
window.addEventListener('resize', syncForRoute);
/* #1461 #7: on mobile, packets-list group-header expand is a UX dead-end
* (we hid the chevron so there's no way to collapse). Intercept those
* clicks and force them to the single-select code path instead — the
* detail pane has all the obs info anyway. */
document.addEventListener('click', function (e) {
if (!isMobile()) return;
const row = e.target.closest && e.target.closest('#pktTable tr[data-action="toggle-select"]');
if (!row) return;
// Convert to a select-hash event by re-dispatching synthetically — simpler
// to mutate the attribute briefly so the existing delegated handler
// routes it correctly.
row.setAttribute('data-action', 'select-hash');
}, true);
/* #1461 #8: traffic_share_score / bridge_score tooltips use title= which
* doesn't fire on touch. Show a click-to-toast popover on mobile when
* operator taps a TD whose title mentions traffic/bridge/centrality. */
document.addEventListener('click', function (e) {
if (!isMobile()) return;
const el = e.target.closest('[title]');
if (!el) return;
const text = el.getAttribute('title');
if (!text) return;
// Limit to score / metric explanations to avoid spamming on every titled element
if (!/traffic share|bridge|centrality|score|usefulness/i.test(text + ' ' + el.textContent)) return;
e.preventDefault();
e.stopPropagation();
showToast(text);
}, true);
function showToast(msg) {
let t = document.getElementById('mcMobileToast');
if (!t) {
t = document.createElement('div');
t.id = 'mcMobileToast';
t.className = 'mpa-toast';
document.body.appendChild(t);
}
t.textContent = msg;
t.style.opacity = '1';
clearTimeout(t._timer);
t._timer = setTimeout(() => { t.style.opacity = '0'; }, 4000);
}
/* #1467: mirror missing top-nav controls (Favorites, Search, Customize)
* into the bottom-nav More sheet. bottom-nav.js only wired Dark mode;
* the others have no mobile surface today. Insert above the existing
* dark-mode separator so the new items group with the other route items. */
function addMissingMoreSheetItems(retryCount) {
retryCount = retryCount || 0;
const sheet = document.querySelector('[data-bottom-nav-sheet]');
if (!sheet) {
// Bounded retry — bottom-nav.js builds the sheet asynchronously, but
// give up after ~5s so we don't poll forever on pages that don't have
// bottom-nav (e.g. embedded views, headless tests).
if (retryCount < 10) setTimeout(() => addMissingMoreSheetItems(retryCount + 1), 500);
return;
}
if (sheet.querySelector('[data-mpa-mirror]')) return; // already injected
const mirrors = [
{ id: 'favToggle', icon: '⭐', label: 'Favorites' },
{ id: 'searchToggle', icon: '🔍', label: 'Search' },
{ id: 'customizeToggle', icon: '🎨', label: 'Customize' },
];
const sep = sheet.querySelector('.bottom-nav-sheet-sep');
mirrors.forEach((m) => {
const real = document.getElementById(m.id);
if (!real) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bottom-nav-sheet-item';
btn.setAttribute('role', 'menuitem');
btn.setAttribute('data-mpa-mirror', m.id);
const ic = document.createElement('span');
ic.className = 'bottom-nav-sheet-icon';
ic.setAttribute('aria-hidden', 'true');
ic.textContent = m.icon;
const lb = document.createElement('span');
lb.className = 'bottom-nav-sheet-label';
lb.textContent = m.label;
btn.appendChild(ic);
btn.appendChild(lb);
btn.addEventListener('click', function () {
real.click();
// close the sheet after delegating
try { sheet.classList.remove('open'); } catch (_e) {}
});
if (sep) sheet.insertBefore(btn, sep);
else sheet.appendChild(btn);
});
}
// Also re-run when sheet is opened (bottom-nav rebuilds it on open)
document.addEventListener('click', function (e) {
const target = e.target.closest && e.target.closest('[data-bottom-nav-more]');
if (target) setTimeout(addMissingMoreSheetItems, 50);
}, true);
// Run after page-header is rendered (packets.js builds it async); retry briefly
let tries = 0;
function init() {
syncForRoute();
addMissingMoreSheetItems();
if (tries++ < 20 && /^#\/packets/.test(location.hash) && !document.getElementById('pktPauseBtn')) {
setTimeout(init, 250);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+25 -2
View File
@@ -74,6 +74,29 @@
let wsHandler = null;
let detailMap = null;
// #1461 followup: node-detail inset map tile layer that honors the
// customizer dark-tile-provider pick (#1420/#1430). Falls back to
// window.getTileUrl() output if the registry isn't loaded. Also applies
// the provider's invert CSS filter to the tile pane when needed.
function _applyTilesToNodeMap(map) {
if (!map) return;
var tileUrl = (window.getTileUrl && window.getTileUrl()) || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
var provider = window.getActiveTileProvider && window.getActiveTileProvider();
var attribution = (provider && provider.attribution) || '© OpenStreetMap contributors';
var layer = L.tileLayer(tileUrl, { maxZoom: 18, attribution: attribution }).addTo(map);
// Esri 2-layer provider: add the labels reference overlay too
if (provider && provider.refUrl) {
try { L.tileLayer(provider.refUrl, { maxZoom: 18 }).addTo(map); } catch (_e) {}
}
// Apply invert CSS filter to the tile pane if the provider needs it
try {
var pane = map.getPane && map.getPane('tilePane');
if (pane) pane.style.filter = (provider && provider.invertFilter) ? provider.invertFilter : '';
} catch (_e) {}
return layer;
}
// ROLE_COLORS loaded from shared roles.js
const TABS = [
{ key: 'all', label: 'All' },
@@ -676,7 +699,7 @@
try {
if (detailMap) { detailMap.remove(); detailMap = null; }
detailMap = L.map('nodeFullMap', { zoomControl: true, attributionControl: false }).setView([n.lat, n.lon], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(detailMap);
_applyTilesToNodeMap(detailMap);
L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(n.name || n.public_key.slice(0, 12));
setTimeout(() => detailMap.invalidateSize(), 100);
} catch {}
@@ -1524,7 +1547,7 @@
try {
if (detailMap) { detailMap.remove(); detailMap = null; }
detailMap = L.map('nodeMap', { zoomControl: false, attributionControl: false }).setView([n.lat, n.lon], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(detailMap);
_applyTilesToNodeMap(detailMap);
L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(n.name || n.public_key.slice(0, 12));
setTimeout(() => detailMap.invalidateSize(), 100);
} catch {}
+29 -1
View File
@@ -363,7 +363,35 @@
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
return isDark ? TILE_DARK : TILE_LIGHT;
if (!isDark) return TILE_LIGHT;
// #1461 followup: honor customizer's dark-tile-provider pick (#1420 / #1430)
// when the registry is loaded. Falls back to TILE_DARK if absent.
try {
if (window.MC_getDarkTileProvider && window.MC_TILE_PROVIDERS) {
var id = window.MC_getDarkTileProvider();
var p = window.MC_TILE_PROVIDERS[id];
if (p && (p.url || p.baseUrl)) {
return p.url || p.baseUrl;
}
}
} catch (_e) {}
return TILE_DARK;
};
/* Helper: get the full provider object (for callers that also need the
* invertFilter or refUrl/attribution). Returns null when no customizer
* provider applies (light mode, or registry not loaded). */
window.getActiveTileProvider = function () {
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (!isDark) return null;
try {
if (window.MC_getDarkTileProvider && window.MC_TILE_PROVIDERS) {
var id = window.MC_getDarkTileProvider();
return window.MC_TILE_PROVIDERS[id] || null;
}
} catch (_e) {}
return null;
};
// ─── SNR thresholds ───
+159
View File
@@ -879,6 +879,165 @@ img.brand-logo {
width: 32px; min-width: 32px; max-width: 32px;
padding: 3px 4px; text-align: center;
}
/* #1415 / #1458 / #1461 — mobile packets UX overhaul.
* NOTE: hiding the first dt/dd pair below assumes Payload Type is the
* first <dt> in .detail-meta (per packets.js renderDetail). Keep that
* invariant — test-issue-1415-packets-layout.js asserts it. */
@media (max-width: 600px) {
/* #1: kill empty chevron rail */
#pktTable th.col-expand,
#pktTable td.col-expand { display: none !important; }
/* #1b: slim THEAD ~24px sticky */
#pktTable thead th {
padding: 3px 6px !important;
font-size: 9px !important;
line-height: 1.2 !important;
letter-spacing: 0.04em !important;
text-transform: uppercase;
height: 24px !important;
}
#pktTable thead { position: sticky; top: 0; z-index: 5; background: var(--surface-1, #1a1a1a); }
/* #2: hide page-header row + filter-expr.
* pause + Filters pill are mirrored into .nav-left by mobile-page-actions.js.
* NOTE: do NOT display:none the entire .filter-bar — the original
* .filter-toggle-btn lives inside it and must remain clickable for the
* #534 E2E test (and for any user whose navbar mirror failed to inject). */
#pktLeft .page-header { display: none !important; }
#pktLeft .pkt-filter-expr { display: none !important; }
/* Navbar-mirror buttons injected by mobile-page-actions.js. Visual tokens
* (border, colors) live here so the customizer can theme them and so we
* don't ship them as inline cssText strings. */
.mpa-btn-icon {
padding: 4px 6px;
font-size: 14px;
background: transparent;
border: 1px solid var(--border, #444);
border-radius: 6px;
color: var(--nav-text, #fff);
cursor: pointer;
}
.mpa-btn-pill {
padding: 4px 10px;
font-size: 12px;
background: transparent;
border: 1px solid var(--border, #444);
border-radius: 999px;
color: var(--nav-text, #fff);
cursor: pointer;
font-weight: 500;
}
.mpa-toast {
position: fixed;
left: 50%;
bottom: 90px;
transform: translateX(-50%);
background: var(--surface-2, #222);
color: var(--text, #fff);
padding: 10px 16px;
border: 1px solid var(--border, #444);
border-radius: 8px;
font-size: 13px;
max-width: 80vw;
z-index: 9999;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
transition: opacity .3s;
}
.filter-bar.filters-expanded {
padding: 6px 8px !important;
margin-bottom: 4px !important;
background: var(--surface-2, rgba(0,0,0,0.05)) !important;
border: 1px solid var(--border, #333) !important;
border-radius: 6px !important;
height: auto;
}
/* #3: detail panel cleanup */
/* Strip redundant src→dst line (identity in sticky header) */
.mobile-detail-sheet .detail-srcdst { display: none !important; }
/* Hide boxed "decoded message" card on mobile — content already in
* the sticky header's semantic summary */
.mobile-detail-sheet .detail-message { display: none !important; }
/* Hide PAYLOAD TYPE row (first dt+dd pair — already in header badge) */
.mobile-detail-sheet dl.detail-meta > dt:first-child,
.mobile-detail-sheet dl.detail-meta > dt:first-child + dd { display: none !important; }
/* 2-col grid for label/value rows */
.mobile-detail-sheet dl.detail-meta {
display: grid;
grid-template-columns: minmax(90px, 30%) 1fr;
gap: 4px 10px;
margin: 8px 0 !important;
}
.mobile-detail-sheet dl.detail-meta dt {
margin: 0 !important;
padding: 0 !important;
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
align-self: center;
}
.mobile-detail-sheet dl.detail-meta dd {
margin: 0 !important;
padding: 0 !important;
font-size: 13px;
}
/* #4: sheet structure — kill handle, sticky header inside sheet, clear
* bottom-nav (58px tall) so last row doesn't get cut off, taller max */
/* Restructure: outer sheet has fixed height + overflow:hidden; the
* .mobile-sheet-content is the ONLY scroll region. ✕ close stays sticky
* pinned at top of the sheet. Title also stays sticky within the
* scroll region. */
.mobile-detail-sheet {
max-height: calc(100vh - 110px) !important; /* leave room for bottom nav */
padding: 0 !important;
overflow: hidden !important;
display: none;
flex-direction: column;
}
.mobile-detail-sheet.open { display: flex !important; }
.mobile-sheet-handle { display: none !important; } /* conflicts with browser pull-to-refresh */
.mobile-sheet-close {
position: absolute !important;
top: 6px !important;
right: 10px !important;
background: var(--surface-2, rgba(0,0,0,0.6)) !important;
border-radius: 999px !important;
width: 32px !important;
height: 32px !important;
display: flex !important;
align-items: center;
justify-content: center;
font-size: 16px !important;
z-index: 20 !important;
border: 1px solid var(--border, #444) !important;
}
.mobile-sheet-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 12px 12px 60px !important;
/* visible scrollbar so operator sees there's more */
scrollbar-width: thin;
}
.mobile-sheet-content::-webkit-scrollbar { width: 6px; }
.mobile-sheet-content::-webkit-scrollbar-thumb { background: var(--border, #555); border-radius: 3px; }
/* Sticky in-sheet header for packet identity */
.mobile-detail-sheet .detail-title {
position: sticky;
top: 0;
background: var(--detail-bg);
z-index: 5;
padding: 6px 36px 8px 0;
margin: 0 0 8px 0;
border-bottom: 1px solid var(--border);
}
}
.data-table td:has(.spark-bar), .data-table td.col-spark { max-width: none; overflow: visible; min-width: 80px; }
.data-table .col-time { min-width: clamp(72px, 8vw, 108px); white-space: nowrap; }
+4
View File
@@ -45,6 +45,10 @@ node test-issue-1450-logo-aspect.js
node test-issue-1454-channels-toggle.js
node test-issue-1456-score-labels.js
# #1461 mobile UX overhaul + #1470 node-detail tile helper (#1468 covered by E2E)
node test-issue-1461-mobile-page-actions.js
node test-issue-1470-node-tile-helper.js
echo ""
echo "═══════════════════════════════════════"
echo " All tests passed"
+83
View File
@@ -2071,6 +2071,89 @@ async function run() {
// ─── End mobile filter tests ──────────────────────────────────────────────
// ─── #1468 — drop client-side "unknown" channel synthesis ────────────────
await test('#1468: live WS CHAN message with no payload.channel is dropped (no "unknown" bucket)', async () => {
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded' });
// Wait for the channels init() to mount and expose the test hook.
await page.waitForFunction(() => typeof window._channelsProcessWSBatchForTest === 'function', { timeout: 10000 });
// Snapshot starting state so we can compare deltas.
const before = await page.evaluate(() => {
const s = window._channelsGetStateForTest();
return { count: s.channels.length, names: s.channels.map(c => c.name || c.channel || '') };
});
// Feed a CHAN-like message with NO payload.channel field (but valid hash).
await page.evaluate(() => {
window._channelsProcessWSBatchForTest([
{
type: 'packet',
data: {
hash: 'test1468drophash' + Date.now(),
decoded: {
header: { payloadTypeName: 'GRP_TXT' },
payload: { /* no `channel` */ text: 'orphan: hello' },
},
},
},
], null);
});
const after = await page.evaluate(() => {
const s = window._channelsGetStateForTest();
return { count: s.channels.length, names: s.channels.map(c => c.name || c.channel || '') };
});
// No "unknown" channel materialized.
assert(!after.names.includes('unknown'),
'channels list does not contain a synthesized "unknown" entry — got ' + JSON.stringify(after.names));
// And the channel-count delta is 0 — the orphan message was dropped, not bucketed.
assert(after.count === before.count,
`channel count unchanged after orphan WS msg — before=${before.count}, after=${after.count}`);
});
await test('#1468 control: same WS message WITH payload.channel is still routed', async () => {
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window._channelsProcessWSBatchForTest === 'function', { timeout: 10000 });
const sentinel = '__test_chan_1468_' + Date.now();
const before = await page.evaluate((name) => {
const s = window._channelsGetStateForTest();
return { hasSentinel: s.channels.some(c => (c.name || c.channel) === name) };
}, sentinel);
assert(!before.hasSentinel, 'pre: sentinel channel does not pre-exist');
await page.evaluate((name) => {
window._channelsProcessWSBatchForTest([
{
type: 'packet',
data: {
hash: 'test1468hash' + Date.now(),
decoded: {
header: { payloadTypeName: 'GRP_TXT' },
payload: { channel: name, text: 'alice: hi', sender: 'alice' },
},
},
},
], null);
}, sentinel);
const after = await page.evaluate((name) => {
const s = window._channelsGetStateForTest();
return {
hasSentinel: s.channels.some(c => (c.name || c.channel) === name),
names: s.channels.map(c => c.name || c.channel || ''),
};
}, sentinel);
assert(after.hasSentinel,
'control: channel WITH payload.channel IS routed into the registry — got ' + JSON.stringify(after.names));
});
// ─── End #1468 tests ──────────────────────────────────────────────────────
// Extract frontend coverage if instrumented server is running
try {
const coverage = await page.evaluate(() => window.__coverage__);
+351
View File
@@ -0,0 +1,351 @@
/* test-issue-1461-mobile-page-actions.js — behavioral test for the mobile
* page-actions wiring shipped in #1471. Loads mobile-page-actions.js into
* a vm sandbox with a minimal DOM mock (no jsdom dep), then exercises the
* public observable surfaces:
*
* 1. On mobile + /#/packets, hashchange synthesizes a "Filters ▾" button
* and a "⏸" button under .nav-left, and clicking them delegates to the
* real #filterToggleBtn / #pktPauseBtn elements.
* 2. The bottom-nav More sheet receives 3 injected mirrors (Favorites /
* Search / Customize) when the sheet element is present.
* 3. On desktop viewport, no buttons are injected.
* 4. Idempotent: re-init does not duplicate mirrors.
* 5. Route change off /packets clears the slot AND closes the detail sheet.
*
* No source-grep. All assertions are on real function side effects.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(' ✅ ' + name); }
catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
}
// ── Minimal Element / Document shim — no jsdom dep ────────────────────────
function makeElement(tag) {
const el = {
tagName: String(tag || 'div').toUpperCase(),
children: [],
parentNode: null,
id: '',
className: '',
type: '',
title: '',
textContent: '',
_attrs: {},
_listeners: {},
_clickCount: 0,
};
el.style = { _cssText: '', get cssText() { return this._cssText; }, set cssText(v) { this._cssText = v; } };
el.setAttribute = function (k, v) {
this._attrs[k] = String(v);
if (k === 'class') this.className = String(v);
if (k === 'id') this.id = String(v);
};
el.getAttribute = function (k) { return Object.prototype.hasOwnProperty.call(this._attrs, k) ? this._attrs[k] : null; };
el.appendChild = function (child) { child.parentNode = this; this.children.push(child); return child; };
el.insertBefore = function (child, ref) {
child.parentNode = this;
const idx = this.children.indexOf(ref);
if (idx < 0) this.children.push(child);
else this.children.splice(idx, 0, child);
return child;
};
el.removeChild = function (child) {
const idx = this.children.indexOf(child);
if (idx >= 0) this.children.splice(idx, 1);
child.parentNode = null;
return child;
};
el.addEventListener = function (type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn); };
el.dispatchEvent = function (ev) { (this._listeners[ev.type] || []).forEach(fn => fn(ev)); };
el.click = function () {
this._clickCount++;
this.dispatchEvent({ type: 'click', target: this, preventDefault() {}, stopPropagation() {} });
};
el.closest = function (sel) {
let cur = this;
while (cur) {
if (_elementMatches(cur, sel)) return cur;
cur = cur.parentNode;
}
return null;
};
el.querySelector = function (sel) { return _walk(this, sel, true)[0] || null; };
el.querySelectorAll = function (sel) { return _walk(this, sel, false); };
el.classList = {
add(c) { const s = new Set(String(el.className).split(/\s+/).filter(Boolean)); s.add(c); el.className = Array.from(s).join(' '); },
remove(c) { const s = new Set(String(el.className).split(/\s+/).filter(Boolean)); s.delete(c); el.className = Array.from(s).join(' '); },
contains(c) { return new Set(String(el.className).split(/\s+/).filter(Boolean)).has(c); },
};
Object.defineProperty(el, 'innerHTML', {
configurable: true,
get() { return ''; },
set(v) {
if (v === '' || v == null) {
el.children.forEach(c => { c.parentNode = null; });
el.children = [];
}
},
});
return el;
}
function _elementMatches(el, sel) {
if (!el || !sel) return false;
sel = String(sel).trim();
// Comma list: any selector matches
if (sel.indexOf(',') >= 0) {
return sel.split(',').some(s => _elementMatches(el, s.trim()));
}
// Descendant combinator
if (sel.indexOf(' ') >= 0) {
const parts = sel.split(/\s+/);
const last = parts[parts.length - 1];
if (!_elementMatches(el, last)) return false;
let cur = el.parentNode;
for (let i = parts.length - 2; i >= 0; i--) {
let found = false;
while (cur) {
if (_elementMatches(cur, parts[i])) { found = true; cur = cur.parentNode; break; }
cur = cur.parentNode;
}
if (!found) return false;
}
return true;
}
// ID
if (sel.startsWith('#')) return el.id === sel.slice(1);
// Attribute [data-X] or [data-X="v"]
const attrMatch = sel.match(/^\[([a-zA-Z0-9-]+)(?:="([^"]+)")?\]$/);
if (attrMatch) {
const k = attrMatch[1], v = attrMatch[2];
if (!Object.prototype.hasOwnProperty.call(el._attrs, k)) return false;
return v == null ? true : el._attrs[k] === v;
}
// .class
if (sel.startsWith('.')) {
return el.classList && el.classList.contains(sel.slice(1));
}
// tag.class
if (sel.indexOf('.') > 0) {
const [tag, ...cls] = sel.split('.');
if (el.tagName.toLowerCase() !== tag.toLowerCase()) return false;
return cls.every(c => el.classList && el.classList.contains(c));
}
// Bare tag
return el.tagName.toLowerCase() === sel.toLowerCase();
}
function _walk(root, sel, firstOnly) {
const out = [];
function visit(n) {
if (n !== root && _elementMatches(n, sel)) {
out.push(n);
if (firstOnly) return true;
}
for (const c of (n.children || [])) {
if (visit(c) && firstOnly) return true;
}
return false;
}
visit(root);
return out;
}
function makeSandbox(opts) {
opts = opts || {};
const docElements = {};
const docRoot = makeElement('html');
const docBody = makeElement('body');
docRoot.appendChild(docBody);
const documentListeners = {};
const windowListeners = {};
const documentMock = {
documentElement: docRoot,
body: docBody,
createElement(tag) { return makeElement(tag); },
getElementById(id) {
if (docElements[id]) return docElements[id];
function find(n) {
if (n.id === id) return n;
for (const c of (n.children || [])) { const r = find(c); if (r) return r; }
return null;
}
return find(docRoot);
},
querySelector(sel) { return docRoot.querySelector(sel) || docBody.querySelector(sel); },
querySelectorAll(sel) { return docRoot.querySelectorAll(sel).concat(docBody.querySelectorAll(sel)); },
addEventListener(type, fn) { (documentListeners[type] = documentListeners[type] || []).push(fn); },
dispatchEvent(ev) { (documentListeners[ev.type] || []).forEach(fn => fn(ev)); return true; },
readyState: 'complete',
};
const ctx = {
console,
setTimeout, clearTimeout,
JSON, Date, Math, Object, Array, String, Number, Boolean, Set, Map,
document: documentMock,
window: {
innerWidth: opts.innerWidth || 390,
addEventListener(type, fn) { (windowListeners[type] = windowListeners[type] || []).push(fn); },
dispatchEvent(ev) { (windowListeners[ev.type] || []).forEach(fn => fn(ev)); return true; },
},
location: { hash: opts.hash || '#/packets' },
};
ctx.globalThis = ctx;
ctx.window.document = documentMock;
ctx.window.location = ctx.location;
vm.createContext(ctx);
ctx._docRoot = docRoot;
ctx._docBody = docBody;
ctx._documentListeners = documentListeners;
ctx._windowListeners = windowListeners;
ctx._registerElement = (el) => { if (el.id) docElements[el.id] = el; };
return ctx;
}
function loadMPA(ctx) {
const src = fs.readFileSync(path.join(__dirname, 'public', 'mobile-page-actions.js'), 'utf8');
vm.runInContext(src, ctx);
}
function buildMobileDOM(ctx, opts) {
opts = opts || {};
const body = ctx._docBody;
const nav = makeElement('div');
nav.className = 'nav-left';
body.appendChild(nav);
const realFilter = makeElement('button');
realFilter.id = 'filterToggleBtn';
realFilter.setAttribute('class', 'filter-toggle-btn');
body.appendChild(realFilter);
ctx._registerElement(realFilter);
const realPause = makeElement('button');
realPause.id = 'pktPauseBtn';
body.appendChild(realPause);
ctx._registerElement(realPause);
if (opts.bottomNav !== false) {
const sheet = makeElement('div');
sheet.setAttribute('data-bottom-nav-sheet', '');
body.appendChild(sheet);
['favToggle', 'searchToggle', 'customizeToggle'].forEach(id => {
const btn = makeElement('button');
btn.id = id;
body.appendChild(btn);
ctx._registerElement(btn);
});
const sep = makeElement('div');
sep.setAttribute('class', 'bottom-nav-sheet-sep');
sheet.appendChild(sep);
}
const detailSheet = makeElement('div');
detailSheet.id = 'mobileDetailSheet';
detailSheet.classList.add('open');
body.appendChild(detailSheet);
ctx._registerElement(detailSheet);
return { nav, realFilter, realPause };
}
console.log('── #1461 mobile-page-actions behavioral tests ──');
test('mobile + /#/packets: hashchange injects ⏸ + Filters ▾ buttons under .nav-left', () => {
const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' });
const { nav, realFilter, realPause } = buildMobileDOM(ctx);
loadMPA(ctx);
(ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' }));
const slot = ctx.document.getElementById('navPageActions');
assert.ok(slot, 'navPageActions slot was created');
assert.strictEqual(slot.parentNode, nav, 'slot is appended under .nav-left');
const btnTexts = slot.children.map(c => c.textContent);
assert.ok(btnTexts.includes('⏸'), 'pause button (⏸) injected — got: ' + JSON.stringify(btnTexts));
assert.ok(btnTexts.some(t => /Filters/.test(t)), 'Filters button injected — got: ' + JSON.stringify(btnTexts));
const pauseBtn = slot.children.find(c => c.textContent === '⏸');
pauseBtn.click();
assert.strictEqual(realPause._clickCount, 1, 'pause-mirror delegates click to #pktPauseBtn');
const filtBtn = slot.children.find(c => /Filters/.test(c.textContent));
filtBtn.click();
assert.strictEqual(realFilter._clickCount, 1, 'Filters-mirror delegates click to #filterToggleBtn');
});
test('mobile: 3 More-sheet mirrors (Favorites/Search/Customize) injected', () => {
const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' });
buildMobileDOM(ctx);
loadMPA(ctx);
const sheet = ctx.document.querySelector('[data-bottom-nav-sheet]');
assert.ok(sheet, 'sheet found');
const mirrors = sheet.querySelectorAll('[data-mpa-mirror]');
assert.strictEqual(mirrors.length, 3, 'exactly 3 mirrors injected — got ' + mirrors.length);
const ids = mirrors.map(m => m.getAttribute('data-mpa-mirror')).sort();
assert.deepStrictEqual(ids, ['customizeToggle', 'favToggle', 'searchToggle']);
const favReal = ctx.document.getElementById('favToggle');
const favMirror = mirrors.find(m => m.getAttribute('data-mpa-mirror') === 'favToggle');
favMirror.click();
assert.strictEqual(favReal._clickCount, 1, 'Favorites mirror delegates click to #favToggle');
});
test('mobile: idempotent — re-init does not duplicate More-sheet mirrors', () => {
const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' });
buildMobileDOM(ctx);
loadMPA(ctx);
(ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' }));
const sheet = ctx.document.querySelector('[data-bottom-nav-sheet]');
const mirrors = sheet.querySelectorAll('[data-mpa-mirror]');
assert.strictEqual(mirrors.length, 3, 'still 3 mirrors after re-init — got ' + mirrors.length);
});
test('desktop viewport (innerWidth=1280): slot stays empty', () => {
const ctx = makeSandbox({ innerWidth: 1280, hash: '#/packets' });
buildMobileDOM(ctx);
loadMPA(ctx);
(ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' }));
const slot = ctx.document.getElementById('navPageActions');
if (slot) {
assert.strictEqual(slot.children.length, 0, 'desktop slot empty — got ' + slot.children.length);
}
});
test('mobile + non-packets route: hashchange clears the slot', () => {
const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' });
buildMobileDOM(ctx);
loadMPA(ctx);
ctx.location.hash = '#/map';
(ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' }));
const slot = ctx.document.getElementById('navPageActions');
if (slot) {
assert.strictEqual(slot.children.length, 0, 'slot cleared off /packets — got ' + slot.children.length);
}
});
test('mobile detail sheet closes on route change away from /packets', () => {
const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' });
buildMobileDOM(ctx);
const sheet = ctx.document.getElementById('mobileDetailSheet');
assert.ok(sheet.classList.contains('open'), 'pre: sheet open');
loadMPA(ctx);
ctx.location.hash = '#/map';
(ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' }));
assert.ok(!sheet.classList.contains('open'), 'sheet closed after route change off /packets');
});
console.log(`\n#1461 mobile-page-actions: ${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
+226
View File
@@ -0,0 +1,226 @@
/* test-issue-1470-node-tile-helper.js — behavioral test for the
* _applyTilesToNodeMap helper shipped in #1471 and roles.js
* getActiveTileProvider() / getTileUrl() integration with the
* MC_TILE_PROVIDERS registry.
*
* Strategy:
* - vm-load public/map-tile-providers.js + public/roles.js into a single
* sandbox with mocked localStorage + document(theme=dark).
* - Extract the _applyTilesToNodeMap function source from public/nodes.js
* by regex and run it in the same sandbox.
* - Mock L.tileLayer / L.map to capture the URL + options the helper passes
* and the .addTo() target. Assert that selecting voyager-inverted in the
* customizer ends up calling tileLayer with the voyager URL AND setting
* the tile pane filter to the provider.invertFilter string.
*
* No source-grep on the production fix. The assertions exercise the helper
* code path end-to-end. Reverting the fix (re-hardcoding OSM) breaks this.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(' ✅ ' + name); }
catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
}
function makeStorage() {
const store = {};
return {
getItem(k) { return Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null; },
setItem(k, v) { store[k] = String(v); },
removeItem(k) { delete store[k]; },
clear() { for (const k of Object.keys(store)) delete store[k]; },
};
}
function makeLeafletMock() {
const calls = [];
const tilePane = { style: { filter: '' } };
const fakeMap = {
_tileLayers: [],
getPane: (name) => name === 'tilePane' ? tilePane : null,
};
const L = {
tileLayer(url, opts) {
const layer = {
url, opts,
addTo(map) {
calls.push({ kind: 'tileLayer', url, opts, map });
map._tileLayers.push(this);
return this;
},
};
return layer;
},
map() { return fakeMap; },
marker() { return { addTo: () => ({ bindPopup: () => {} }) }; },
};
return { L, calls, fakeMap, tilePane };
}
function makeSandbox(opts) {
opts = opts || {};
const ctx = {
console,
setTimeout, clearTimeout,
JSON, Date, Math, Object, Array, String, Number, Boolean, Set, Map,
fetch: () => Promise.resolve({ ok: false, json: () => Promise.resolve({}) }),
localStorage: makeStorage(),
document: {
documentElement: {
getAttribute: () => opts.theme || 'dark',
style: { getPropertyValue: () => '' },
},
querySelector: () => null,
querySelectorAll: () => [],
getElementById: () => null,
createElement: () => ({ style: {}, appendChild: () => {}, setAttribute: () => {}, addEventListener: () => {} }),
addEventListener: () => {},
body: { appendChild: () => {}, style: {} },
head: { appendChild: () => {} },
readyState: 'complete',
},
window: {
addEventListener: () => {},
dispatchEvent: () => true,
matchMedia: () => ({ matches: opts.prefersDark !== false, addEventListener: () => {} }),
},
CustomEvent: function (type, init) { this.type = type; this.detail = (init && init.detail) || null; },
};
ctx.window.localStorage = ctx.localStorage;
ctx.window.document = ctx.document;
ctx.globalThis = ctx;
vm.createContext(ctx);
// After context creation, make the sandbox global object also be `window`
// so `window.X = ...` in roles.js makes X a bare global the same file
// can reference. We do this by copying assignments back — simpler: install
// a Proxy. Cheapest practical fix: shadow common globals after load.
return ctx;
}
function loadInto(ctx, relPath) {
const src = fs.readFileSync(path.join(__dirname, relPath), 'utf8');
vm.runInContext(src, ctx, { filename: relPath });
// Mirror window.* back to sandbox globals so code that uses bare names
// (which in a browser are window.X) can still resolve them in vm. We do
// this AFTER each file load so window.getTileUrl, window.TILE_DARK, etc.
// become bare refs `getTileUrl` / `TILE_DARK` for callers in the same
// sandbox.
for (const k of Object.keys(ctx.window)) {
if (!(k in ctx)) ctx[k] = ctx.window[k];
}
}
function extractApplyTilesHelper() {
const nodesSrc = fs.readFileSync(path.join(__dirname, 'public', 'nodes.js'), 'utf8');
// Match the function _applyTilesToNodeMap(map) { ... } block. Brace-count.
const startMatch = nodesSrc.match(/function\s+_applyTilesToNodeMap\s*\(map\)\s*\{/);
if (!startMatch) throw new Error('_applyTilesToNodeMap definition not found in public/nodes.js');
const start = startMatch.index;
let depth = 0, i = start;
for (; i < nodesSrc.length; i++) {
const ch = nodesSrc[i];
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) { i++; break; }
}
}
return nodesSrc.slice(start, i);
}
console.log('── #1470 node-detail inset-map tile-provider routing ──');
test('roles.js + providers: getActiveTileProvider returns null in light mode', () => {
const ctx = makeSandbox({ theme: 'light', prefersDark: false });
loadInto(ctx, 'public/map-tile-providers.js');
loadInto(ctx, 'public/roles.js');
const p = ctx.window.getActiveTileProvider();
assert.strictEqual(p, null, 'expected null in light mode, got ' + JSON.stringify(p));
});
test('roles.js + providers: getActiveTileProvider returns selected provider in dark mode', () => {
const ctx = makeSandbox({ theme: 'dark' });
loadInto(ctx, 'public/map-tile-providers.js');
loadInto(ctx, 'public/roles.js');
ctx.window.MC_setDarkTileProvider('voyager-inverted');
const p = ctx.window.getActiveTileProvider();
assert.ok(p, 'provider returned in dark mode');
assert.ok(typeof p.url === 'string' && /voyager/.test(p.url),
'provider url is voyager — got ' + JSON.stringify(p.url));
assert.ok(typeof p.invertFilter === 'string' && /invert\(/.test(p.invertFilter),
'voyager-inverted invertFilter present — got ' + JSON.stringify(p.invertFilter));
});
test('roles.js: getTileUrl returns voyager URL in dark mode when voyager-inverted selected', () => {
const ctx = makeSandbox({ theme: 'dark' });
loadInto(ctx, 'public/map-tile-providers.js');
loadInto(ctx, 'public/roles.js');
ctx.window.MC_setDarkTileProvider('voyager-inverted');
const url = ctx.window.getTileUrl();
assert.ok(/voyager/.test(url), 'getTileUrl returns voyager URL — got ' + url);
});
test('_applyTilesToNodeMap: dark + voyager-inverted → tileLayer(voyagerURL) + invert filter', () => {
const ctx = makeSandbox({ theme: 'dark' });
loadInto(ctx, 'public/map-tile-providers.js');
loadInto(ctx, 'public/roles.js');
ctx.window.MC_setDarkTileProvider('voyager-inverted');
const mock = makeLeafletMock();
ctx.L = mock.L;
ctx._fakeMap = mock.fakeMap;
vm.runInContext(extractApplyTilesHelper(), ctx, { filename: 'nodes.js#_applyTilesToNodeMap' });
vm.runInContext('_applyTilesToNodeMap(_fakeMap);', ctx);
assert.ok(mock.calls.length >= 1, 'tileLayer was called — got ' + mock.calls.length + ' calls');
const firstCall = mock.calls[0];
assert.ok(/voyager/.test(firstCall.url),
'first tileLayer URL is voyager — got ' + firstCall.url);
assert.ok(/invert\(/.test(mock.tilePane.style.filter),
'tile pane filter set to invert(...) — got ' + JSON.stringify(mock.tilePane.style.filter));
});
test('_applyTilesToNodeMap: dark + carto-dark (non-inverted) → no invert filter applied', () => {
const ctx = makeSandbox({ theme: 'dark' });
loadInto(ctx, 'public/map-tile-providers.js');
loadInto(ctx, 'public/roles.js');
ctx.window.MC_setDarkTileProvider('carto-dark');
const mock = makeLeafletMock();
ctx.L = mock.L;
ctx._fakeMap = mock.fakeMap;
vm.runInContext(extractApplyTilesHelper(), ctx, { filename: 'nodes.js#_applyTilesToNodeMap' });
vm.runInContext('_applyTilesToNodeMap(_fakeMap);', ctx);
assert.ok(mock.calls.length >= 1, 'tileLayer was called');
assert.ok(/cartocdn|basemaps\.cartocdn|dark_all/.test(mock.calls[0].url),
'carto-dark URL — got ' + mock.calls[0].url);
assert.strictEqual(mock.tilePane.style.filter, '',
'no invert filter for carto-dark — got ' + JSON.stringify(mock.tilePane.style.filter));
});
test('_applyTilesToNodeMap: light mode → uses TILE_LIGHT (carto light_all), no invert', () => {
const ctx = makeSandbox({ theme: 'light', prefersDark: false });
loadInto(ctx, 'public/map-tile-providers.js');
loadInto(ctx, 'public/roles.js');
const mock = makeLeafletMock();
ctx.L = mock.L;
ctx._fakeMap = mock.fakeMap;
vm.runInContext(extractApplyTilesHelper(), ctx, { filename: 'nodes.js#_applyTilesToNodeMap' });
vm.runInContext('_applyTilesToNodeMap(_fakeMap);', ctx);
assert.ok(mock.calls.length >= 1, 'tileLayer called');
assert.ok(/light_all|openstreetmap/.test(mock.calls[0].url),
'light mode uses light tile URL — got ' + mock.calls[0].url);
assert.strictEqual(mock.tilePane.style.filter, '', 'no invert filter in light mode');
});
console.log(`\n#1470 node-detail tile helper: ${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);