Files
meshcore-analyzer/test-issue-1470-node-tile-helper.js
T
Kpa-clawbot d964c27964 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>
2026-05-28 16:11:25 -07:00

227 lines
9.1 KiB
JavaScript

/* 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);