internal/lib/facet-walk.mjs's __srcFeatures (injected into the page by
check:facet-changes-map) and library/maps/pin-selection.js's readSourceFeatures (the
packet and zoomToLegendRow) answer the same question about the same private GeoJSON
field. They are two copies because one must be a string evaluated in a browser and the
other an ES module, so they cannot be one function -- but nothing tested that they
still agreed, and they had already stopped.
THE CROSS-CHECK FOUND A DIVERGENCE ON ITS FIRST RUN, which is the argument for it. A
source may hold a single Feature rather than a FeatureCollection; both engines accept
that. The product's reader handled it and the gate's did not, so the gate answered
"cannot measure" about a payload it could read perfectly well. Nobody reading either
file would have noticed: each is correct on its own terms. __srcFeatures now handles
it and the two agree.
ONE DIFFERENCE IS DELIBERATE AND IS NOW PINNED AS SUCH RATHER THAN WAIVED: on a
url-backed source the gate returns null while the product returns [] and warns. Both
are right for their caller -- a gate whose job is to tell "the map drew nothing" from
"I could not look" needs a third answer, while a selection returning null would break
the Set arithmetic every caller does on it. Asserted in both directions, so neither
side can quietly adopt the other's and call it a fix.
AND NEITHER MAY BE SILENT ABOUT A SHAPE IT DOES NOT KNOW. maplibre has a THIRD _data
shape: updateData() stores { updateable } (maplibre-gl-dev.js:49001). No product code
calls updateData today, so this is a landmine rather than a bug -- and it is armed the
moment someone adopts partial updates for performance, at which point the product
reader would have gone back to returning [] in silence: "the map drew nothing", stated
as fact about a payload nobody recognised, one line below the url warning that exists
to prevent exactly that. Both readers now name the unrecognised shape by its keys, and
a fixture holds it, so adopting updateData() trips a test instead of blanking a map.
Negative-tested: dropping the single-Feature case, silencing either reader, or making
the gate adopt the product's url answer each turn it red; restored, 14/14 green.
check:pin-selection still passes, so the product reader is unchanged where it matters.
A gate scoped by a bundle's own declared stage misses what a production reader
sees. Two production leaves compose three bundles that declare themselves
experimental, so the code on those pages was held to the experimental bar --
not by anyone's decision, but because the scope was computed from the wrong end
of the composition.
Effective stage: production if it declares production, or if anything
production-for-gating composes it, transitively. The graph's edges come in two
shapes, a bare bundle name and a bundle/profile pair, and reading only one
silently halves it.
It lives in its own module because check:frozen-labels needs the same rule.
Two gates with their own idea of "production" would disagree by passing a file
the other fails, with neither obviously wrong.
The scan goes from seven plugins to ten, nineteen files to twenty-nine, and
from 178 to 274. The 96 are not new: they are English on production pages that
no gate was measuring. The report names which plugins are in scope only by
composition, and why, since that is the first question a reader will have.
The WALK string the facet gate injects into the page moves to a browser-free
module, internal/lib/facet-walk.mjs; the gate imports it, and
internal/check-facet-shape.mjs imports the same string and evaluates it on both
engines' source shapes without a browser. check:facet-shape ran from the browser
gate's own file, so the runner routed it to the browser slot; now it is headless
and joins gates:pre. Poisoned (the { geojson } unwrap disabled) it reports 4/5.
Only the browser seat runs browsers, and only inside internal/browser-slot.sh.
That rule was remembered, not enforced: an agent ran a gate it took for a
headless source check and it launched Chromium outside the slot.
internal/lib/playwright-guarded.mjs is now the one way a repo file reaches
playwright: its launch(), launchPersistentContext(), launchServer(), connect()
and connectOverCDP() exit 75 (NOT MEASURED) with a message naming
browser-slot.sh unless DOXA_BROWSER_SLOT is set, and browser-slot.sh exports it
for the command it runs, so a bash -c grandchild inherits it. All 72 files that
imported playwright switch to it (internal/lib/browser.mjs and 64 internal gates;
plugin-designs scripts: doxa-research-map 3, my-upg-100-list 2,
research-map-plus-grid 2, research-dashboard 1): a mechanical import swap.
check:browser-gates-guarded (gates:pre) fails a direct import;
bun run browser-gates lists the 97 scripts that launch a browser. isBrowserGate
moves to internal/lib/browser-gates.mjs so the runner and the check share it.
deepFind returns the FIRST match and stops. Every count-based assertion needs
all of them -- how many rows, anchors, hints, canvases -- and because this
file offered no such helper, each ad-hoc probe hand-rolled its own shadow
walker. Across one session of verification work those produced four false
results:
a naive walker double-visited through nested roots: 48 cards for 24;
a single-level querySelector found 0 anchors where there were 155;
an .rd-row count included a detail panel sharing that class, so a correct
filter read as broken;
a bare "<script" prefix match counted the word scriptUrl in a comment and
flagged a valid file.
Two of those nearly became bug reports against working code. A missing helper
does not stop anyone writing the check; it moves the check somewhere nobody
reviews.
The seen-Set dedupe is the load-bearing part: a node reachable through two
roots must count once, which is the first failure above.
Backward compatible by construction -- the third parameter is APPENDED, so
callbacks taking (deepFind, arg) are untouched. Verified rather than assumed:
a (deepFind, arg) callback still resolves; deepFindAll returns 24 cards with
returned === distinct; share-roundtrip-smoke (a real consumer) still passes;
check:a11y-button-names and check:parse both pass.
- internal/playground → internal/plugin-gallery; playground-smoke → plugin-gallery-smoke;
generate-spa → generate-gallery (scripts: gallery, smoke:gallery; smoke:playground stays as alias)
- generate-gallery exports renderGallery(model, { mode }) — the dev plugin serves the same page
at / from the discovered registry (dev mode imports the dev entries, ladder popovers on);
the build writes public/index.html from the manifest (byte-identical to before)
- Docs and probes use the gallery vocabulary
- The Mapbox Map instance is unwrapped (markRaw) at the profile boundary and the layers
composable works on the raw map, so Mapbox internals no longer pay Vue's get/set traps on
every frame; the geocoder control instance is a shallowRef for the same reason
- Prayer glow rings animate at 12 steps/s with paint transitions disabled, constants moved to
addLayer, and the loop pauses while the map is off-screen — the map can idle again
- New gate: internal/long-frames-probe.mjs measures long animation frames, blocking time and
DevTools rAF violations per phase (boot / idle / interaction); idle violations on the prayer
and research maps dropped from ~100 to the no-animation baseline
- ShareButton resolves its link from where the bundle is hosted (profile-config.shareUrl ->
embedBaseUrl -> window.MAP_EMBED_BASE_URL -> the bundle's own script URL); profiles pass
tree-relative embed pages, so links work in the playground, on doxa.life and on any CDN
- Generated leaf pages try token endpoints in order (override -> configured absolute URL ->
same-origin) and only then offer the paste-once box; build config points at the canonical
https://doxa.life/api/maps/token
- Dev playground extracted from vite.config.js into internal/playground/{discover,render,dev-plugin}.mjs;
serves /assets/* like the built site so data-driven maps initialise; passes assetsBase +
shareUrl to every element; header ladder with a Start-here primer and the five kinds of
mounting files (single-element, preset-element, multi-view-element, composite-element, app-shell)
- New gates: smoke:playground (every element boots, with status text + data diagnostics),
smoke:share (Share URL resolves and reopens the same map), sync:dropin (mirror public/ into a
host checkout, never credentials), deploy:dev (build + sync)
- MapToolbar + ShareButton on every map profile; UPG-100 composite (map + prayer lists) on the
pane layout; shared --map-radius token consumed by the legend components
- docs/reference.md: profile types table, profile-config assetsBase/shareUrl/embedBaseUrl, gates