mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-04 20:01:22 +00:00
d964c27964
## 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>
227 lines
9.1 KiB
JavaScript
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);
|