From 777f77a4513f8d0d56a321dbf7b058d81f4ad91c Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Wed, 27 May 2026 07:37:51 -0700 Subject: [PATCH] feat(#1420): dark-tile provider picker in customizer (4 variants) (#1430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # feat(#1420): dark-tile provider picker in customizer (4 variants) Closes #1420. ## What Operator pick: don't force a single dark-tile choice on everyone. Wire 4 candidates into the customizer + server config so users can choose which dark basemap they want, with per-browser persistence. ## Providers shipped | ID | Source | Filter | |---|---|---| | `carto-dark` (default) | `https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` | none | | `esri-darkgray-labels` | Esri Dark Gray Base + Reference (two stacked layers) | none | | `voyager-inverted` | Carto Voyager + CSS `invert(1) hue-rotate(180deg) brightness(0.9) contrast(1.05)` on `.leaflet-tile-pane` | applied in dark, cleared in light | | `positron-inverted` | Carto Positron + same CSS invert | applied in dark, cleared in light | No new dependencies — all providers are URL-only. ## Architecture - **`public/map-tile-providers.js`** — registry + 5 public helpers (`MC_TILE_PROVIDERS`, `MC_setDarkTileProvider`, `MC_getDarkTileProvider`, `MC_setServerDefaultTileProvider`, `MC_applyTileFilter`). Persists to `localStorage['mc-dark-tile-provider']`. Dispatches `mc-tile-provider-changed` on user pick. - **`public/map.js` / `public/live.js`** — resolve the active dark provider via the registry, manage the Esri labels overlay lifecycle (add when needed, remove cleanly so we don't leak layers on repeated theme toggles), and apply/clear the CSS filter on `.leaflet-tile-pane`. Listen for both `data-theme` mutations AND `mc-tile-provider-changed`. - **`public/customize-v2.js`** — new "Dark Map Tiles" dropdown in the Display tab. On change, calls `MC_setDarkTileProvider(id)`; the maps re-render live without reload. - **`public/roles.js`** — hydrates the server default via `MC_setServerDefaultTileProvider` from `/api/config/client`. - **Server (`cmd/server/`)** — new `mapDarkTileProvider` string on `Config` + surfaced in `ClientConfigResponse`. Default empty → client uses `carto-dark`. - **`config.example.json`** — documents the new field with all allowed values. ## Behavior guarantees (from the acceptance criteria) - ✅ Light mode is **completely unchanged** — `_resolveTileUrl(false)` short-circuits to `TILE_LIGHT` with no filter and no overlay logic. - ✅ Switching dark→light always clears the CSS filter, even if an inverted provider remains selected (`MC_applyTileFilter` is called on every theme change and early-returns to `style.filter = ''` when not dark). - ✅ Switching light→dark with an inverted provider re-applies the filter. - ✅ Attribution is updated per provider (Esri credit for Esri, CartoDB credit for the others); the Leaflet attribution control is refreshed. - ✅ Esri uses two stacked layers (base + reference labels). The reference layer is added/removed cleanly so repeat toggles do not leak. - ✅ Customizer change → immediate re-render, no reload. Uses the same "live setting + persist + dispatch event" pattern as cb-presets (#1361). ## TDD - Red commit: `148b71c3` — `test(#1420): add failing tests for dark-tile provider registry (red)` — 6/7 assertions fail (stub only returns nulls). - Green commit: `49ffb230` — `feat(#1420): dark-tile provider picker — 4 variants wired into customizer` — 7/7 pass. ## Tests `test-issue-1420-tile-providers.js` (wired into `test-all.sh` and `.github/workflows/deploy.yml` JS-unit step): ``` ── #1420 Dark-tile provider registry ── ✅ MC_TILE_PROVIDERS has all 4 IDs with url + attribution ✅ Inverted providers have non-null invertFilter; non-inverted have null ✅ MC_setDarkTileProvider persists to localStorage and dispatches mc-tile-provider-changed ✅ MC_setDarkTileProvider rejects unknown IDs (no persistence, no dispatch) ✅ MC_getDarkTileProvider falls back to server default, then carto-dark ✅ Apply filter for inverted provider in dark mode; clear when switching to non-inverted ✅ Light mode always clears the CSS filter even if inverted provider is selected 7 passed, 0 failed ``` `cd cmd/server && go build ./... && go vet ./...` — clean. ## CDP verification Not run in this PR — the sandbox does not have a Chrome CDP endpoint reachable, and staging cannot exercise this code path until this branch is deployed. The issue body's "CDP-verified candidate set" table covers prior provider-URL validation; the new code path (registry lookup + filter swap + Esri overlay lifecycle) is covered by the unit tests above. **Recommend operator run a quick manual verification on staging post-deploy:** dark mode → open customizer → cycle through all 4 providers, confirm tiles render and the CSS filter is applied for `voyager-inverted` / `positron-inverted` (verify via `getComputedStyle(document.querySelector('.leaflet-tile-pane')).filter`). ## Files touched - `public/map-tile-providers.js` (new) - `public/map.js`, `public/live.js`, `public/customize-v2.js`, `public/roles.js`, `public/index.html` - `cmd/server/config.go`, `cmd/server/routes.go`, `cmd/server/types.go` - `config.example.json` - `test-issue-1420-tile-providers.js` (new), `test-all.sh`, `.github/workflows/deploy.yml` - `.eslintrc.json` (register new `MC_*` globals) --------- Co-authored-by: openclaw --- .eslintrc.json | 6 + .github/workflows/deploy.yml | 1 + cmd/server/config.go | 7 ++ cmd/server/routes.go | 1 + cmd/server/types.go | 3 + config.example.json | 2 + public/customize-v2.js | 32 ++++++ public/index.html | 1 + public/live.js | 48 +++++++- public/map-tile-providers.js | 135 ++++++++++++++++++++++ public/map.js | 50 ++++++++- public/roles.js | 4 + test-all.sh | 1 + test-issue-1420-tile-providers.js | 181 ++++++++++++++++++++++++++++++ 14 files changed, 467 insertions(+), 5 deletions(-) create mode 100644 public/map-tile-providers.js create mode 100644 test-issue-1420-tile-providers.js 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); +});