The pin-dim slider works, and it composes with legend dimming instead of fighting it

Dragging the side menu's pin-dim slider changed nothing: it wrote a store field
that did not exist, the same defect as the clustering picker beside it and found
while fixing that one.

The obvious fix would have created a worse bug. The pin layer's circle-opacity
already has an owner — legend dimming writes a ['case', …, 1, 0] expression — so
a flat slider value from a second writer would silently erase dimming, or be
erased by it, depending on which control the reader touched last. Nothing would
have looked broken; the map would just have stopped answering one of them.

So there is now exactly ONE writer and the value is the PRODUCT:

    opacity = slider × (1 for a matched pin, 0 for a dimmed one)

The slider scales the whole layer, dimming still dims relative to it, neither
wins. Seven call sites that each wrote opacity by hand now go through it.

MEASURED, reading the live paint property rather than the store:

    clustering off          1
    clustering on, 0.5      0.5
    + a dimming selection   ['case', <religion is B>, 0.5, 0]
    slider back to 1.0      ['case', <religion is B>, 1, 0]

One thing the gate had to learn, and it is written down in it: a REGION
selection does not dim at all — it sets a layer filter, so non-matching pins are
removed rather than faded. Testing that path would have reported "the slider
erased the dimming" about code that never writes opacity.

★ ALSO: the Globe/Flat control's 24px touch target now applies at EVERY width.
The first fix scoped it to `@media (pointer: coarse)`, reasoning that a mouse
needs no touch floor. Wrong twice: WCAG 2.5.8 is a TARGET SIZE criterion, not a
touch one, and the query meant phones passed at 34px while desktop stayed at
73.5×23 — the same one-pixel failure, now invisible to a phone sweep. Measured
after: 73.5×34 at 390, 430 and 1440.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 18:20:53 +00:00
parent 30a11a175c
commit 7965b8dbcd
16 changed files with 296 additions and 113 deletions

View file

@ -49,7 +49,7 @@ The scripts these commands are grounded in (from this branch's `package.json`):
"build": "bash internal/build-lock.sh",
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary",
"build:bundles": "node internal/run-parallel.mjs --bundles", // the 22 vite builds, 4 at a time
"preview": "bun x vite preview",
"serve": "node internal/serve-strict.mjs", // 404s a directory, like production

View file

@ -125,6 +125,8 @@
"check:poster-wall-localised": "node internal/check-poster-wall-localised.mjs",
"check:prayer-walk-planner": "node internal/check-prayer-walk-planner.mjs",
"check:print-geometry": "node internal/check-print-geometry.mjs",
"check:pin-dim-slider": "node plugin-designs/doxa-research-map/scripts/check-pin-dim-slider.mjs",
"check:pin-dim-slider": "the side menu's pin-dim slider and the legend's dimming must COMPOSE, not compete. Both want the pin layer's circle-opacity, so there is ONE writer and the value is the PRODUCT: slider x (1 matched, 0 dimmed). Asserts the LIVE paint property, not the store: flat 1 with clustering off, flat 0.5 with clustering on, ['case', expr, 0.5, 0] once something is dimmed, and ['case', expr, 1, 0] when the slider returns to 1. Dims by RELIGION deliberately - a region selection sets a layer FILTER rather than an opacity expression, so testing that path would report a failure about code that never writes opacity.",
"check:provider-switch": "node internal/check-provider-switch.mjs",
"check:public-hygiene": "node internal/public-hygiene-check.mjs",
"check:published": "node internal/check-published-matches-build.mjs",
@ -170,7 +172,7 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts",
"library-index": "node internal/generate-library-index.mjs",
"packet-fixture": "node internal/generate-packet-fixture.mjs",

View file

@ -1127,11 +1127,7 @@ function onSemanticTreeSelect(node) {
if (m && m.getLayer('language-family-pins')) {
try { m.setFilter('language-family-pins', null) } catch (_) {}
_syncHitboxFilter(m, null)
m.setPaintProperty('language-family-pins', 'circle-opacity', 1)
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
if (m.getLayer('language-family-pins-shadow')) {
m.setPaintProperty('language-family-pins-shadow', 'circle-opacity', 1)
}
applyPinOpacity(m, null)
mapLayers.syncGlowFilter(null)
}
// Reset region polygon highlight when deselecting on the WAGF Regions tab.
@ -1161,8 +1157,7 @@ function onSemanticTreeSelect(node) {
if (m && m.getLayer('language-family-pins') && node.filter) {
try { m.setFilter('language-family-pins', node.filter) } catch (_) {}
_syncHitboxFilter(m, node.filter)
m.setPaintProperty('language-family-pins', 'circle-opacity', 1)
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
applyPinOpacity(m, null)
mapLayers.syncGlowFilter(node.filter)
}
// Mirror selection into mapStore (loop-safe via legend's _lastInternalId).
@ -1186,19 +1181,8 @@ function onSemanticTreeSelect(node) {
// hitbox stays full-size for every pin and the user can tap an
// apparent empty area to select a hidden pin.
_syncHitboxFilter(m, node.filter)
// Dim via case expression — matched pins at 1.0, others at 0 (matches
// applyDimFilter values).
m.setPaintProperty('language-family-pins', 'circle-opacity', [
'case', node.filter, 1, 0
])
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', [
'case', node.filter, 1, 0
])
if (m.getLayer('language-family-pins-shadow')) {
m.setPaintProperty('language-family-pins-shadow', 'circle-opacity', [
'case', node.filter, 1, 0
])
}
// Dim via the one writer: matched pins at the slider's scale, others at 0.
applyPinOpacity(m, node.filter)
mapLayers.syncGlowFilter(node.filter)
}
}
@ -1212,8 +1196,7 @@ function onSemanticTreeSelect(node) {
if (m && m.getLayer('language-family-pins') && node.filter) {
try { m.setFilter('language-family-pins', node.filter) } catch (_) {}
_syncHitboxFilter(m, node.filter)
m.setPaintProperty('language-family-pins', 'circle-opacity', 1)
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
applyPinOpacity(m, null)
}
// Mirror region selection into mapStore (for geocoder / external sync).
if (id.startsWith('region:')) mapStore.selectRegion?.(id.slice(7))
@ -1572,15 +1555,49 @@ function onLegendHighlight(evt) {
mapFly.fitBounds?.([[minLng, minLat], [maxLng, maxLat]], 60, { maxZoom })
}
// ─── ONE WRITER FOR PIN OPACITY ──────────────────────────────────────────────
// ★ THE SLIDER AND THE LEGEND DIMMING DO NOT COMPETE, THEY COMPOSE.
// Two features want this one paint property: the side menu's dim slider (fade the individual pins
// while clustering is on, so the clusters read) and legend dimming (hide everything that is not the
// selected row). Written as two owners they would overwrite each other — the slider would erase
// dimming, or dimming would erase the slider, depending on click order. So there is exactly one
// writer and the value is the PRODUCT: opacity = slider × (1 for a matched pin, 0 for a dimmed one).
// The slider scales the whole layer; dimming still dims relative to it; neither silently wins.
//
// The slider was inert before this (mapStore never declared clusterPinOpacity, so dragging it
// changed a number nobody read) — the same defect as the clustering picker beside it, and found
// while fixing that one.
let _pinMatchExpr = null // the current dim expression, or null for "show everything"
function pinBaseAlpha() {
// The slider only applies while clustering is actually drawing — it is hidden in the side menu
// when clustering is off, and applying it then would dim a map with nothing to dim FOR.
const on = mapStore?.showClusters && (mapStore?.clusteringMode || 'off') !== 'off'
const a = Number(mapStore?.clusterPinOpacity)
return on && Number.isFinite(a) ? Math.min(1, Math.max(0, a)) : 1
}
// matchExpr: an expression to dim by, null to clear dimming, undefined to re-apply what is current
// (which is what the slider's watcher needs — it changes the scale, not the selection).
function applyPinOpacity(m, matchExpr) {
if (matchExpr !== undefined) _pinMatchExpr = matchExpr || null
if (!m || !m.getLayer?.('language-family-pins')) return
const a = pinBaseAlpha()
const value = _pinMatchExpr ? ['case', _pinMatchExpr, a, 0] : a
try {
m.setPaintProperty('language-family-pins', 'circle-opacity', value)
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', value)
if (m.getLayer('language-family-pins-shadow')) {
m.setPaintProperty('language-family-pins-shadow', 'circle-opacity', value)
}
} catch (_) { /* style still loading — the next apply will carry it */ }
}
watch(() => [mapStore?.clusterPinOpacity, mapStore?.clusteringMode, mapStore?.showClusters],
() => applyPinOpacity(map.value))
// Last-clicked highlight key per kind — clicking the same row again clears.
const _lastHL = { family: null, language: null, dialect: null, region: null, prayer: null, engagement: null, adoption: null }
function clearAllHighlights(m) {
m.setPaintProperty('language-family-pins', 'circle-opacity', 1)
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
if (m.getLayer('language-family-pins-shadow')) {
m.setPaintProperty('language-family-pins-shadow', 'circle-opacity', 1)
}
applyPinOpacity(m, null)
m.setFilter('language-family-pins', null)
_syncHitboxFilter(m, null)
mapLayers.syncGlowFilter(null)
@ -1696,8 +1713,7 @@ function applyDimFilter(detail) {
// only pins within that region" UX request (2026-04-26).
m.setFilter('language-family-pins', ['==', ['get', 'doxaRegion'], expectedValue])
_syncHitboxFilter(m, ['==', ['get', 'doxaRegion'], expectedValue])
m.setPaintProperty('language-family-pins', 'circle-opacity', 1)
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
applyPinOpacity(m, null)
if (m.getLayer('regions-fill')) {
// Build a match expression on iso_3166_1_alpha_3 (vector tile property) to
// highlight only the countries belonging to the selected WAGF region.
@ -1800,17 +1816,7 @@ function applyDimFilter(detail) {
matchExpr = ['==', ['get', property], expectedValue]
}
m.setPaintProperty('language-family-pins', 'circle-opacity', [
'case', matchExpr, 1, 0
])
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', [
'case', matchExpr, 1, 0
])
if (m.getLayer('language-family-pins-shadow')) {
m.setPaintProperty('language-family-pins-shadow', 'circle-opacity', [
'case', matchExpr, 1, 0
])
}
applyPinOpacity(m, matchExpr)
// Restrict the invisible hitbox layer to the matched bucket so dimmed
// (opacity-0) pins are no longer hit-testable. Without this the user
// can tap on apparent empty space and select a hidden pin, and the
@ -2038,6 +2044,14 @@ if (typeof window !== 'undefined') {
// many features are actually RENDERED right now. Layers are found by prefix rather than from a
// list, so a clustering layer added later shows up here without anyone remembering to add it.
// Same shape and purpose as the two handles beside it.
// THE PIN LAYER'S ACTUAL OPACITY EXPRESSION, for the same reason as the handle below: the slider
// and legend dimming COMPOSE into one paint property, and "the store value changed" is not evidence
// that they composed. Returns whatever GL is holding — a number when nothing is dimmed, a ['case',…]
// when something is.
window.__researchMapPinOpacity = () => {
try { return map.value?.getPaintProperty?.('language-family-pins', 'circle-opacity') ?? null } catch (_) { return null }
}
window.__researchMapClusterDraw = () => {
const m = map.value
const out = {}

View file

@ -0,0 +1,168 @@
#!/usr/bin/env node
/**
* check-pin-dim-slider.mjs — the side menu's pin-dim slider and the legend's dimming must COMPOSE,
* not compete, and the slider must actually reach the map.
*
* THE DEFECT. The slider wrote mapStore.clusterPinOpacity, which mapStore never declared, so
* dragging it changed a number nobody read — the same shape of defect as the clustering picker
* beside it, found while fixing that one. And the naive fix would have created a worse one: the pin
* layer's circle-opacity is ALSO owned by legend dimming (a ['case', …, 1, 0] expression), so a flat
* slider value written by a second owner would silently erase dimming, or be erased by it, depending
* on which the reader touched last.
*
* ★ SO THE VALUE IS A PRODUCT, AND THAT IS WHAT THIS ASSERTS: opacity = slider × (1 matched, 0
* dimmed). The slider scales the whole layer; dimming still dims relative to it; neither wins.
*
* ASSERTIONS, reading the LIVE paint property rather than the store:
* clustering off a plain 1 — the slider is hidden then, and dimming a map with nothing
* to dim FOR would be a regression of its own.
* clustering on, 0.5 a plain 0.5.
* + a dimming selection ['case', <expr>, 0.5, 0] — dimming survives the slider.
* slider back to 1.0 ['case', <expr>, 1, 0] — the slider survives dimming.
*
* Exit: 0 pass or SKIP (no built tree, no browser, no data) · 1 fail.
*/
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const TAG = 'PIN-DIM-SLIDER'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const MANIFEST = join(ROOT, 'public', 'doxa-maps', 'manifest.json')
const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0) }
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
let chromium
try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') }
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const bundle = (manifest.bundles || []).find((b) => b.bundle === 'doxa-research-map')
const profile = bundle && (bundle.profiles || []).find((p) => p.iframeUrl && /research-map/.test(p.iframeUrl))
if (!profile) skip('the manifest declares no research-map leaf to load.')
const freePort = () => new Promise((ok) => {
const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) })
})
const PORT = await freePort()
const TREE = `http://127.0.0.1:${PORT}/doxa-maps`
const LEAF = `${TREE}/${String(profile.iframeUrl).replace(/^\.\//, '')}`
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT)], { cwd: ROOT, stdio: 'ignore' })
const cleanup = () => { try { tree.kill() } catch { /* already gone */ } }
await new Promise((r) => setTimeout(r, 900))
if (!await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false)) {
cleanup(); skip('could not serve the built tree locally.')
}
const { requireThisTree } = await import(join(ROOT, 'internal', '_serves-this-tree.mjs'))
await requireThisTree(TREE, ROOT, TAG)
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
const fails = []
const lines = []
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
await page.goto(LEAF, { waitUntil: 'domcontentloaded', timeout: 60000 })
const out = await page.evaluate(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
let read = null
for (let i = 0; i < 120 && !read; i++) {
if (typeof window.__researchMapPinOpacity === 'function') read = window.__researchMapPinOpacity
else await sleep(500)
}
if (!read) return { skip: 'the map never exposed its pin-opacity report' }
for (let i = 0; i < 120; i++) {
if ((window.__researchMapVocabulary?.('religionFamily') || []).length) break
await sleep(500)
}
// Fresh walk each time: the slider is behind v-if="clusterMode !== 'off'", so it does not EXIST
// until a clustering mode is chosen. Caching the visited set across calls (as the clustering gate
// can) would mean searching a DOM snapshot taken before the element was created — which is how
// this gate first reported "the slider is not in the DOM" on a build where it works.
const findAll = (sel) => {
const seen = new Set()
const hits = []
const walk = (root) => {
if (!root || seen.has(root)) return
seen.add(root)
for (const el of root.querySelectorAll('*')) {
if (el.matches && el.matches(sel)) hits.push(el)
if (el.shadowRoot) walk(el.shadowRoot)
}
}
walk(document)
return hits
}
const buttons = findAll('.rm-cluster-btn')
if (buttons.length !== 4) return { skip: `expected 4 clustering buttons, found ${buttons.length}` }
const setSlider = (v) => {
const slider = findAll('#rm-pin-op')[0] || findAll('.rm-slider-row input[type=range]')[0]
if (!slider) return false
slider.value = String(v)
slider.dispatchEvent(new Event('input', { bubbles: true }))
return true
}
const host = document.querySelector('doxa-research-map')
// ★ DIM BY RELIGION, NOT BY REGION, AND THAT IS NOT ARBITRARY. MEASURED while writing this: a
// REGION selection does not dim at all — it sets a layer FILTER, so non-matching pins are
// removed rather than faded, and the opacity stays flat. Only the case-expression dim paths
// (religion / prayer / engagement / adoption / language-family) compose with the slider, so
// testing region here would have reported "the slider erased the dimming" about a path that
// never writes opacity.
const dim = () => {
let cfg = {}
try { cfg = JSON.parse(host.getAttribute('profile-config') || '{}') } catch (_) { cfg = {} }
cfg.externalFilters = { region: '', religion: 'Buddhism', language: '' }
host.setAttribute('profile-config', JSON.stringify(cfg))
}
const out = {}
buttons[0].click(); await sleep(4000) // Off
out.off = read()
buttons[2].click(); await sleep(8000) // MST — the slider only applies while clustering draws
if (!setSlider(0.5)) return { skip: 'the pin-dim slider never appeared after choosing a clustering mode' }
await sleep(2500)
out.clusteredHalf = read()
dim(); await sleep(9000)
out.dimmedHalf = read()
setSlider(1); await sleep(2500)
out.dimmedFull = read()
return out
})
if (out.skip) { await browser.close(); cleanup(); skip(out.skip) }
const show = (v) => JSON.stringify(v)
lines.push(` · clustering off ${show(out.off)}`)
lines.push(` · clustering on, 0.5 ${show(out.clusteredHalf)}`)
lines.push(` · + dimming, 0.5 ${show(out.dimmedHalf)}`)
lines.push(` · + dimming, 1.0 ${show(out.dimmedFull)}`)
const isCase = (v) => Array.isArray(v) && v[0] === 'case' && v.length === 4
if (out.off !== 1) fails.push(`with clustering off the pins should be fully opaque, got ${show(out.off)} — the slider is applying where it is not even shown.`)
if (out.clusteredHalf !== 0.5) fails.push(`the slider at 0.5 with clustering on should give a flat 0.5, got ${show(out.clusteredHalf)} — the slider is not reaching the map.`)
if (!isCase(out.dimmedHalf)) fails.push(`dimming while the slider is at 0.5 should give a ['case', …] expression, got ${show(out.dimmedHalf)} — the slider ERASED the dimming.`)
else {
if (out.dimmedHalf[2] !== 0.5) fails.push(`matched pins should render at the slider's 0.5 while dimming, got ${show(out.dimmedHalf[2])} — the dimming ERASED the slider.`)
if (out.dimmedHalf[3] !== 0) fails.push(`dimmed pins should render at 0, got ${show(out.dimmedHalf[3])}.`)
}
if (!isCase(out.dimmedFull)) fails.push(`returning the slider to 1.0 should keep the dimming expression, got ${show(out.dimmedFull)} — the slider erased it on the way back.`)
else if (out.dimmedFull[2] !== 1) fails.push(`at slider 1.0 matched pins should be fully opaque, got ${show(out.dimmedFull[2])}.`)
} catch (err) {
fails.push(`the measurement itself failed: ${err && err.message}`)
} finally {
await browser.close().catch(() => {})
cleanup()
}
for (const l of lines) console.log(l)
if (fails.length) {
console.log(`\n${TAG}: FAIL`)
for (const f of fails) console.log(` ✗ ${f}`)
console.log('\n opacity = slider × (1 matched, 0 dimmed). One writer, one product — see applyPinOpacity.')
process.exit(1)
}
console.log(`${TAG}: PASS — the slider scales the layer, dimming still dims relative to it, and neither erases the other.`)

View file

@ -1569,14 +1569,17 @@ useShadowStyles(`
.rd-proj{ position:absolute; right:8px; top:8px; z-index:3; font-size:11px; font-weight:700; cursor:pointer; user-select:none;
background:#1a1a2ecc; color:#fff; border:1px solid #ffffff33; border-radius:6px; padding:4px 9px; backdrop-filter:blur(4px); }
.rd-proj:hover{ background:#1a1a2e; }
/* ★ TOUCH TARGET: 24px, STATED. Measured on a phone (390x844, pointer:coarse) this rendered
/* ★ TOUCH TARGET: 24px, STATED, AT EVERY WIDTH. Measured on a phone (390x844) this rendered
74x23 — one pixel under WCAG 2.5.8 AA. The fix is to name the 24, not to add padding: the
height is padding PLUS a line box the renderer derives from the font, and padding arithmetic
lands at 23.7px, which rounds to the same failure. Mouse sizing is untouched; only a coarse
pointer gets the floor. (Found by regions' phone sweep across every map.) */
@media (pointer: coarse) {
.rd-proj{ min-height:24px; display:inline-flex; align-items:center; }
}
lands at 23.7px, which rounds to the same failure.
★ AND IT MUST NOT BE BEHIND pointer:coarse. The first fix scoped this to a coarse pointer,
reasoning that a mouse does not need a touch floor. That was wrong twice over: 2.5.8 is a
TARGET SIZE criterion, not a touch criterion, so it applies to the desktop pointer too — and
the media query meant phones passed at 34px while desktop stayed at 73.5x23, the same one-pixel
failure, now invisible to a phone sweep. Measured again at 1440x900 by upg100 (2a2ea36e). One
rule, every width, no query to be right about. */
.rd-proj{ min-height:24px; display:inline-flex; align-items:center; }
/* Zoom cluster. MapToolbar defaults to top:6px on the inline-end edge, which is exactly where
the Globe/Flat pill already sits, so drop this instance below it. 34px clears the pill
(top:8px + roughly 22px tall) with a small breathing gap.

View file

@ -663,9 +663,7 @@
.rd-proj{ position:absolute; right:8px; top:8px; z-index:3; font-size:11px; font-weight:700; cursor:pointer; user-select:none;
background:#1a1a2ecc; color:#fff; border:1px solid #ffffff33; border-radius:6px; padding:4px 9px; backdrop-filter:blur(4px); }
.rd-proj:hover{ background:#1a1a2e; }
@media (pointer: coarse) {
.rd-proj{ min-height:24px; display:inline-flex; align-items:center; }
}
.rd-proj{ min-height:24px; display:inline-flex; align-items:center; }
.rd-zoomtools.rd-zoomtools{ top:34px; gap:4px; }
.rd-zoomtools.rd-zoomtools .mcb{
width:26px; height:26px;

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../../dashboard-plus-grid.js" integrity="sha384-O+zoSKgDJQEpXRc5Nle3TEAcaBJyuSJyvsgV70dQN+t4TkTbwRLVGdHRDqmIrutB" crossorigin="anonymous"></script>
<script src="../../dashboard-plus-grid.js" integrity="sha384-T30j3WyUfQUHQCyeUzafmovl19rxdmHpQ8zl218xHf3jRPrXMbamku+6EVt2MmmG" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../dashboard-plus-grid.js" integrity="sha384-O+zoSKgDJQEpXRc5Nle3TEAcaBJyuSJyvsgV70dQN+t4TkTbwRLVGdHRDqmIrutB" crossorigin="anonymous"></script>
<script src="../dashboard-plus-grid.js" integrity="sha384-T30j3WyUfQUHQCyeUzafmovl19rxdmHpQ8zl218xHf3jRPrXMbamku+6EVt2MmmG" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -215,7 +215,7 @@
"bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "doxa-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-O+zoSKgDJQEpXRc5Nle3TEAcaBJyuSJyvsgV70dQN+t4TkTbwRLVGdHRDqmIrutB",
"integrity": "sha384-T30j3WyUfQUHQCyeUzafmovl19rxdmHpQ8zl218xHf3jRPrXMbamku+6EVt2MmmG",
"elements": [
"dashboard-plus-grid"
],
@ -235,7 +235,7 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-O+zoSKgDJQEpXRc5Nle3TEAcaBJyuSJyvsgV70dQN+t4TkTbwRLVGdHRDqmIrutB\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-T30j3WyUfQUHQCyeUzafmovl19rxdmHpQ8zl218xHf3jRPrXMbamku+6EVt2MmmG\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
},
"instances": [
{
@ -354,7 +354,7 @@
"bundle": "doxa-research-map",
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"output": "doxa-maps/doxa-research-map/doxa-research-map.js",
"integrity": "sha384-WrjMVdPV8TX5ao3fUArpVqTD1iimG5FpbRUEhfUf3pGstfwU7/ZGf+AzvWZPImWH",
"integrity": "sha384-Ru6gHvMYAs45EqJjXx8jJ6UaU5ZMtWs2xPLWaobuGQK9o2ID//HHTs7zFWV2rMnQ",
"elements": [
"doxa-research-map"
],
@ -370,7 +370,7 @@
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-research-map/research-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-WrjMVdPV8TX5ao3fUArpVqTD1iimG5FpbRUEhfUf3pGstfwU7/ZGf+AzvWZPImWH\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-Ru6gHvMYAs45EqJjXx8jJ6UaU5ZMtWs2xPLWaobuGQK9o2ID//HHTs7zFWV2rMnQ\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
},
"instances": [
{
@ -945,7 +945,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "doxa-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-J4Z6S2wH67C9lP13F3QaF+oNMnQdRHzfEzEpUnou1QUkJFP2RQqIhg4g5sTnUg8j",
"integrity": "sha384-ATlhk9cAD1Wsa5NqMcJLga9t3FBMNFCQ01C+vG8DhrJ+yvKaQgeWmI6femHiWSpA",
"elements": [
"research-dashboard-map"
],
@ -961,7 +961,7 @@
"scriptUrl": "./research-dashboard/research-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-J4Z6S2wH67C9lP13F3QaF+oNMnQdRHzfEzEpUnou1QUkJFP2RQqIhg4g5sTnUg8j\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-ATlhk9cAD1Wsa5NqMcJLga9t3FBMNFCQ01C+vG8DhrJ+yvKaQgeWmI6femHiWSpA\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
},
"bundle": "research-dashboard"
}

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
<script src="../doxa-research-map.js" integrity="sha384-WrjMVdPV8TX5ao3fUArpVqTD1iimG5FpbRUEhfUf3pGstfwU7/ZGf+AzvWZPImWH" crossorigin="anonymous"></script>
<script src="../doxa-research-map.js" integrity="sha384-Ru6gHvMYAs45EqJjXx8jJ6UaU5ZMtWs2xPLWaobuGQK9o2ID//HHTs7zFWV2rMnQ" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -27,7 +27,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
<script src="../../doxa-research-map.js" integrity="sha384-WrjMVdPV8TX5ao3fUArpVqTD1iimG5FpbRUEhfUf3pGstfwU7/ZGf+AzvWZPImWH" crossorigin="anonymous"></script>
<script src="../../doxa-research-map.js" integrity="sha384-Ru6gHvMYAs45EqJjXx8jJ6UaU5ZMtWs2xPLWaobuGQK9o2ID//HHTs7zFWV2rMnQ" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -215,7 +215,7 @@
"bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "doxa-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-O+zoSKgDJQEpXRc5Nle3TEAcaBJyuSJyvsgV70dQN+t4TkTbwRLVGdHRDqmIrutB",
"integrity": "sha384-T30j3WyUfQUHQCyeUzafmovl19rxdmHpQ8zl218xHf3jRPrXMbamku+6EVt2MmmG",
"elements": [
"dashboard-plus-grid"
],
@ -235,7 +235,7 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-O+zoSKgDJQEpXRc5Nle3TEAcaBJyuSJyvsgV70dQN+t4TkTbwRLVGdHRDqmIrutB\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-T30j3WyUfQUHQCyeUzafmovl19rxdmHpQ8zl218xHf3jRPrXMbamku+6EVt2MmmG\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
},
"instances": [
{
@ -354,7 +354,7 @@
"bundle": "doxa-research-map",
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"output": "doxa-maps/doxa-research-map/doxa-research-map.js",
"integrity": "sha384-WrjMVdPV8TX5ao3fUArpVqTD1iimG5FpbRUEhfUf3pGstfwU7/ZGf+AzvWZPImWH",
"integrity": "sha384-Ru6gHvMYAs45EqJjXx8jJ6UaU5ZMtWs2xPLWaobuGQK9o2ID//HHTs7zFWV2rMnQ",
"elements": [
"doxa-research-map"
],
@ -370,7 +370,7 @@
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-research-map/research-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-WrjMVdPV8TX5ao3fUArpVqTD1iimG5FpbRUEhfUf3pGstfwU7/ZGf+AzvWZPImWH\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-Ru6gHvMYAs45EqJjXx8jJ6UaU5ZMtWs2xPLWaobuGQK9o2ID//HHTs7zFWV2rMnQ\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
},
"instances": [
{
@ -945,7 +945,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "doxa-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-J4Z6S2wH67C9lP13F3QaF+oNMnQdRHzfEzEpUnou1QUkJFP2RQqIhg4g5sTnUg8j",
"integrity": "sha384-ATlhk9cAD1Wsa5NqMcJLga9t3FBMNFCQ01C+vG8DhrJ+yvKaQgeWmI6femHiWSpA",
"elements": [
"research-dashboard-map"
],
@ -961,7 +961,7 @@
"scriptUrl": "./research-dashboard/research-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-J4Z6S2wH67C9lP13F3QaF+oNMnQdRHzfEzEpUnou1QUkJFP2RQqIhg4g5sTnUg8j\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-ATlhk9cAD1Wsa5NqMcJLga9t3FBMNFCQ01C+vG8DhrJ+yvKaQgeWmI6femHiWSpA\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
},
"bundle": "research-dashboard"
}

View file

@ -667,9 +667,7 @@
.rd-proj{ position:absolute; right:8px; top:8px; z-index:3; font-size:11px; font-weight:700; cursor:pointer; user-select:none;
background:#1a1a2ecc; color:#fff; border:1px solid #ffffff33; border-radius:6px; padding:4px 9px; backdrop-filter:blur(4px); }
.rd-proj:hover{ background:#1a1a2e; }
@media (pointer: coarse) {
.rd-proj{ min-height:24px; display:inline-flex; align-items:center; }
}
.rd-proj{ min-height:24px; display:inline-flex; align-items:center; }
.rd-zoomtools.rd-zoomtools{ top:34px; gap:4px; }
.rd-zoomtools.rd-zoomtools .mcb{
width:26px; height:26px;

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <research-dashboard-map> custom element. -->
<script src="../research-dashboard.js" integrity="sha384-J4Z6S2wH67C9lP13F3QaF+oNMnQdRHzfEzEpUnou1QUkJFP2RQqIhg4g5sTnUg8j" crossorigin="anonymous"></script>
<script src="../research-dashboard.js" integrity="sha384-ATlhk9cAD1Wsa5NqMcJLga9t3FBMNFCQ01C+vG8DhrJ+yvKaQgeWmI6femHiWSpA" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: