diff --git a/.eslintrc.json b/.eslintrc.json
index f4bb0304..d7849a77 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -59,6 +59,12 @@
"SlideOver": "readonly",
"TILE_DARK": "readonly",
"TILE_LIGHT": "readonly",
+ "MC_TILE_PROVIDERS": "readonly",
+ "MC_setDarkTileProvider": "readonly",
+ "MC_getDarkTileProvider": "readonly",
+ "MC_setServerDefaultTileProvider": "readonly",
+ "MC_applyTileFilter": "readonly",
+ "MC_DARK_TILE_DEFAULT": "readonly",
"TYPE_COLORS": "readonly",
"TableResponsive": "readonly",
"TableSort": "readonly",
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 2f7a2b16..b631e885 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -120,6 +120,7 @@ jobs:
node test-issue-1418-spider-fan.js
node test-issue-1418-deeplink-hops-channels.js
node test-issue-1418-polish-review.js
+ node test-issue-1420-tile-providers.js
node test-live.js
- name: ๐งน Frontend lint (eslint no-undef) โ issue #1342
diff --git a/cmd/server/config.go b/cmd/server/config.go
index c00f42bb..92f0ce23 100644
--- a/cmd/server/config.go
+++ b/cmd/server/config.go
@@ -92,6 +92,13 @@ type Config struct {
DebugAffinity bool `json:"debugAffinity,omitempty"`
+ // MapDarkTileProvider selects the default dark-mode basemap provider for
+ // new visitors. The client may override per-browser via the customizer
+ // (persisted to localStorage). Allowed values: "carto-dark" (default),
+ // "esri-darkgray-labels", "voyager-inverted", "positron-inverted". See
+ // public/map-tile-providers.js for the registry. #1420.
+ MapDarkTileProvider string `json:"mapDarkTileProvider,omitempty"`
+
// ObserverBlacklist is a list of observer public keys to exclude from API
// responses (defense in depth โ ingestor drops at ingest, server filters
// any that slipped through from a prior unblocked window).
diff --git a/cmd/server/routes.go b/cmd/server/routes.go
index b547a162..1f882d9a 100644
--- a/cmd/server/routes.go
+++ b/cmd/server/routes.go
@@ -346,6 +346,7 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
PropagationBufferMs: float64(s.cfg.PropagationBufferMs()),
Timestamps: s.cfg.GetTimestampConfig(),
DebugAffinity: s.cfg.DebugAffinity,
+ MapDarkTileProvider: s.cfg.MapDarkTileProvider,
})
}
diff --git a/cmd/server/types.go b/cmd/server/types.go
index 8e3f7927..e9804ff0 100644
--- a/cmd/server/types.go
+++ b/cmd/server/types.go
@@ -988,6 +988,9 @@ type ClientConfigResponse struct {
PropagationBufferMs float64 `json:"propagationBufferMs"`
Timestamps TimestampConfig `json:"timestamps"`
DebugAffinity bool `json:"debugAffinity,omitempty"`
+ // #1420 โ server default for dark-tile provider picker. Client uses this
+ // as the fallback when no localStorage override is set.
+ MapDarkTileProvider string `json:"mapDarkTileProvider,omitempty"`
}
// โโโ IATA Coords โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
diff --git a/config.example.json b/config.example.json
index 41102e70..aa0a1018 100644
--- a/config.example.json
+++ b/config.example.json
@@ -47,6 +47,8 @@
"observer": "#8b5cf6",
"_comment": "Marker/badge colors per node role. Used on map, nodes list, and live feed."
},
+ "mapDarkTileProvider": "carto-dark",
+ "_comment_mapDarkTileProvider": "Default dark-mode basemap provider. Allowed: 'carto-dark' (Carto dark_all โ default), 'esri-darkgray-labels' (Esri Dark Gray Canvas + reference labels), 'voyager-inverted' (Carto Voyager with CSS invert filter), 'positron-inverted' (Carto Positron with CSS invert filter). Light mode is unaffected. Users can override per-browser via the in-app customizer (persisted to localStorage). #1420.",
"home": {
"heroTitle": "CoreScope",
"heroSubtitle": "Find your nodes to start monitoring them.",
diff --git a/public/customize-v2.js b/public/customize-v2.js
index cfadbb41..3701eef6 100644
--- a/public/customize-v2.js
+++ b/public/customize-v2.js
@@ -1256,9 +1256,31 @@
'
Gesture Hints
' +
'Re-show first-visit gesture discoverability hints (swipe rows, swipe tabs, edge-swipe drawer, pull-to-refresh).
' +
'' +
+ _renderDarkTileProviderSelector() +
'';
}
+ // โโ #1420 Dark-tile provider selector โโ
+ // Persists per-browser via MC_setDarkTileProvider; map.js / live.js
+ // listen for `mc-tile-provider-changed` and swap tiles live.
+ function _renderDarkTileProviderSelector() {
+ var reg = (typeof window !== 'undefined') && window.MC_TILE_PROVIDERS;
+ if (!reg) return '';
+ var active = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark';
+ var ids = ['carto-dark', 'esri-darkgray-labels', 'voyager-inverted', 'positron-inverted'];
+ var options = ids.filter(function (id) { return reg[id]; }).map(function (id) {
+ var label = reg[id].label || id;
+ var sel = id === active ? ' selected' : '';
+ return '';
+ }).join('');
+ return 'Dark Map Tiles
' +
+ 'Choose the dark-mode basemap. Light mode is unaffected. Inverted variants apply a CSS filter for higher contrast.
' +
+ '' +
+ '
';
+ }
+
function _renderHome() {
var eff = _getEffective();
var h = eff.home || {};
@@ -1808,6 +1830,16 @@
});
});
+ // #1420 Dark-tile provider dropdown โ persists + fires mc-tile-provider-changed
+ container.querySelectorAll('[data-cv2-dark-tile-provider]').forEach(function (sel) {
+ sel.addEventListener('change', function () {
+ var id = sel.value;
+ if (typeof window.MC_setDarkTileProvider === 'function') {
+ window.MC_setDarkTileProvider(id);
+ }
+ });
+ });
+
// Preset buttons
container.querySelectorAll('.cust-preset-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
diff --git a/public/index.html b/public/index.html
index 85ae56c8..0cbb5d98 100644
--- a/public/index.html
+++ b/public/index.html
@@ -123,6 +123,7 @@
+
diff --git a/public/live.js b/public/live.js
index f3d6599f..157bf167 100644
--- a/public/live.js
+++ b/public/live.js
@@ -1171,15 +1171,59 @@
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
- let tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, { maxZoom: 19 }).addTo(map);
+ // #1420 โ multi-provider dark-tile picker. Light mode unchanged.
+ let _liveDarkRefLayer = null;
+ function _liveResolveTile(dark) {
+ if (!dark) return { url: TILE_LIGHT, attribution: 'ยฉ OpenStreetMap ยฉ CartoDB', refUrl: null };
+ const reg = window.MC_TILE_PROVIDERS || {};
+ const id = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark';
+ const p = reg[id] || reg['carto-dark'] || {};
+ return {
+ url: p.url || p.baseUrl || TILE_DARK,
+ attribution: p.attribution || 'ยฉ OpenStreetMap ยฉ CartoDB',
+ refUrl: p.refUrl || null
+ };
+ }
+ function _liveSyncDarkTiles(dark) {
+ const r = _liveResolveTile(dark);
+ tileLayer.setUrl(r.url);
+ if (tileLayer.options) tileLayer.options.attribution = r.attribution;
+ if (dark && r.refUrl) {
+ if (!_liveDarkRefLayer) {
+ _liveDarkRefLayer = L.tileLayer(r.refUrl, { maxZoom: 19, attribution: r.attribution }).addTo(map);
+ } else {
+ _liveDarkRefLayer.setUrl(r.refUrl);
+ }
+ } else if (_liveDarkRefLayer) {
+ map.removeLayer(_liveDarkRefLayer);
+ _liveDarkRefLayer = null;
+ }
+ if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
+ // #1420 parity with map.js โ refresh visible attribution credit after provider swap.
+ if (map.attributionControl) {
+ try { map.attributionControl._update && map.attributionControl._update(); } catch (_) {}
+ }
+ }
+ const _liveInitTile = _liveResolveTile(isDark);
+ let tileLayer = L.tileLayer(_liveInitTile.url, { maxZoom: 19, attribution: _liveInitTile.attribution }).addTo(map);
+ if (isDark && _liveInitTile.refUrl) {
+ _liveDarkRefLayer = L.tileLayer(_liveInitTile.refUrl, { maxZoom: 19, attribution: _liveInitTile.attribution }).addTo(map);
+ }
+ if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
// Swap tiles when theme changes
const _themeObs = new MutationObserver(function () {
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
- tileLayer.setUrl(dark ? TILE_DARK : TILE_LIGHT);
+ _liveSyncDarkTiles(dark);
});
_themeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
+ // #1420 โ re-render on customizer change.
+ window.addEventListener('mc-tile-provider-changed', function () {
+ const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
+ (document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
+ _liveSyncDarkTiles(dark);
+ });
L.control.zoom({ position: 'topright' }).addTo(map);
nodesLayer = L.layerGroup().addTo(map);
diff --git a/public/map-tile-providers.js b/public/map-tile-providers.js
new file mode 100644
index 00000000..325d6941
--- /dev/null
+++ b/public/map-tile-providers.js
@@ -0,0 +1,135 @@
+/* map-tile-providers.js โ Dark-tile provider registry & runtime switcher (#1420).
+ *
+ * Scope:
+ * - 4 providers: carto-dark (default), esri-darkgray-labels (base+ref),
+ * voyager-inverted, positron-inverted (CSS-filter variants).
+ * - MC_setDarkTileProvider(id) persists per-browser to localStorage and
+ * dispatches `mc-tile-provider-changed` so map.js / live.js can swap.
+ * - MC_getDarkTileProvider() resolves localStorage โ server default โ
+ * 'carto-dark'.
+ * - MC_applyTileFilter() applies/clears the CSS filter on
+ * `.leaflet-tile-pane` based on current theme + selected provider.
+ *
+ * No new deps โ URL-only providers. Light mode is unchanged.
+ */
+(function () {
+ 'use strict';
+
+ var STORAGE_KEY = 'mc-dark-tile-provider';
+ var DEFAULT_ID = 'carto-dark';
+ var EVENT_NAME = 'mc-tile-provider-changed';
+ var INVERT_CSS = 'invert(1) hue-rotate(180deg) brightness(0.9) contrast(1.05)';
+
+ // Per-browser server-injected default. roles.js writes this from
+ // /api/config/client (cfg.mapDarkTileProvider) before any consumer reads.
+ var _serverDefault = null;
+
+ // โโ Registry โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ var REGISTRY = {
+ 'carto-dark': {
+ label: 'Carto Dark (default)',
+ url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
+ attribution: 'ยฉ OpenStreetMap ยฉ CartoDB',
+ invertFilter: null
+ },
+ 'esri-darkgray-labels': {
+ label: 'Esri Dark Gray + Labels',
+ // Two-layer provider: base + reference (labels) overlay.
+ baseUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}',
+ refUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Reference/MapServer/tile/{z}/{y}/{x}',
+ attribution: 'Tiles ยฉ Esri โ Esri, DeLorme, NAVTEQ',
+ invertFilter: null
+ },
+ 'voyager-inverted': {
+ label: 'Carto Voyager (inverted)',
+ url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
+ attribution: 'ยฉ OpenStreetMap ยฉ CartoDB',
+ invertFilter: INVERT_CSS
+ },
+ 'positron-inverted': {
+ label: 'Carto Positron (inverted)',
+ url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
+ attribution: 'ยฉ OpenStreetMap ยฉ CartoDB',
+ invertFilter: INVERT_CSS
+ }
+ };
+
+ function _hasId(id) {
+ return typeof id === 'string' && Object.prototype.hasOwnProperty.call(REGISTRY, id);
+ }
+
+ function _isDark() {
+ try {
+ var attr = document.documentElement.getAttribute('data-theme');
+ if (attr === 'dark') return true;
+ if (attr === 'light') return false;
+ return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
+ } catch (_) { return false; }
+ }
+
+ function getActiveId() {
+ try {
+ var stored = window.localStorage && window.localStorage.getItem(STORAGE_KEY);
+ if (_hasId(stored)) return stored;
+ } catch (_) { /* localStorage may be disabled */ }
+ if (_hasId(_serverDefault)) return _serverDefault;
+ return DEFAULT_ID;
+ }
+
+ function setActive(id) {
+ if (!_hasId(id)) return false;
+ try {
+ if (window.localStorage) window.localStorage.setItem(STORAGE_KEY, id);
+ } catch (_) { /* swallow quota / disabled */ }
+ var detail = { id: id, provider: REGISTRY[id] };
+ try {
+ var ev = (typeof CustomEvent === 'function')
+ ? new CustomEvent(EVENT_NAME, { detail: detail })
+ : { type: EVENT_NAME, detail: detail };
+ window.dispatchEvent(ev);
+ } catch (_) { /* dispatch optional */ }
+ // Re-apply filter immediately so callers without a listener still see it.
+ applyTileFilter();
+ return true;
+ }
+
+ function setServerDefault(id) {
+ if (_hasId(id)) _serverDefault = id;
+ }
+
+ function applyTileFilter() {
+ var pane;
+ try { pane = document.querySelector('.leaflet-tile-pane'); } catch (_) { pane = null; }
+ if (!pane || !pane.style) return;
+ if (!_isDark()) { pane.style.filter = ''; return; }
+ var id = getActiveId();
+ var p = REGISTRY[id];
+ pane.style.filter = (p && p.invertFilter) ? p.invertFilter : '';
+ }
+
+ // โโ Public surface โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ window.MC_TILE_PROVIDERS = REGISTRY;
+ window.MC_DARK_TILE_DEFAULT = DEFAULT_ID;
+ window.MC_setDarkTileProvider = setActive;
+ window.MC_getDarkTileProvider = getActiveId;
+ window.MC_setServerDefaultTileProvider = setServerDefault;
+ window.MC_applyTileFilter = applyTileFilter;
+
+ // โโ Cross-tab sync โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ // If another tab in the same browser changes the provider, mirror the
+ // dispatch + filter-apply here so live map.js / live.js swap tiles too.
+ try {
+ window.addEventListener('storage', function (e) {
+ if (!e || e.key !== STORAGE_KEY) return;
+ if (!_hasId(e.newValue)) return;
+ var detail = { id: e.newValue, provider: REGISTRY[e.newValue], crossTab: true };
+ try {
+ var ev = (typeof CustomEvent === 'function')
+ ? new CustomEvent(EVENT_NAME, { detail: detail })
+ : { type: EVENT_NAME, detail: detail };
+ window.dispatchEvent(ev);
+ } catch (_) { /* dispatch optional */ }
+ applyTileFilter();
+ });
+ } catch (_) { /* addEventListener may not exist in some envs */ }
+})();
diff --git a/public/map.js b/public/map.js
index c74e1cf8..4652395f 100644
--- a/public/map.js
+++ b/public/map.js
@@ -256,16 +256,60 @@
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
- const tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, {
- attribution: 'ยฉ OpenStreetMap ยฉ CartoDB',
+ // #1420 โ multi-provider dark-tile picker. Light mode unchanged.
+ let _darkRefLayer = null; // Esri-only: labels overlay
+ function _resolveTileUrl(dark) {
+ if (!dark) return { url: TILE_LIGHT, attribution: 'ยฉ OpenStreetMap ยฉ CartoDB', refUrl: null };
+ const reg = window.MC_TILE_PROVIDERS || {};
+ const id = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark';
+ const p = reg[id] || reg['carto-dark'] || {};
+ return {
+ url: p.url || p.baseUrl || TILE_DARK,
+ attribution: p.attribution || 'ยฉ OpenStreetMap ยฉ CartoDB',
+ refUrl: p.refUrl || null
+ };
+ }
+ function _syncDarkTiles(dark) {
+ const r = _resolveTileUrl(dark);
+ tileLayer.setUrl(r.url);
+ if (tileLayer.options) tileLayer.options.attribution = r.attribution;
+ // Esri reference (labels) overlay: add when needed, remove otherwise.
+ if (dark && r.refUrl) {
+ if (!_darkRefLayer) {
+ _darkRefLayer = L.tileLayer(r.refUrl, { maxZoom: 19, attribution: r.attribution }).addTo(map);
+ } else {
+ _darkRefLayer.setUrl(r.refUrl);
+ }
+ } else if (_darkRefLayer) {
+ map.removeLayer(_darkRefLayer);
+ _darkRefLayer = null;
+ }
+ if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
+ if (map.attributionControl) {
+ try { map.attributionControl._update && map.attributionControl._update(); } catch (_) {}
+ }
+ }
+ const _initTile = _resolveTileUrl(isDark);
+ const tileLayer = L.tileLayer(_initTile.url, {
+ attribution: _initTile.attribution,
maxZoom: 19,
}).addTo(map);
+ if (isDark && _initTile.refUrl) {
+ _darkRefLayer = L.tileLayer(_initTile.refUrl, { maxZoom: 19, attribution: _initTile.attribution }).addTo(map);
+ }
+ if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
const _mapThemeObs = new MutationObserver(function () {
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
- tileLayer.setUrl(dark ? TILE_DARK : TILE_LIGHT);
+ _syncDarkTiles(dark);
});
_mapThemeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
+ // #1420 โ re-render when the user picks a different dark provider in the customizer.
+ window.addEventListener('mc-tile-provider-changed', function () {
+ const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
+ (document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
+ _syncDarkTiles(dark);
+ });
// Save position on move
map.on('moveend', () => {
diff --git a/public/roles.js b/public/roles.js
index 6a4b915c..49f2766a 100644
--- a/public/roles.js
+++ b/public/roles.js
@@ -357,6 +357,10 @@
if (cfg.tiles.dark) window.TILE_DARK = cfg.tiles.dark;
if (cfg.tiles.light) window.TILE_LIGHT = cfg.tiles.light;
}
+ // #1420 โ server default for dark-tile provider picker.
+ if (typeof cfg.mapDarkTileProvider === 'string' && typeof window.MC_setServerDefaultTileProvider === 'function') {
+ window.MC_setServerDefaultTileProvider(cfg.mapDarkTileProvider);
+ }
if (cfg.snrThresholds) Object.assign(SNR_THRESHOLDS, cfg.snrThresholds);
if (cfg.distThresholds) Object.assign(DIST_THRESHOLDS, cfg.distThresholds);
if (cfg.maxHopDist != null) window.MAX_HOP_DIST = cfg.maxHopDist;
diff --git a/test-all.sh b/test-all.sh
index 96fa3032..8da8212b 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -36,6 +36,7 @@ node test-issue-1418-cb-preset-ramp.js
node test-issue-1418-spider-fan.js
node test-issue-1418-deeplink-hops-channels.js
node test-issue-1418-polish-review.js
+node test-issue-1420-tile-providers.js
echo ""
echo "โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ"
diff --git a/test-issue-1420-tile-providers.js b/test-issue-1420-tile-providers.js
new file mode 100644
index 00000000..e4f23452
--- /dev/null
+++ b/test-issue-1420-tile-providers.js
@@ -0,0 +1,181 @@
+/* test-issue-1420-tile-providers.js โ Dark-tile provider registry tests.
+ *
+ * Asserts the MC_TILE_PROVIDERS registry shape, persistence helpers,
+ * and CSS-filter swap behavior for inverted variants. Tests via VM
+ * sandbox (no jsdom dep). Follows the pattern from cb-presets tests.
+ */
+'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]; },
+ _raw: store
+ };
+}
+
+function makeSandbox(opts) {
+ opts = opts || {};
+ const events = [];
+ const listeners = {};
+ const tilePane = { style: { filter: '' } };
+ const ctx = {
+ console,
+ setTimeout, clearTimeout,
+ JSON, Date, Math, Object, Array, String, Number, Boolean,
+ localStorage: makeStorage(),
+ document: {
+ documentElement: { getAttribute: () => opts.theme || 'dark' },
+ querySelector: (sel) => sel === '.leaflet-tile-pane' ? tilePane : null,
+ querySelectorAll: () => [],
+ addEventListener: () => {},
+ },
+ window: {
+ addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn); },
+ dispatchEvent: (ev) => { events.push(ev); return true; },
+ matchMedia: () => ({ matches: false, addEventListener: () => {} }),
+ },
+ CustomEvent: function (type, init) { this.type = type; this.detail = (init && init.detail) || null; }
+ };
+ ctx.window.localStorage = ctx.localStorage;
+ ctx.globalThis = ctx;
+ vm.createContext(ctx);
+ // Make window mirror globals (the module uses window.X assignment)
+ ctx.window.document = ctx.document;
+ ctx.events = events;
+ ctx.listeners = listeners;
+ ctx.tilePane = tilePane;
+ return ctx;
+}
+
+function loadProviders(ctx) {
+ const src = fs.readFileSync(path.join(__dirname, 'public', 'map-tile-providers.js'), 'utf8');
+ vm.runInContext(src, ctx);
+}
+
+console.log('โโ #1420 Dark-tile provider registry โโ');
+
+test('MC_TILE_PROVIDERS has all 4 IDs with url + attribution', () => {
+ const ctx = makeSandbox();
+ loadProviders(ctx);
+ const reg = ctx.window.MC_TILE_PROVIDERS;
+ assert.ok(reg, 'registry must exist on window');
+ const ids = ['carto-dark', 'esri-darkgray-labels', 'voyager-inverted', 'positron-inverted'];
+ for (const id of ids) {
+ assert.ok(reg[id], 'missing provider: ' + id);
+ assert.ok(typeof reg[id].attribution === 'string' && reg[id].attribution.length > 0, id + ' attribution');
+ // Esri uses baseUrl + refUrl; others use url
+ if (id === 'esri-darkgray-labels') {
+ assert.ok(typeof reg[id].baseUrl === 'string' && reg[id].baseUrl.indexOf('{z}') >= 0, 'esri baseUrl');
+ assert.ok(typeof reg[id].refUrl === 'string' && reg[id].refUrl.indexOf('{z}') >= 0, 'esri refUrl');
+ } else {
+ assert.ok(typeof reg[id].url === 'string' && reg[id].url.indexOf('{z}') >= 0, id + ' url has {z}');
+ }
+ }
+});
+
+test('Inverted providers have non-null invertFilter; non-inverted have null', () => {
+ const ctx = makeSandbox();
+ loadProviders(ctx);
+ const reg = ctx.window.MC_TILE_PROVIDERS;
+ assert.strictEqual(reg['carto-dark'].invertFilter, null);
+ assert.strictEqual(reg['esri-darkgray-labels'].invertFilter, null);
+ assert.ok(typeof reg['voyager-inverted'].invertFilter === 'string' && reg['voyager-inverted'].invertFilter.indexOf('invert(') >= 0);
+ assert.ok(typeof reg['positron-inverted'].invertFilter === 'string' && reg['positron-inverted'].invertFilter.indexOf('invert(') >= 0);
+});
+
+test('MC_setDarkTileProvider persists to localStorage and dispatches mc-tile-provider-changed', () => {
+ const ctx = makeSandbox();
+ loadProviders(ctx);
+ ctx.window.MC_setDarkTileProvider('voyager-inverted');
+ assert.strictEqual(ctx.localStorage.getItem('mc-dark-tile-provider'), 'voyager-inverted');
+ assert.ok(ctx.events.length >= 1, 'event dispatched');
+ const ev = ctx.events[ctx.events.length - 1];
+ assert.strictEqual(ev.type, 'mc-tile-provider-changed');
+ assert.ok(ev.detail && ev.detail.id === 'voyager-inverted');
+});
+
+test('MC_setDarkTileProvider rejects unknown IDs (no persistence, no dispatch)', () => {
+ const ctx = makeSandbox();
+ loadProviders(ctx);
+ const ok = ctx.window.MC_setDarkTileProvider('not-a-real-provider');
+ assert.strictEqual(ok, false);
+ assert.strictEqual(ctx.localStorage.getItem('mc-dark-tile-provider'), null);
+ assert.strictEqual(ctx.events.length, 0);
+});
+
+test('MC_getDarkTileProvider falls back to server default, then carto-dark', () => {
+ const ctx = makeSandbox();
+ loadProviders(ctx);
+ // No localStorage, no server hint โ default carto-dark
+ assert.strictEqual(ctx.window.MC_getDarkTileProvider(), 'carto-dark');
+ // Server-provided default surfaces through
+ ctx.window.MC_setServerDefaultTileProvider('esri-darkgray-labels');
+ assert.strictEqual(ctx.window.MC_getDarkTileProvider(), 'esri-darkgray-labels');
+ // localStorage wins over server
+ ctx.window.MC_setDarkTileProvider('voyager-inverted');
+ assert.strictEqual(ctx.window.MC_getDarkTileProvider(), 'voyager-inverted');
+});
+
+test('Apply filter for inverted provider in dark mode; clear when switching to non-inverted', () => {
+ const ctx = makeSandbox({ theme: 'dark' });
+ loadProviders(ctx);
+ ctx.window.MC_setDarkTileProvider('voyager-inverted');
+ ctx.window.MC_applyTileFilter(); // dark theme + inverted โ filter set
+ assert.ok(ctx.tilePane.style.filter.indexOf('invert(') >= 0, 'filter applied: ' + ctx.tilePane.style.filter);
+ ctx.window.MC_setDarkTileProvider('carto-dark');
+ ctx.window.MC_applyTileFilter();
+ assert.strictEqual(ctx.tilePane.style.filter, '', 'filter cleared after switching to carto-dark');
+});
+
+test('Light mode always clears the CSS filter even if inverted provider is selected', () => {
+ const ctx = makeSandbox({ theme: 'light' });
+ loadProviders(ctx);
+ ctx.tilePane.style.filter = 'invert(1)'; // pre-set from a prior dark session
+ ctx.window.MC_setDarkTileProvider('voyager-inverted');
+ ctx.window.MC_applyTileFilter(); // light theme โ must clear regardless of provider
+ assert.strictEqual(ctx.tilePane.style.filter, '');
+});
+
+test('Cross-tab storage event re-dispatches mc-tile-provider-changed and re-applies filter', () => {
+ const ctx = makeSandbox({ theme: 'dark' });
+ loadProviders(ctx);
+ // Sanity: module registered a storage listener.
+ assert.ok(ctx.listeners.storage && ctx.listeners.storage.length >= 1, 'storage listener registered');
+ // Simulate localStorage change from another tab (do NOT call setActive โ that's same-tab).
+ ctx.localStorage.setItem('mc-dark-tile-provider', 'voyager-inverted');
+ const before = ctx.events.length;
+ ctx.listeners.storage[0]({ key: 'mc-dark-tile-provider', newValue: 'voyager-inverted', oldValue: null });
+ assert.ok(ctx.events.length > before, 'storage event re-dispatched mc-tile-provider-changed');
+ const ev = ctx.events[ctx.events.length - 1];
+ assert.strictEqual(ev.type, 'mc-tile-provider-changed');
+ assert.strictEqual(ev.detail.id, 'voyager-inverted');
+ assert.strictEqual(ev.detail.crossTab, true);
+ assert.ok(ctx.tilePane.style.filter.indexOf('invert(') >= 0, 'filter re-applied after cross-tab change');
+ // Unknown values from other tabs must be ignored.
+ const beforeIgnored = ctx.events.length;
+ ctx.listeners.storage[0]({ key: 'mc-dark-tile-provider', newValue: 'bogus', oldValue: 'voyager-inverted' });
+ assert.strictEqual(ctx.events.length, beforeIgnored, 'unknown ids do not re-dispatch');
+ // Unrelated keys must be ignored.
+ ctx.listeners.storage[0]({ key: 'other-key', newValue: 'carto-dark', oldValue: null });
+ assert.strictEqual(ctx.events.length, beforeIgnored, 'unrelated key ignored');
+});
+
+process.on('beforeExit', () => {
+ console.log('');
+ console.log(' ' + passed + ' passed, ' + failed + ' failed');
+ if (failed) process.exit(1);
+});