containerRef.__doxaMap was set when the map was created and never cleared, so the dead map (GL
context, sources, tiles) stayed reachable from the DOM after destroy(). It is now cleared, only if
it is still this instance's. The logo-owner claim is released by the commit before this one.
rega's heap snapshots (leak-722ac7789): Simple Research and All People Groups
mounted twice went 54.2 MB to 65.1 MB, with live Vue apps 6 to 8. Every unmounted
application was still there, one copy per mount, and the retainers were all the
same shape -- a window key set during setup and never cleared. The closure keeps
its component, the component keeps its shadow root, and a whole app survives its
own unmount. An element property dies with the element; a window key does not.
The named one was window.__doxaLogoOwner -> Map -> div.rm-map-canvas -> ShadowRoot
-> app, released only when the map's 'remove' event fires. That listener cannot be
the guarantee the global is cleared, because it only runs if remove() is reached;
it is a consequence of the guarantee, not the guarantee itself. useMapInstance now
drops the claim inside destroy(), before removing, so it goes even if remove()
throws part-way.
As for why remove() was never reached: research-map's unmount hook ran ten
teardown steps and called destroy() last, and two of those steps were unguarded
calls into other composables. A single throw anywhere above stranded the map, and
with it the app. I could not reproduce which step threw without a browser, so the
fix does not depend on knowing: each step is isolated and destroy() runs from a
finally, where nothing above it can skip it. Ordering teardown so the most
important step is last, behind nine chances to fail, was the real defect.
The globals themselves are released by the components that set them -- four verify
hooks in research-map, three in the dashboard map panel, the vocabulary hook in
the grid, which had no unmount hook at all. Released as SETS: __rdashPinAt was the
one the snapshot named, but __rdashMap holds the map and retains strictly more, so
clearing only the reported one would have looked fixed and leaked nearly as much.
The deletes are written one per line instead of looped over an array of names, so
that the check can read them. A release a checker cannot see is worth no more than
no release, the day someone removes three of the four.
The check flags only values that actually retain. A warn-once boolean latch and a
page-wide cache keyed off itself are deliberate and must not be reported, or it
becomes noise and gets switched off. It is NOT in gates:pre: 19 sites remain in
other owners' plugins, and this is a fleet pattern rather than one plugin's bug.
rega's load waterfall: the engine was requested only after the component tree
had rendered, and the dashboard's 384 KB country boundaries only after the whole
basemap style had loaded, so each waited on the other. The engine download now
starts when the map composable is set up, and the boundaries when the dashboard's
map panel mounts; the style handler awaits that same download.
With provider=mapbox explicit and no token, useMapInstance logged 'No Mapbox access token provided' as
console.error once per map (7x on pplr), while the UI already says "Map unavailable". It is now a single
console.warn per page, so production consoles stay at 0 errors (dx-shepherd, TKWARN).
rega's row 34 (map controls in ar) found the engine logo's label English: "Mapbox homepage" /
"MapLibre logo" — one engine key with two meanings. engineUiLocale(lang, provider) now takes the engine
and sets LogoControl.Title from mapui.mapboxHomepage or mapui.maplibreLogo (11 locales). gospel-resources
and cities-internet build their maps RAW; engineUiLocale is documented as exported for them. (PinHint's
close button was already translated on main as aria.dismissHint.)
MapLibre's and Mapbox's own control labels stayed English under ar/es (rega's row 30). Both engines take
a `locale` map of their UI strings at construction; useMapInstance now passes one built from the new
mapui catalogue (17 strings x 11 locales, parity PASS) for the reader's language. The keys are the
engines' own (MapLibre 5.24's list, plus Mapbox's gesture-hint names); an engine ignores the rest.
Built doxa-simple-map carries the translations; check:i18n-treeshake PASS.
maplibre-gl@5 and maplibre-gl-geocoder@1 were floating ranges, and no runtime loader set `integrity`,
so a CDN could serve any bytes to every partner's visitors unnoticed (CRITIC-5).
- library/constants/cdn.js is the one registry: mapbox-gl 3.24.0, maplibre-gl 5.24.0 (the release the
@5 range already resolved to), both geocoders (1.9.4 / 5.0.0) and d3 7.9.0, each with its sha384.
Every host answers Access-Control-Allow-Origin: *, so integrity + crossorigin work cross-origin.
- ensureEngineScript / ensureEngineCSS, the geocoder loaders (library and PPLR) and the generator's
static MapLibre tags set integrity + crossorigin from it; the embed snippet's engine preload matches.
- check:cdn-pins (gates:post): no floating CDN url in shipped code, and every one is in the registry;
--online re-fetches each and compares the hash (all 9 match today).
MapLibre stays on 5.x knowingly. GHSA-jrc7-96c5-q579 (DOM.sanitize, reached through a source's
attribution string) affects <= 6.4.0, and 6.x is ESM-only (no UMD global), so the upgrade is a loader
migration of its own. The attribution strings rendered today come from our configured basemap styles
and our own sources; the note in cdn.js says who is exposed until then.
connectivity-map builds its map raw (new gl.Map), not through useMapInstance, so
nothing injected the engine stylesheet into its shadow root. A document-level
<link> never reaches it. Mapbox's own CSS canary reported "missing CSS
declarations", and controls and markers had no rules to position them.
useMapInstance's ensureEngineCSS is now exported, and connectivity-map calls it
with engineStylesheetUrl(provider) before new gl.Map, re-checking afterwards that
the pane is still mounted. Maps that already use useMapInstance are unchanged.
gospel-resources/resources-map has the same raw-map shape and can make the same
call; that is its owner's change.
Measured with verify-map-paint on a served built tree: main failed both leaves
(canary not salmon, "missing CSS declarations"). This build passes
connectivity-map (live GL, engine CSS applied, 0 unclassified errors).
resources-map still fails, as expected until it adds the call.
Every map translated its own chrome but left the basemap's country, city
and street names in the style default (English on Mapbox, Latin + native
on OpenFreeMap) in all eleven locales. A reader in Arabic saw an Arabic
legend over an English map.
library/maps/localize-labels.js uses each engine's supported mechanism:
- Mapbox: map.setLanguage(lang). It also localizes the Standard style's
IMPORTED basemap, which a layer rewrite cannot reach.
- MapLibre: basemap symbol layers point at
['coalesce', name:<lang>, name:latin, name]. English restores each
layer's original expression, re-captured on every style load so a theme
switch never restores the previous style's labels.
Only layers whose text-field reads an OSM name field are touched. A
plugin's own label layers (zone titles, group names) are never rewritten.
useMapInstance applies it on every style.load and when the reader's
language changes (ProfileLoader's 'lang', else profile-config locale, else
?lang / <html lang>). The Prayer Walk Planner drops its private copy for
the shared one.
Measured on the Prayer Progress map, ar/zh/ru/hi:
- Mapbox getLanguage() == locale in all four.
- MapLibre: 13/14 symbol layers localized, and the plugin's own label
layer untouched.
Read in the Arabic screenshots: القاهرة, الإسكندرية, السويس, الأردن on
both engines. check:gallery-embed (+ :maplibre) and
check:prayer-walk-planner pass. The shared change reaches all 11 bundles
that compile initializeMap, and all are rebuilt here. check:shared-reached
marker basemap-labels-localized: 10/10 consumers.
library/maps/rtl-text.js installed the RTL text plugin for MAPBOX only
(window.mapboxgl, from api.mapbox.com). So every MapLibre map, i.e. every
keyless embed, drew RTL place names as disconnected, reversed glyphs.
Measured on the Prayer Walk Planner's printed Arabic sheet.
Under it was a second gap. Bundle entries call the installer at LOAD
time, before useMapInstance has lazily injected a MapLibre engine, so even
an engine-aware installer found nothing to install into.
- rtl-text.js is engine-aware. Mapbox is unchanged (same URL, same lazy
call). MapLibre gets the same plugin package from jsDelivr, keyless and
not api.mapbox.com, so keyless pages still make zero Mapbox requests.
New ensureRtlTextPlugin(gl) waits until it is LOADED, for callers that
capture a single frame (print).
- useMapInstance installs it the moment an engine global exists, the one
point every map passes through.
- The planner drops its private copy for the shared helper.
Measured: a print map of downtown Cairo (MapLibre, keyless) shows shaped
Arabic street and place names (شارع التحرير, مسجد الطباخ …); plugin
status "loaded", fetched from jsDelivr. check:gallery-embed passes js,
iframe, neighbour and linked (14 tabs); :maplibre passes;
check:prayer-walk-planner passes.
The shared change reaches all 11 bundles that compile initializeMap, and
all are rebuilt here. check:shared-reached gains an rtl-text-maplibre
marker (10/10 consumers carry it; cities-internet, gospel-resources and
my-upg-100-list boot maps another way and are not affected).
Also: the new Experimental bundle pplr-data-maps loads the Mapbox geocoder
under maplibre (pplrData.js:38). It is an owned, printed MAPBOX_KNOWN entry
routed to its owner; delete it in their fix commit.
initializeMap is async. If its component unmounts while it waits (a
studio tab switch, a composite swapping views), Vue nulls containerRef
and the next dereference threw "Cannot read properties of null (reading
'getRootNode')" from ensureEngineCSS. check:studio-all hit it about 1
run in 4 (gospel-dashboard); gospel-plugin found a second site one await
later (containerRef.value.clientWidth after the animation frame).
initializeMap now checks the ref after each of its three awaits and
returns silently when it is gone, and ensureEngineCSS returns on a null
element. An unmounted pane is normal, not an error, so nothing is
logged. The ref is the only liveness signal: requiring isConnected too
would silently stop maps that mount into a subtree before it is
attached.
Proof, deterministic in both directions:
- check:map-unmount-mid-init holds mapbox-gl.js, opens a map plugin in
the studio, switches away (destroying the pane), then releases the
script. Before: FAIL 2/2 runs (research-map, doxa-simple-map threw
the production error). After: PASS 2/2 across research-map,
gospel-dashboard and doxa-simple-map.
- check:map-unmount-race (gospel-plugin's browser-free test, adopted):
the guarded ensureEngineCSS resolves; with the guard stripped it
throws the exact message.
- No regression: check:studio-all passes 31/31, and 19 plugins still
create a live map canvas, as before.
check:shared-reached now tracks this change with a regex marker (a
control-flow fix has no literal that survives minification). It
reports 12 consumer bundles still to rebuild. Verified: rebuilding
doxa-simple-map moves it to "carries it".
The Africa crops showed theirs tighter than ours, and 107 identical CSS
properties could never have said why. Everything I could think to compare
matched: mapbox-gl 3.24.0 on both sides (their bun.lock, our CDN pin), the
style, the 1200x780 container, the padding of 40, the maxZoom of 6, the box
itself (their viewFor() pin extent is byte-identical to our rollup), and
NEITHER page sets a projection.
The asymmetry was when the fit runs. They frame the box in the constructor
(CountryMap.vue:179); Mapbox v3 resolves a constructor `bounds` while
getProjection() still reports 'mercator', giving zoom 3.210. We had recreated
that as a post-style-load fitBounds, resolved with the globe already active,
giving 2.777 -- a sphere shows more planet at the same zoom, so it pulls back.
0.433 zoom is 1.35x linear scale, which is exactly the tightness in the crops.
Copy don't recreate: I recreated their fit instead of copying their construction.
useMapInstance gains a bounds/fitBoundsOptions pass-through, inert unless a
caller passes it, for the deep-link case -- the only case Mapbox can honour,
since the dashboard mounts its one map before the fetch resolves. The normal
case goes through fitTheirWay(), which asks the engine the same question their
constructor asks: setProjection('mercator'), cameraForBounds, restore the globe
in a `finally` so a throw cannot leave the map flat, then easeTo. Asking the
engine beats reimplementing a Web Mercator fit that would drift from Mapbox's
own padding and worldSize semantics at the next upgrade.
Proof, three independent oracles: the camera moved 2.777 -> 3.210 with the
projection still 'globe' afterwards; the like-for-like tile oracle now reads
ours z3 / 8 tiles / [[-45,-66.51],[90,40.98]] against theirs z3 / 8 tiles /
[[-45,-66.51],[90,40.98]], the same tiles at 1.00x; and the regenerated crops,
looked at, put the same labels and pins on the same pixels.
Two oracles of mine were wrong and are fixed here too. The tile-extent probe
compared a z2-quantized extent against a z3 one and reported a "1.33x framing
gap" twice -- 180/135 is four tiles at z2 against three at z3 of the same box,
and our camera's own visible box was 135.44 deg against their 135 deg of tiles.
It now refuses to compare extents across levels and reports the level instead,
which is the real signal. The framing probe asserted our zoom equalled the
GLOBE's cameraForBounds answer, so it would have failed the correct clone; it
now compares against the mercator-resolved camera and checks that asking the
mercator question left the map on the globe.
A map inside a SHADOW ROOT has to inject its own copy of mapbox-gl.css, because
a document stylesheet does not cross a shadow boundary. This profile did that
by writing the URL out a second time, and the second copy drifted: it pinned
v3.3.0 while every production loader -- generate-tree's baked <script>,
useMapInstance, generate-gallery -- had moved to v3.24.0. So the map ran new JS
against old CSS.
MEASURED on the leaf: mapboxgl.version 3.24.0, the only mapbox stylesheet
reaching the shadow root v3.3.0, and mapbox-gl emitting its own "missing CSS
declarations" warning. The file's own header already says the engine loader is
imported rather than re-declared "because a second copy of one fact drifts".
The stylesheet URL was that second copy. It now comes from the same registry
via engineStylesheetUrl().
★ THE GATE COULD NOT SEE ANY OF IT, which is the more useful half of this
commit. verify-map-paint asserts a live GL context, zero console ERRORS, and
that mapbox emitted attribution -- all true of a map whose stylesheet never
applied, since the basemap paints fine while the chrome around it does not.
Mapbox itself says so, but as a WARNING, and the listener filtered to
type()==='error'. It now listens for _detectMissingCSS's verdict and fails on
it.
MUTATION-TESTED BOTH WAYS on the same leaf: restore v3.3.0 -> FAIL, exit 1;
restore the registry lookup -> PASS, exit 0.
Two things I had wrong on the way, recorded so the next reader does not repeat
them. I first asserted position:absolute on .mapboxgl-canvas-container -- but
v3.24.0 declares only cursor and touch-action there, so static is its CORRECT
value and that assertion would have failed every healthy map. I also read
"0 .mapboxgl-marker elements" as missing pins; mapbox draws pins as GL geometry
in the canvas, so zero is expected. A screenshot showed the polygons, the
region colours and hundreds of pins all rendering. Both were properties I
assumed rather than verified, and the canary's background-color is the one
mapbox actually defines -- it is the test _detectMissingCSS uses.
The canary alone would still have been a false green here: that rule is
identical in v3.3.0 and v3.24.0, so it is salmon under both. It catches an
ABSENT stylesheet; the warning catches a MISMATCHED one. The gate asserts both.
The last plugin on the rollout list, and the only one the switch could not
reach: it builds its map RAW (new gl.Map on window.mapboxgl) instead of
through useMapInstance, so resolving the provider inside the factory --
which fixed every other bundle for free -- did nothing here. MEASURED at
?provider=maplibre before this: still Mapbox, 19 requests to api.mapbox.com,
while five sibling leaves swapped cleanly.
Three things blocked the keyless engine, not one:
- the engine was hardcoded to window.mapboxgl. It now loads whatever the
provider selects, through ensureEngineScript/engineGlobal EXPORTED from
useMapInstance. Imported, not copied: a second ENGINES table living in a
profile is how two copies of one fact drift apart.
- a `if (!token) return` early exit. A token is a MAPBOX requirement, not
a map requirement -- left in place it would have refused to draw the
engine that needs no token, turning the keyless option into a blank
cell. Now mapbox-only.
- a hardcoded mapbox://styles/mapbox/light-v11. Now paired through
resolveNamedStyle('paper'), the registry entry that already pairs
light-v11 with OpenFreeMap positron, so both engines show one intent.
And the search bar follows the standing decision -- deferred to Mapbox,
because GeocoderComponent's maplibre path still geocodes through Mapbox, so
a keyless map with a search bar is not keyless. MapToolbar collapses its
reserved gap and the legend rises into the vacated space.
MEASURED on the built tree, both engines:
mapbox search@12 legend@58 19 mapbox requests (all unchanged)
maplibre search@NONE legend@8 5 mapbox requests maplibregl loaded
The remaining 5 are the leaf's own static preloads -- already filed, and
not something this file can reach.
Screenshot read, not just counted: positron basemap, region polygons, all
2107 pins, legend flush at the top, no gap and no collision.
22/22 bundles, 7/7 pre, 18/18 post, browser sweep of five StackLayout
consumers and all seven studio dashboards.
Applied after RENDERING the candidates side by side rather than reading a
gallery. OpenFreeMap ships a blue-toned dark style this registry never
used. At the band this product shows (30E 25N, z2.4), dark reads flat and
muddy -- near-black land against near-black water -- while fiord separates
land from sea and keeps labels legible in BOTH Latin and native script.
Endpoint verified 200 keyless first, the same bar every URL here must meet.
Note OpenFreeMap's documented '3d' style is NOT live at /styles/3d (404).
It took the three dark entries whose MAPBOX counterpart is also blue-ish:
midnight-glow (Standard@night), dusk (the entry literally says "blue
hour"), wayfinder-night (navigation-night is a blue palette). ink pairs
with near-black dark-v11 and its whole job is maximum pin contrast, so a
lighter, bluer ground would eat exactly what it exists to provide -- it
keeps dark, with a note saying so.
AND THE FALLBACK HAD TO LEARN THE THEME, which the swap alone would not
have fixed. A Standard-based profile names no registry style at all:
bootStyle() hands back the raw mapbox:// URL and the DARKNESS lives in the
config ({ basemap: { lightPreset: 'night' } }), a Mapbox-only mechanism.
maplibreStyle() collapsed every mapbox:// style to one LIGHT constant, so
flipping provider turned the dark research map into a light one -- found by
screenshotting it, not by any error: both engines mount, both paint, both
report success. Interoperability had "worked" while the product looked like
a different product. It now reads the preset the Mapbox side would have
applied, from the named entry or the boot config, so both engines tell the
same story.
VERIFIED: research map under ?provider=maplibre renders dark with the ember
dots intact and correct attribution; research dashboard under maplibre
renders the globe with all 2107 dots, greyscale choropleth, charts and
table. CAP-4 interop harness 4/4 combinations PASS. check:facet-changes-map
PASSES on mapbox (2107 -> 783 -> 2107 -> 1324, colour data-driven).
22/22 bundles, 7/7 pre, 18/18 post, browser sweep of five StackLayout
consumers and all seven studio dashboards; mapbox/maplibre search-bar and
legend geometry unchanged (56/104, NONE/58, 56/104).
NOT shipped, and filed instead: check:facet-changes-map cannot measure the
MapLibre engine at all -- it reads a private field that no MapLibre source
keeps live. Two fix attempts made it worse (one failed BOTH engines, one
called a node-side helper from page context) and were reverted; the gate is
back at HEAD and passing. The real fix re-expresses it on the public
querySourceFeatures(), which changes its reported counts and is a redesign,
not a patch.
The switch existed and did not reach. resolveMapProvider has long read
profile-config.provider -> ?provider= -> window.MAP_PROVIDER, but
useMapInstance defaulted `provider` to the LITERAL 'mapbox', so the page
switch only reached a map whose bundle happened to pass provider through.
MEASURED: 3 of the 11 bundles that mount a map. The other 8 pinned Mapbox
whatever the page asked — it LOOKED global and was not.
The factory now resolves it when a caller leaves it unset, which moves the
decision to the one place every map already goes through. An explicit
caller still wins (prayer-walk-planner pins maplibre deliberately), and
with no switch set resolveMapProvider returns 'mapbox', so the default is
unchanged.
DashboardMapPanel collapsed anything that was not the literal 'maplibre' to
'mapbox', which outranked the page switch. But dashboard.config.js pins
Mapbox DELIBERATELY — the Driver's ruling for Mapbox Standard's native
starfield globe — so that default had to survive. Both hold by putting the
page switch BETWEEN the per-element config and the bundle default:
1. profile-config.provider this embed
2. ?provider= / MAP_PROVIDER the page — "swap every map on this site"
3. dashboard.config.js this bundle's shipped default (Mapbox)
MEASURED across seven map leaves, default vs ?provider=maplibre, counting
requests to api.mapbox.com:
doxa-simple-map 21 -> 4 engine swaps
country-posters 24 -> 4 engine swaps
research-dashboard 25 -> 4 engine swaps (was pinned)
doxa-research-map 16 -> 4 engine swaps
research-map-plus-grid 16 -> 0 fully keyless
my-upg-100-list 19 -> 19 NOT swapped — see backlog
regions-dashboard 4 -> 4 no map on that leaf
Simple Research reaches ZERO Mapbox requests, which is the case that
matters most: it is the one carrying the search bar, and the geocoder is
the quota risk (GeocoderComponent's maplibre path still geocodes through
Mapbox, so a keyless map with a search bar is not keyless).
Two gaps left, both filed rather than hidden: my-upg-100-list bypasses
useMapInstance entirely and cannot see the switch, and generated leaves
still preload mapbox-gl.js/.css + the geocoder from api.mapbox.com even
under maplibre (library downloads, no token, but still a CDN dependency —
the composite leaf proves 0 is reachable). The second belongs to the tree
verb, which is not mine to change.
22/22 bundles, 7/7 pre, 18/18 post, browser sweep of five StackLayout
consumers and all seven studio dashboards.
Driver: "it is a priority to reclaim height... so I can see half of the
first row of the people groups grid." Measured after: shell bottom 623,
first card top 640, half the card inside a 900px viewport -- the faces show.
THE HEIGHT: map 360 -> 260 and shell follows the same row arithmetic
(260 + 32 + 7x26 + 28 = 502); headers slim again (topbar 44 -> 38, stats
26 -> 22). Seven data rows unchanged.
THE ZOOM: a shorter map now shows MORE world, not less. Default zoom
1.45 -> 1.1 opens on the whole sphere, minZoom 1 -> 0.5, and the mercator
"no duplicate world" floor is skipped on the globe -- a sphere has no
second world, so the floor only stole zoom-out range.
TWO MORE SECOND-OWNER BUGS FOUND ON THE WAY, both now dead:
- research-dashboard.vue passed its own defaults {mapH:360, shellH:720} to
useDashboardLayout, silently overriding the composable's DEFAULTS -- the
bundle carried BOTH and the stale copy won, so two reclaim passes changed
nothing. The override is deleted; one owner.
- one grid-template-rows fallback spelled 40svh WITHOUT a space and
survived the fallback sweep -- at 900px that IS 360, re-pinning the row.
ALSO: the chart count column is now a real GRID column (label 1fr, count
auto, rows joined via subgrid), because per-row flex put each count at its
own row's right edge the moment the legend scrolled -- "less like a column,
more like cards". Measured: one shared right edge before and after scroll.
GALAXY + MAPLIBRE. New 'galaxy' style in the shared registry -- deep-space
ground, blue oceans -- with both a mapbox and a keyless MapLibre URL, and a
paint override that is applied only if the loaded style actually has the
layer, so a miss is a silent no-op rather than a break. The dashboard now
runs provider 'maplibre' and mapStyle 'galaxy'. Reversible by two config
keys; the Mapbox path is untouched.
BOTH RESIZES RESTORED. My seven-row change had hard-capped the table's
keyhole at 7 x 26px, which read like a default and was not: nothing writes
--rd-table-rows, while the drag handles write --rd-shell-h and --rd-map-h.
So the cap was unreachable by any gesture and the Driver lost both resizes
at once, plus a grey gap where the freed pixels had no consumer.
Fixed by restoring the derivation and moving the default instead: the
keyhole is once again shell - map - bar - foot, and DEFAULTS.shellH drops
720 -> 602 (map 360 + bar 32 + 7 rows x 26 + foot 28) so the table OPENS at
seven rows while staying fully draggable. CSS fallback matched to 602.
Measured before the revert: shell handle 849 -> 731 with rows 7, table
divider map 360 -> 280 with rows 10, gap 1px after each.
Also carries the charts' frozen count column (sticky right:0 with an opaque
background, so a scrolled label cannot slide under the number) and the
ViewSelect library-index entry.
Two Driver-reported defects.
1. No more "Basemap tiles blocked" popup on the map. The banner reported an
operator problem (one Mapbox account setting on one deployment origin) to
the end user, who can do nothing about it, on every map and every load. The
console diagnosis stays, because it reaches the person who can act.
2. Selections in the dashboard now filter the grid beneath it. The two halves
were never connected: research-dashboard had no defineEmits and its bus is a
per-instance factory that is never provided, while dashboard-plus-grid passed
the grid no filter props. The grid's receiving end already worked. Added the
emit, plus a translation layer between the dashboard's snake_case facet keys
and the grid's camelCase props, mirroring big-picture.
Proven live: typing "Turk" takes the grid from 24 cards to 22 with wholly
different rows. Only 4 of 12 facets map; the rest are dropped rather than
guessed, because religion_family carries a family name while the grid's
religion prop matches a code, and mapping them would filter to zero rows.
A console message only helps someone who already suspects a problem and opens
devtools. The reported experience was "I clicked the map and saw nothing", so
the page itself must say why.
The leaf's own #status node is not usable here: done() removes it the moment the
element mounts SUCCESSFULLY, which is exactly what happens in this failure -
mount succeeds, only the tiles 403 afterwards. So the composable creates its own
small banner instead.
A CORNER banner, never a full-cover overlay: the data layer, legend and labels
all render correctly and stay useful. Hiding a working map to report a missing
basemap would trade one blank screen for another.
TOP-LEFT was chosen by LOOKING at a screenshot, not by geometry. A first attempt
at bottom-left rendered underneath the legend panel and was unreadable, while
every numeric check still passed - 442x70px, correct palette, 3% coverage.
The other corners are taken: controls top-right, attribution bottom-right, hint
pill bottom-centre.
Material light only: #fff surface, #3b463d text, #d1d4cd border, #6d766e muted.
No red - this is a configuration notice, not a crash. pointer-events:none so it
can never block map interaction, and the container is only set position:relative
when it is still static, so a profile that positions its own container keeps it.
A URL-restricted Mapbox token returns 200 for the style document AND the fonts,
and 403 only for tilesets. So the element mounts at full height, the legend and
labels paint, the data layer paints - and the basemap silently never arrives.
It reads as a rendering bug in our code. It is not: it is one account setting.
Nothing listened for it. GL-JS fires an "error" event carrying e.status on every
failed tile request, so the information was always there - no one asked. That gap
cost real debugging hours this flight and is the first thing an inheritor
deploying to a NEW domain will hit.
The listener attaches after both engine branches converge, so it covers Mapbox
and MapLibre with one hook, and is wrapped in try/catch so an engine without an
.on() method can never break map creation.
ONE MESSAGE PER MAP, not per tile: a blank basemap 403s every tile in the
viewport, dozens per second while panning, and an unguarded console.error would
bury the page and train the reader to ignore it.
Verified both directions: a restricted map produced 9 tile 403s and exactly 1
diagnostic; the token-free maplibre profile mounted 800px and emitted 0.
Full build ALL GUARDS PASS, SRI consistent.
- useMapTheme: one look rule (any prayer strategy → night/default; everything else →
day/monochrome) applied at boot and on every tab switch in the simple map and the
research map; an unknown strategy keeps the current look
- Legends and the research search pill are 380px again; desktop numeric columns 70px at
13px so every row stays on one line with no overlap
- Map containers carry their map instance for gates (no global)
- useMapTheme: basemapConfig() is the single source of truth; bootConfig() hands Mapbox the
Standard-style config at construction so the very first frame is already night; keepPinned
re-asserts on style.load and styledata, writing only on drift; MapLibre maps are skipped
- useMapInstance passes that config to new Map() and stores the map in a shallowRef so
Mapbox internals never run through Vue's reactive proxy
- Simple map's loading cover is dark like the research map's — the last light frame at boot
- New gate: internal/theme-flash-probe.mjs samples canvas luminance through boot, legend rows
and tabs and fails on any light frame (21 phases, 0 flashes)
- Compile-time gate __DOXA_QA__ (default off): the QA corner and every string
it carries are dead-code-eliminated from public bundles; platform builds
opt in with DOXA_QA=1. Shell QA becomes ?qa=1 opt-in. Audit: zero platform
strings across all bundles + shell.
- One Mapbox wordmark per page (first live map claims it; released on destroy).
- Coach-mark points at the need: 'Tap a red dot — someone still needs prayer.'
- Pray-click glow: locally-prayed groups render as Has Prayer for that visitor
(localStorage, 7-day TTL) — lasting glow comes from the real signup via the API.
- Staging forge gains 'copy embed': snippet built at click time from
location.href, so it carries the FULL URL of whatever host serves the tree.
The page URL's ?utm_source= now outranks config/global/default in every outbound
people-group link — an iframe embedded as <page>?utm_source=agwm, a partner page
carrying its own ?utm_source, or a host page like /embed/map/?utm_source=agwm
all attribute their traffic automatically. Applied in the shared popup and both
dashboard drawer links.
Standalone repo that builds embeddable JAMstack map bundles (web components),
composes them into a lazy-loaded SPA via layouts/, and optionally wraps a
Capacitor Android/iOS app — one build, choose your output/CDN. Diátaxis docs.
History reset to a single clean commit; no PII, IPs, or internal terms.