Commit graph

342 commits

Author SHA1 Message Date
Doxa Maps
fa14fedfdd The poster asked for legend rows from a key the composable never had
useLegendData returns { items, columns, title, activeFilter, setFilter,
totalCount, totalPopulation, progress, fmtPop, fmtCount }. There is no `rows`.
research-map read legend.rows in three places, and because JavaScript answers
undefined rather than complaining, each one failed in a different costume:

  - provide('legendRows', legend.rows) provided undefined, and nothing in the repo
    injects that key -- the comment above it described a contract no component
    implements
  - openPoster's fallback, legend?.rows?.value || [], could only ever be [], so on
    any tab where the flat legend is empty the poster had no legend rows
  - the packet's page-1 slot was rows: legend?.rows?.value || [] with NO second
    source, so that poster's legend was empty unconditionally

and useMapPoster skips the legend slot entirely when the row list is empty. So the
missing legend on the exported poster had two independent causes: the renderer
painting it over the map, fixed separately, and this -- an empty array arriving
through a fallback that could never be anything else.

Both poster paths now share one builder, so they cannot drift apart again, and it
flattens children because a prayer legend is a parent plus two child tiers and
printing only the parents describes bands the map does not draw.

Two further calls went the same way: legend.refresh?.() and
legend.build?.(normalizedPeopleGroups), against methods the composable has never
defined. The optional call swallowed both, so two lines that read like steps did
nothing. `items` is a computed and re-derives on its own, so they are gone rather
than repointed.

exp-shepherd found the same misspelling in countries-map, where it threw instead
(NE-17). The check therefore guards the class rather than these lines: it reads
the returned key set from the composable and reports any consumer reading outside
it. It is NOT in gates:pre yet, because it still reports countries-map, which
belongs to another owner.

Writing it turned up two traps worth keeping. Stripping block comments with one
global regex DELETED REAL CODE -- a slash-star inside an ordinary string opens a
comment nobody wrote and the replace runs to the next real terminator -- and the
check confidently reported "no consumers at all" while both sat in the files. And
matching property reads by name flagged every t('legend.colLabel.religion'): an
i18n key spelled like a property access is not a read.
2026-09-29 00:06:52 +00:00
Doxa Maps
0147b6c2c6 The touch-target fix is what pushed the toggle into the zoom buttons
The projection toggle and the zoom toolbar share the top-right corner. The toolbar
was pinned at a literal top:34px, a number that was correct when it was chosen:
the toggle then measured 74x23 and ended at y=31, which this panel's own comment
records as a live measurement. A later accessibility fix gave the toggle
min-height:24px for WCAG 2.5.8. Nothing in this shadow root sets box-sizing
globally -- it appears exactly once, on the legend -- so 24 was a CONTENT height
and the rendered box became 24 plus 8 padding plus 2 border = 34px. Sitting at
top:8 it now ended at y=42 and ran 8px into the toolbar, which has z-index 10
against the toggle's 3 and so drew straight over it.

Nobody did anything careless. A correct fix to one control broke another because
the agreement between them was a magic number living in the other rule, where the
person changing the first had no reason to look.

So the toggle gets box-sizing:border-box, which makes 24 mean twenty-four and
keeps it a legitimate 24px touch target, and the toolbar's offset is now
calc(--rd-proj-top + --rd-proj-h + --rd-ctl-gap). The relationship is stated once,
on the element that owns it, and both the desktop rule and its mobile restatement
read it. Changing the toggle's size now moves the toolbar by construction.

The check computes the toggle's rendered height from its own declarations the way
a browser does, honouring box-sizing, rather than asserting either number --
restating 34 here would have rebuilt the same trap one file further away. Run
against the code as it stood before this commit it reports the overlap and its
exact size. It also requires the toolbar to DERIVE its offset, because a literal
that is correct today is the precise shape of this bug.
2026-09-29 00:06:52 +00:00
Doxa Maps
813a52ebbe One page loaded the same bundle twice and nothing said so
Every plugin ships as a self-contained IIFE, so library/utils/embedScript.js is
compiled separately into each one: eleven bundles on main each carry their own
copy. Its cache was a module-scoped const, which made "load exactly once" true
per BUNDLE rather than per page — the opposite of what the file's own header
claimed. Two shells nesting the same child (the Research Gallery and the Plugin
Studio both mounting doxa-research-map) each injected it.

Nothing failed, which is why it survived. The second request came from the HTTP
cache, so no failed request and no extra network row; the bundle then executed in
full a second time — a second Vue, a second vue-i18n, a second pinia, ~375 KiB
gzip of work — and its guarded customElements.define quietly declined at the very
end. Where the two URLs are different versions of one plugin, whichever executed
first owns the tag and the other is discarded in silence.

The cache now lives on globalThis, the only scope that matches the claim, under a
versioned key so a page mixing old and new bundles falls back to a cache each
rather than misreading a shape it does not expect. Entries are keyed by the
resolved href, because the callers do not pass the same string for one file.
A <script> already in the document is adopted, which is what covers bundles built
before this and host pages that write their own tag; that resolves immediately,
and it is safe only because every caller here follows with a wait on the element
itself — the note in the file says so, since a new caller must keep that pairing.

claimTag is a diagnostic, not a guard: by the time a second bundle arrives the
define is long since won, so the thing that was missing was anyone saying so.

The check imports the module twice under different URLs — two module instances
sharing one globalThis, the same shape as two IIFEs on one page. It asserts the
second caller receives the FIRST one's promise, not merely that one script tag
exists: mutation-testing showed the tag count alone is satisfied by the adoption
path, so it would have passed with the original bug restored.
2026-09-29 00:06:51 +00:00
Doxa Maps
29a34ad050 check:select-zone-race is registered; it and check:plural-forms run in gates:pre 2026-09-28 23:24:22 +00:00
Doxa Maps
c54bde307f CLDR plural selection, and the "N countries / N regions" keys it was blocking
vue-i18n v9 pluralizes by an English-shaped INDEX and applies it to every
locale. Measured on a real instance, authoring 'a | b | c':

  ru  n=0 -> the ONE form,  n=2 -> the MANY form     CLDR: 0=many, 2=few
  ar  n=3, 11, 100 -> ALL the dual form              CLDR: few, many, other

That is confidently wrong grammar that looks translated, which is worse than
the single fixed form the regions summary shipped until now. upg100 proposed a
plural key and was correctly refused on this exact ground
(upg-100-list.vue:3006) — "real plural support needs a pluralRules map at
createI18n". The refusal's premise was the missing map, so this adds it.

THE PRECONDITION SWEEP (dx-shepherd), before installing anything: every
catalogue value containing " | " across all 11 locales, and every call site
passing a plural choice.

  pipe keys:    wall.prayedCount, wall.havePrayed — and ONLY in en. Every other
                locale ships a pipe-free label form ("Помолились: {n}").
  call sites:   exactly two, both in wall-of-people-groups
                (CommentsDialog.vue:36, wallCanvas.js:198).

  BEFORE/AFTER, 2 keys x 11 locales x n = 0,1,2,3,11,100:
                132 renders compared, 0 CHANGED.

Zero changed because the map is SCOPED TO EXCLUDE en, which is the one locale
holding pipe strings today. en is also the case dx-shepherd's assertion (3) is
about: vue-i18n's 3-form default is zero|one|many while CLDR for en is
one|other, so an en string written "none | one | {n}" would change meaning at
n=0. Leaving en out means it cannot.

★ THE BUG I SHIPPED INTO MY OWN FIRST DRAFT, caught by reading the rendered
Russian rather than by any assertion: the index was taken against the fixed
zero|one|two|few|many|other list, which is only correct for a locale that HAS
all six. ru has {one, few, many, other} — no zero, no two — so 'few' sat at
global index 3 while the authored string holds it at 1, and n=2/n=3 rendered
"2 стран" instead of "2 страны". ARABIC HID IT COMPLETELY, being the one locale
that does have all six, so its two indexes coincide. The index is now computed
against each locale's OWN category subset, and ru/ro are authored in that order.

  ar  0 لا دول · 1 دولة واحدة · 2 دولتان · 3 دول · 11 دولة · 100 دولة
  ru  0 стран · 1 страна · 2 страны · 3 страны · 11 стран · 100 стран
  (regionCount likewise; all six categories distinct in ar, all three in ru)

check:plural-forms carries studio's three assertions, each mutation-tested to
fail: FORM COUNT (a 2-form ru string fails against its 4 categories), the
tStatic PATH (reading a pipe key through tStatic fails — tStatic does no
selection at all, so the page would show the literal "a | b | c"), and ZERO (a
5-form ar string fails because ar has an explicit zero category). Every mutated
file restored byte-identical afterwards.

The regions shim gains tc(key, n). t() resolves through tm()+rt(), and rt()
takes a message only — there is nowhere to pass the choice — so a pipe string
would come back whole. tc goes through vue-i18n's own t(key, named, choice).

Registered in all three places and VERIFIED by re-reading the parsed
package.json, not by trusting the replace: my last gate registration silently
no-opped on two of three anchors and studio caught it at the landing.
2026-09-28 23:23:56 +00:00
Doxa Maps
81f382a5c3 check:cdn-cache is registered and runs in gates:pre 2026-09-28 23:13:47 +00:00
Doxa Maps
bfb03de852 check:instance-config-keys has its script entry
gates:post named it with no script, which fails gates:post.
2026-09-28 22:26:41 +00:00
Doxa Maps
34010ce6d8 The grid's "controls off" preset did nothing, and nothing said so
rega RQ20: the driven page asks for show-search:false and show-filters:false and
renders the search box and the filters bar anyway, with 0 console errors. There
were no errors because nothing failed. The key never arrived:

  · the preset writes KEBAB into profile-config — "show-search": "false"
  · ProfileLoader PROVIDES that object; it does not bind its keys as props
  · the profile reads props.showSearch (CAMEL), which keeps its default of true

Three correct-looking layers and no join between them. A preset key that does
nothing looks exactly like a preset key that works, which is why the fix ships
with a gate rather than a comment.

The grid now reads these flags off the config as well as the prop, in the same
prop -> cfg ladder researchUrl beside them already used, and accepts BOTH
spellings: someone writing profile-config by hand reaches for the kebab spelling
because that is what the docs show on the tag, while the prop is camel.
Accepting one silently is what made this inert. An explicit prop still wins.
Proved on five cases including the exact preset, an explicit prop overriding the
config, and config:true keeping the control on.

check:instance-config-keys asserts every preset key reaches the profile that
renders it. Writing it was the interesting part, and it was WRONG TWICE:

  1. First version matched only `cfg.` and reported NINE FALSE POSITIVES across
     four plugins — big-picture reads config.value.accent, doxa-simple-map reads
     profileConfig?.value?.tabs. The config object's variable name is each
     profile's own business; matching on it measured my naming habit, not the
     contract.
  2. Widening it to "any accessor" then matched `props.showSearch` — the exact
     line that does NOT work — and the gate went GREEN on the defect it exists
     for. props is the one object that cannot carry a profile-config key, so it
     is now masked out before the match.

Mutation-tested after that: reverting the fix to props-only makes it fail again,
and the grid file is restored byte-identical. A gate I have not watched fail is
not evidence, and this one passed for the wrong reason once already.

The four bundles that embed the grid are rebuilt, so the fix is in the shipped
.js and not only in the source rega cannot run.
2026-09-28 22:25:30 +00:00
Doxa Maps
07e086ff23 check:legend-top-offset runs in gates:pre; a grid comment states its rule instead of quoting it 2026-09-28 17:54:33 +00:00
Doxa Maps
f7f180df50 The legend held a slot for a toolbar this profile never draws
DRV1650-SR-LEGEND-FILLS-SEARCH-GAP. On Simple Research's Regions and Languages tabs the
search bar is gone but the legend does not rise into the space. The other tabs are fine.

The two-tab split was a predicate, not a coincidence. With no geocoder the legend rises
to 14px UNLESS showsFlyTools() is true, and that returns true for exactly 'doxa-regions',
'language-family' and 'language-families'. It stops at 56px to avoid a MEASURED collision
on country-posters, where LegendTools renders at top:14px — the legend's own slot.

★ BUT showsFlyTools() DESCRIBES THE LEGEND TYPE, NOT WHETHER THE TOOLBAR EXISTS. Fly and
Clusters are leaf-only: the composite — which is what a reader calls "Simple Research" —
passes legendTools:false, so the toolbar is never drawn there and the 56px reserved a
slot for nothing. rega measured it at 1440x900 on main e1d0bc20: the band from the map's
top edge (y 44) to the legend card (y 100) is 56px of EMPTY map on both tabs, with no
LegendTools anywhere in the strip.

So the offset now asks whether the toolbar is RENDERED, not whether the type allows one.
Keying it on the tab name would have closed the Driver's gap and reintroduced the
country-posters collision the moment the tools were shown — the guard is kept, it is
simply asked the right question.

showLegendTools moved ABOVE legendTopVar, which reads it. A computed body is lazy so a
later const would have resolved at runtime, but this file has a TDZ history and
check:setup-order exists because of it; position is cheaper to keep honest than to reason
about.

check-legend-top-offset asserts BOTH inputs are consulted — drop either and it fails —
and that the declaration still precedes its reader. Negative-tested.

This is reading (b) of the two I put to dx-shepherd, chosen because rega measured it
rather than because it was likelier. Reading (a) would have been a cosmetic change to a
toolbar that turned out not to be there.
2026-09-28 17:53:39 +00:00
Doxa Maps
59a8b02d2e check:provide-shadowing runs in gates:pre 2026-09-28 17:53:38 +00:00
Doxa Maps
01a1a7f2ad Twenty-four warnings about an overwrite that was the whole point
The console showed 24 Vue warnings, 8 each for "profileModules", "appI18n"
and "Symbol(pinia)" — "already provides property with key … inherited from its parent
element. It will be overwritten with the new value." They come from a composite mounting
child custom elements: Vue's CE layer lets a child app inherit its parent ELEMENT's
provides, and app.provide() warns on any key already reachable.

ALL THREE MUST BE THE CHILD'S OWN, so neither silencing the warning nor honouring it by
not providing would have been right. profileModules is the plugin's own .vue glob — a
child rendering its parent's components is a different and worse bug. pinia is
per-instance so two embeds on one page never bleed store state. And the i18n instance is
the one ProfileLoader drives from THIS element's profile-config, which is the whole of
the permanently-English fix from earlier today. Inheriting any of the three would be the
regression, not the warning.

So the shadow is made DELIBERATE at the line that does it. provideOwn() writes to the
app's own provides when the key is already reachable and uses the public app.provide()
when it is not, so the ordinary path is untouched. pinia needs the other shape: its
symbol is module-private, so it can only be provided by app.use(), and the key cannot be
named — withoutInheritedProvides() hides the inherited chain for the duration of the
install and restores it in a FINALLY, so a throwing install cannot leave an element
unable to see its parent.

The helpers are split into library/elements/provide-own.js with NO framework import,
because defineMapElement.js imports ProfileLoader.vue and is therefore unloadable by the
gate that proves it right. Third time today that split has been necessary; the gate now
exercises the shipped helpers rather than a copy.

check-provide-shadowing asserts BOTH halves — the child ends up with its own value, AND
the parent's other provides still resolve afterwards — against a Vue-shaped fake app that
warns exactly as runtime-core does. Its control is the naive call, which must still warn.
Negative-tested three ways, including moving the restore out of the finally.

check-element-bootstrap now accepts either spelling and was re-checked for the thing it
actually guards: removing the provide entirely still turns it red.
2026-09-28 17:53:38 +00:00
Doxa Maps
7ea66b21a2 Phones: production maps not built for them stay closed and say why; experimental ones open with a notice 2026-09-28 17:53:38 +00:00
Doxa Maps
3b5a86aaef Auto resolves to MapLibre; a token no longer picks the engine
The Driver wants MapLibre as the default everywhere, on cost and on how it
looks, with Mapbox supported as an explicit choice.

One line decided it. resolveMapProvider read
  explicit || (token present ? 'mapbox' : 'maplibre')
so merely HOLDING a Mapbox token selected Mapbox. That conflated two different
things: having a token is a capability, wanting Mapbox is a decision, and only
the decision belongs in this function. It now reads explicit || 'maplibre'.

The token is still honoured for what it is — with provider:'mapbox' it
authenticates the map, and the token argument stays in the signature because
callers pass one they hold outside the config. It simply no longer votes.

The migration surface is exactly one shape: a host that pasted a real token into
a published embed snippet and set no provider. All 36 shipped snippets carry a
tk placeholder and no provider key, so any host who filled one in is in it. An
unfilled snippet was already MapLibre, because hasMapToken rejects YOUR_
placeholders, and the DX Life branches pass no token at all. Such a host keeps
Mapbox by adding "provider":"mapbox"; ?provider= and window.MAP_PROVIDER are
unchanged.

The gate's four token-selects-Mapbox assertions were the old specification, so
they are inverted rather than deleted — the diff should show what changed for
whom. Two are added: the opt-in still works with a token in hand, which is the
path a migrating host takes, and a real token is still SEEN, asserted apart from
the engine because otherwise the engine could be right while authentication
broke silently.

check:map-props-unit was not in gates:pre, so it proved nothing on a normal run.
It is now, and the suite reads 32 of 32 with it named.
2026-09-28 17:53:38 +00:00
Doxa Maps
64ac9c00b9 check:ring-vocabulary is registered, and it and check:mercator-slice run in gates:pre 2026-09-28 17:53:38 +00:00
Doxa Maps
84e1e0cf42 The poster sliced its rows in degrees, and Mercator charges more for a degree up north
DRV1650-SR-POSTER-EXPORT, the "too much Antarctica" half. The big-poster renderer
stitches a grid of tiles, each drawn into an EQUAL-HEIGHT slot of the output image, and
it chose each row's latitude band by dividing the degree span evenly:

    latStep = (ne.lat - sw.lat) / rows

Screen height is proportional to ln(tan(pi/4 + lat/2)), not to latitude, so equal DEGREE
bands are not equal SCREEN bands. MEASURED on a -60..75 view in 4 rows: the top band is
37.0% of the true rendered height and was being painted into 25% of the poster; the next
19.7% into 25%; the bottom 25.2% into 25%. Every row is squeezed or stretched, which is
the wrong aspect, and the bands where a degree buys the most pixels are the most
over-represented -- the Antarctica the Driver saw.

Sliced in Mercator y instead, every row is 25.0%, exactly, because that IS the screen
coordinate.

★ THIRD PLACE TODAY FOR THE SAME MISTAKE. A whiteboard chart nearly shipped with a linear
scale into degrees; a measurement harness nearly reported a false threshold by correcting
the wrong axis; and this has been shipping. Treating latitude as a screen coordinate is
the error, the conversion is one line, and it now lives in library/maps/mercator-slice.js
rather than being rediscovered a fourth time.

check-mercator-slice asserts the PROPERTY across five view/row combinations -- every band
an equal share of rendered height, contiguous, covering the span, north-first -- and its
CONTROL is the old code: degree-linear slicing must FAIL the same assertion, which it does
by 12.0 points. Without that control the test would pass on a tree where nothing changed.
A -90..90 view clamps at the Mercator limit instead of going infinite.

NOT FIXED HERE, because it is a product decision and not a defect: the export also asks
for an 8.5x11 PORTRAIT sheet (research-map.vue:1410), so a landscape map is letterboxed
onto a tall page. That is a deliberate default, it is the other half of the wrong vertical aspect, and changing the poster's default shape is a product decision, not a fix.
2026-09-28 17:53:38 +00:00
Doxa Maps
903fc5ef76 Eight headless checks from this batch run in gates:pre; check:selection-kinds is registered
They arrived as script entries (or, for selection-kinds, a bare file) that nothing collected.
2026-09-28 17:40:35 +00:00
Doxa Maps
5c9c1fd36b Two maps placed their share menu correctly because they were named in a string literal
DRV1650-SHARE-MENU-MOBILE-OFFSCREEN. On mobile the share/embed menu opens off-screen to
the right on Cities with Internet and Gospel Resources, while Simple Research and Prayer
Progress are correct.

THE SPLIT IS NOT A PLUGIN DIFFERENCE. MapControlMenu resolved the area it places inside
with closest('.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]') -- three
hardcoded plugin class names. doxa-simple-map and research-map are on that list. Everyone
else resolved `|| null`, and place() answers a null area by returning an EMPTY STYLE,
which leaves .mcm{position:absolute} at its STATIC position: below-and-right of a button
already at the top right. Off the page.

So the two maps that worked worked because they were named in a string in another
component, and the bug was decided by a literal rather than by geometry. It is also the
SAME failure the 2026-09-22 dashboard fix documents twenty lines below -- not "a wrong
side was chosen" but "nothing chose anything" -- returning through a different door.

MEASURED: cities-internet, gospel-resources, key-cities and upg-neighbor carry no
map-area class and no data-map-area, so FOUR plugins were unanchored. The Driver reported
two. A fix aimed at the two he named would have left the other two, and a gate written
around them would have passed.

The named area is still preferred, because it is a real map's walls and a menu must not
outgrow them. When a plugin declares none, the anchor falls back to the toolbar's own
offsetParent -- the containing block this menu is absolutely positioned against, so
placing against it is correct by definition rather than by registration. A plugin that
adopts a class later simply gets the better anchor; nothing has to be registered anywhere
for a menu to be on screen.

check-map-menu-anchor asserts the PROPERTY rather than the list: that the resolution
cannot produce null from a real toolbar, that place() still guards a genuinely absent
one, and that a declared map area is still preferred over the fallback. Negative-tested:
restoring the allowlist-only resolution turns it red.

NEEDS EYES: this is rendered geometry and I work headless. What I can prove is that the
anchor is no longer decided by a name; whether the menu now sits correctly at 390px on
all four is rega's to measure.
2026-09-28 17:40:34 +00:00
Doxa Maps
3d7ad038a9 A relative share link works when you click it, which is why nobody caught it
DRV1650-SR-EMBED-URL-RUNTIME-ORIGIN. The Driver copied
`research-map-plus-grid/research-map-plus-grid/index.html` from a page at
http://127.0.0.1:5192/, a link that works on no other site.

resolveShareUrl's tier 1 returns its input VERBATIM, correctly: a host handing over a
whole url has already decided. So a composite that BUILDS a tier-1 url takes on tier 2's
job, and both of mine wrote the same thing:

    base ? base + '/' + leaf : leaf

On any host where the tree root cannot be derived from the script url — a dev server, an
ESM build, a tree served from a path that does not match `<name>/<name>.js` — base is ''
and the else branch hands tier 1 a bare relative path, which it then returns unresolved.

★ IT LOOKS RIGHT IN THE ONE PLACE ANYONE CHECKS. A relative share link WORKS WHEN
CLICKED, because the browser resolves it against the page you copied it from. It is the
EMBED that breaks — an <iframe src="…"> on a partner's page resolves against THEIRS — so
the failure is invisible exactly where it is tested and visible only where it ships.
research-map-plus-grid.vue:174 even documents the requirement ("It MUST be absolute,
which is the non-obvious part") four lines above the branch that violates it.

absoluteEmbedUrl() is tier 2's resolution made available to tier-1 builders: the
configured base when known, otherwise resolved against document.baseURI, which yields the
runtime origin. Never relative in a browser. The ladder is unchanged; only its last rung
is.

The gate has both arms, because either alone would rot: the RESOLVER must be absolute
with no base (the failing case, not the passing one), and no plugin may rebuild the
`: leaf` fallback. Negative-tested: removing the origin fallback, and re-introducing the
ternary in a plugin, each turn it red.

"Copy link didn't work" is most likely the same defect rather than a second one —
ShareButton's copy already falls back from navigator.clipboard to execCommand for
insecure contexts, so the copy almost certainly succeeded and produced a useless URL. If
it still fails after this, it is a separate bug and needs a browser to see.
2026-09-28 17:40:34 +00:00
Doxa Maps
fb769b3e86 The language tree becomes anyone's, and the numbers nobody wrote down get written down
Driver brief §B. gospel-internet's GiTree had NO props and NO emits: it read a board
composable and three module singletons, which is what made it gospel's rather than
anybody's. Everything it reached for is now a prop, and the parts worth testing are no
longer inside an SFC a gate cannot run.

WHAT IS ACTUALLY IN THE COMPONENT: wiring, 160 lines. The drawing is the whiteboard
tiered-points layer, the surface is whiteboard-map.js (moved out of gospel's plugin --
67 lines whose every import already came from library, so it was never theirs), the
hierarchy is language-tree-model.js and the camera is frame-target.js. Four modules,
four headless gates. An SFC cannot be executed by a check, so it should hold as little
as possible.

THREE BEHAVIOURS PINNED BECAUSE THEY ARE EASY TO STATE WRONGLY:

FRAME ON SELECT, NOT ON CHANGE. Clearing does not re-frame; the camera stays where the
reader left it. "It frames when the selection changes" is the obvious phrasing and it is
wrong in exactly the case that annoys people -- being yanked somewhere after deselecting.

A SELECTION THAT ARRIVES BEFORE THE MAP IS REPLAYED. GiTree survived this by ordering:
its host set the node through a composable that waited for data, so the value always
landed after the map. As a PROP the ordering belongs to the host, and a prop whose effect
depends on WHEN you set it is a trap -- a shared deep link would work or not depending on
load timing, which nobody could report. ready replays whatever `selected` is.

HOVER CANNOT MOVE THE CAMERA, structurally rather than by convention: hover sets a filter
on its own layer and there is no camera call on that path to remove later.

AND THE CONSTANTS. gospel's maintainer inherited GiTree and had to read intent out of
structure -- there is no "this took measuring" note anywhere in it. Asked which line they
would flag, they named frame(). So padding 60 (the outermost nodes carry labels that
extend past the point), maxZoom 8 (a two-language family must not fill the screen with
its own neighbourhood gone), duration 900, and the floor zoom that stops framing one node
from zooming the reader OUT are now stated with their reasons and asserted.
Two branch rules with them, both of which a tidy-up would erase: reduced motion is a
DIFFERENT CALL (jumpTo, not easeTo with duration 0 -- the engines do not treat those as
one), and a single point CENTRES rather than fitting a degenerate box, because a
one-language family is ordinary and fitBounds on no extent zooms to nothing.

THE ROLLUP IS OVER THE READER'S SCOPE. "A family shows the worst colour below it" is only
true relative to what is currently on the board: a family that is red overall can be
green inside a filter. Handing the rollup a whole subtree makes the colours quietly
disagree with the board -- no error, no blank, just a wrong fact about a real people
group. Asserted in both directions, and colorFor stays injectable because nothing in the
drawing depends on the rollup being worst-of.

THE CLICK TOGGLE MOVED TO THE HOST, and that is a change rather than a codification:
GiTree cleared the selection when you re-clicked the selected node, and that toggle lived
in the component's own handler. A host that wants it must now write it. In exchange the
chart holds no selection state.

gospel's UI is not wired and their boardEngine.js is untouched on main; they adopt on
their own schedule.
2026-09-28 17:40:34 +00:00
Doxa Maps
d3ee0bb6ec The whiteboard is a layer of tiered points; only the edges were ever a tree
Driver brief §B, the seam under the language tree, so gospel's set charts inherit zoom,
label priority and tiling from the map engine instead of re-implementing them in SVG.

READ FROM GiTree RATHER THAN ASSUMED: six layer groups, exactly ONE hierarchical. The
parent->child links are sourced from linkFC; the points, the selection and hover rings,
the dish behind a cluster and EVERY label layer come from one point collection and know
nothing about trees. gospel re-read that independently and found the same split, which
is the only reason I trust it -- "the seam is generic" is the claim that should never
travel on one reader.

So library/charts/whiteboard/tiered-points.js takes a point FeatureCollection whose
features carry id, label, tier and priority, and gives each TIER its own symbol layer
with its own minzoom. That is the whole of "band names zoomed out, ring names mid, group
names in": the engine is told which labels may exist at which zoom and resolves the
collisions itself. Paint expressions pass through unread -- the layer has no opinion
about what a colour means, which is what keeps it reusable. Selection and hover are
FILTERED layers, so hover costs one setFilter and structurally cannot move a camera:
there is nothing in here that could.

AND THE PROJECTION IS NOT A SCALE, which is the part that would have shipped wrong.
gospel asked for degrees and planned to scale by one number; their charts are CONCENTRIC
RINGS, and Mercator stretches vertically by 1/cos(lat) -- 1.0154x at 10 degrees, 1.1547x
at 30 -- so a linear scale renders rings as OVALS, worst at the outer ring where the
labels a reader compares are. library/maps/whiteboard-projection.js is the inverse
conversion, EXTRACTED from build-language-tree.mjs rather than written, because the
second copy was about to be made by hand. The degree-height it emits shrinks from 0.9996
of the width at the centre to 0.6794 at the edge; that shrink IS the correction the
renderer then cancels.

TWO TESTS, EACH GUARDING A DIFFERENT RISK.
check:whiteboard-projection reproduces the old private implementation VERBATIM as a
reference and compares over 1,734 grid points EXACTLY -- the language tree is a
build-time artifact, so a rounding shift would move 4,000 coordinates and look like
nothing. It then asserts a ring renders round at the inner, middle and outer radii, AND
that a linear scale FAILS that same assertion, because an assertion the defect can pass
is measuring nothing.
check:tiered-points runs gospel's CENTERED SET through the layer: 2,079 points, 3 tiers,
priority by population, and NO EDGES. The absent links layer is the evidence -- if the
whiteboard draws this chart with no line layer anywhere, edges are separable in fact
rather than in my reading. The map is a recording fake, so the assertions are about what
the module DOES; a regex over the source would pass on a module that never called
addLayer.

Negative-tested: making the projection linear, changing its rounding, dropping the label
priority, or giving every tier the same minzoom each turn one of them red.

gospel's UI is NOT wired -- their component is theirs to write, later, against a module
that now exists. Their boardEngine.js stays where it is until the chart lands.
2026-09-28 17:40:34 +00:00
Doxa Maps
5ab4af2e44 Five plugins were silently monolingual, because the bootstrap was copied sixteen times
Goal (c), and it turned into a goal (d) fix. Every profile element is
defineCustomElement(ProfileLoader, { configureApp }), and the configureApp has four
steps. The fourth, app.provide('appI18n', i18n), is the one that gets dropped.

ProfileLoader injects that instance (ProfileLoader.vue:176) to drive its locale from
profile-config. Without the provide the watcher holds null and the drive NEVER RUNS --
while setRuntimeLocale() on the next line still fires, so tStatic strings DO follow the
host. The element renders a convincing MIXTURE of the reader's language and English, and
nothing throws, because the t() shim falls back to its key. It reads as "a few strings
not translated yet".

SHIPPING THAT TODAY: country-posters, key-cities, gospel-resources, cities-internet and
my-upg-100-list. regions-plugin censused all 19 entry files independently and found the
same five and no sixth. The measurement is regions-dashboard's, from when they hit it:
a Spanish catalogue present, es set, and every t() string still English.

All sixteen entries now call library/elements/defineMapElement.js, so the five are fixed
by construction rather than by five separate edits. Behaviour is otherwise identical --
same options, same element names, same per-plugin extras. wall-of-people-groups needed
the helper to hand its pinia back, because it takes a store off its own instance and a
helper that hid pinia would have given it a second one: two stores and a silent state
split.

★ AND THE CENSUS THAT RECOMMENDED THIS SAW FOUR OF SIXTEEN. My detector hashed function
BODIES; eleven sites are not `function buildXElement()` at all, they are
`const El = defineCustomElement(...)`. I wrote "every number here is a lower bound" in
the census as a caveat and it was the headline within the hour -- a 4x undercount of the
item it recommended. So this gate greps for the SHAPE, not for a known list: a check
written around the four that matched would have passed the twelve that did not.

check-element-bootstrap has two arms. No entry may build a profile element by hand, and
the helper itself must provide appI18n -- PLANTED as a negative case, so the regression
cannot return by editing library instead of a plugin. It also fails if it finds fewer
than ten entries, because a detection that stops matching reports the same green as a
tree with no problems.

THE PLANT CAUGHT TWO BUGS IN THIS GATE BEFORE IT CAUGHT ANYTHING ELSE. The predicate
matched the helper's DOCSTRING, so it would have passed a helper that only mentions the
provide in prose -- comments are stripped now, because a check that reads comments is
reading the author's intentions rather than the program. And the plant itself used /m
instead of /gm, so it deleted the documentation and left the code, then reported that it
could not detect an absence that was not there.
2026-09-28 17:40:34 +00:00
Doxa Maps
ca5ce4acd5 check:profile-table runs in gates:pre 2026-09-28 16:49:01 +00:00
Doxa Maps
05ceeb2289 check:i18n-keys-exist and check:packet-variants run in gates:pre
i18n-keys-exist is green now that the panel keys and legend.noList exist, so a key named in code
without a catalogue entry can no longer land silently. packet-variants arrived unregistered.
2026-09-28 16:49:01 +00:00
Doxa Maps
b33006f028 check:composite-token-url and check:language-tree-layout run in gates:pre
Both arrived as files with no script entry, so nothing ran them. Both are headless and fast.
2026-09-28 15:52:47 +00:00
Doxa Maps
fd7c65e591 Collect the packet-charts check, not merely register it
The previous commit added the script and its description but did not put it in
gates:pre, so it passed only when run by hand. That is the exact failure this
project has been naming all week, committed by the person who wrote the gate for
it. gates:pre now reads PASS 22 of 22 and names check:packet-charts at 781ms in
its own output.
2026-09-28 15:01:19 +00:00
Doxa Maps
8c84838c78 Packet chart arithmetic, and the three ways it was quietly wrong
These numbers print. A packet is handed to a reader on paper: no hover, no
tooltip, no correction, no redeploy. So the arithmetic lives in its own module
and is asserted against the shipped data rather than a fixture.

DOXA IS NOT A SLICE. Of its 2,107 groups, 2,082 are unreached and 1,745 are
frontier, so 25 are not unreached at all and 362 are not frontier. That is one
group in six. A chart drawing DOXA as a fourth band nested inside frontier
misstates 17.2% of the list with a clean shape and no error of any kind, which
is why steps and overlays are different props and an overlay reports whether it
is a slice rather than being assumed to be one. upg100 asked for the numbers to
reach the caption as text too, because a printed band nobody can hover over
explains nothing.

Three bugs I wrote, each caught by an assertion rather than by review:

The publisher warning sat in the wrong function. I put "Joshua Project counts
16,426 where IMB counts 12,379" into the nesting error, and the test proved the
claim false: 16,426 on top of 12,379 is perfectly monotonic. The two are not the
same kind of count, and no comparison of magnitudes can see that, so it needed a
separate check reading what the numbers are OF. An error message naming a cause
its own code cannot detect sends the next reader to look for the wrong thing.

A percent read as a fraction filled 3,189 cells of 5,200 instead of 32. My
heuristic was "above 1 means percent", and pctEvangelical is 0.6133, which is a
valid fraction and a valid percent with nothing in the value to separate them.
The refusal I added catches a percent of 1 or more and provably cannot catch
ours, and every share this packet prints is below 1% because the subject is
unreached peoples. So the guard is not the protection, the name is:
fractionFromPercent states the unit in the source line. Both halves are
asserted, including the half that does not work, because a guard believed to
cover more than it does is the worse failure.

And the rounding flags were untested until the test used values that could
reach them. 0.6133% of 5,200 is 31.9 cells; the reader sees 32 and cannot see
31.9, so the exact value goes in the caption and a share too small to fill one
cell is flagged rather than drawn as one.
2026-09-28 15:01:19 +00:00
Doxa Maps
0f0a01372d check:one-scroll-container joins gates:pre now that it is green
The gallery pins plugins with min-height:0 (sr) and gospel-internet's .gb moved its floor to :host
(on main), so no plugin asserts a floor a short pane could not hold. From here it guards against
the second scrollbar coming back.
2026-09-28 15:01:19 +00:00
Doxa Maps
c99efff8a6 check:describe-group renders from the real catalogue too, and runs in gates:pre
Catalogue arm by the UPG-100 owner: one row per GSEC level through describeGroup against the
shipping packet-strings.json, in en and ar, failing on any empty sentence. The existing arms test
the function on fixture strings; this one tests that the catalogue can feed it. It lands with the
ten keys, so it arrives green.
2026-09-28 15:01:19 +00:00
Doxa Maps
9fddcb9901 min-height clamps height, so the pane could not hold the plugin it was pinning
The Driver's least favourite bug: on DX Life's research page a capability-4 plugin
with tabs at the top had a second, element-level scrollbar, and scrolling it scrolled
the TAB HEADER out of view.

doxa-maps-spa pins each fixed-viewport plugin with position:absolute;inset:0;height:
100% and its own comment states the invariant: "fills the pane exactly, so
scrollHeight===clientHeight and overflow:auto paints NO scrollbar." CSS never granted
that. A used height is CLAMPED between min-height and max-height, so a child's own
min-height BEATS the height:100% the shell sets -- it is a floor, not a preference.
Wherever the pane was shorter than that floor the pinned element grew taller than the
pane it was meant to fill, .dms-slot grew with it, and .dms-pane's overflow-y:auto
became a real scroll container wrapping the WHOLE plugin, tab strip included. That is
the second scrollbar: not the plugin's content scroller, the PANE scrolling the plugin
bodily. The invariant was written as a description and was only ever a hope.

The shell now pins with min-height:0. An inline style outranks a child's :host rule,
so that one property restores the invariant for every fixed-viewport plugin at once
without editing any of them. It is NOT added to the scroll-stack branch, where
natural height is the design.

A floor set INSIDE a child's shadow root is out of reach from the shell, and two of
mine had one. research-dashboard's .rd-shell and big-picture's .bp-root now say
min-height:inherit, deferring to :host -- which the gallery pins to 0, and which a
naive page leaves at its own floor, so the collapse guard those floors exist for is
kept rather than deleted. big-picture's 480px MOVED UP to :host rather than being
dropped: :host had no floor there, so inheriting would have quietly resolved to 0
everywhere and thrown away the black-screen guard its own comment calls load-bearing.

check-one-scroll-container asserts both halves, because either alone rots: the shell
still pins with min-height:0, and no plugin grows a NEW floor inside its shadow root.
The second half is what matters later -- a fixed shell plus a plugin that adds
min-height:700px next month is this bug again and nobody would connect it.

The rule is a CONTRADICTION IN ONE DECLARATION, not a large min-height by itself: an
element saying height:100% ("I fill whatever holds me") while refusing to be shorter
than N. My first version flagged any big min-height and caught gospel-resources'
.gd-map, a map deliberately sized height:80svh that scrolls inside its root's own
content scroller -- the one scrollbar a plugin is allowed. Flagging it would have sent
its owner after a non-bug, which is worse than not having the gate.

Negative-tested: removing min-height:0 from the shell, and reverting either .rd-shell
or .bp-root to a constant, each add a failure; restored, only the known one remains.

RED ON PURPOSE, and deliberately NOT in gates:pre. gospel-internet's .gb still carries
min-height:640px and that fix is its owner's. Wiring a red gate into gates:pre would
block the fleet on someone else's file, so the gates:pre entry belongs in the same
landing that fixes .gb -- until then this gate is the work item, afterwards the guard.

Source only: doxa-maps-spa, research-dashboard and big-picture need rebuilding, which
is the build owner's to do.
2026-09-28 15:01:19 +00:00
Doxa Maps
4b7f65204f Collect the gospel set-chart layout check in gates:pre
The check itself shipped with the plugin modules it covers, but the line that
runs it existed only in a message, which is not a place code lives. So it sat in
internal/ and would never have run — the same failure it was written to prevent,
one layer up.

This is the whole registration in one commit: the file, the script entry and the
word in the gates:pre list. It imports boundedSet.js and centeredSet.js, so it
must land after the plugin commits that add them; landing it first fails
collection and takes gates:pre red for everyone.
2026-09-28 15:01:19 +00:00
Doxa Maps
6b6c745dbe Make the zone-join assertions travel, and record what each guard buys
Both joins were verified while they were written, and that verification lived in
a terminal. A probe proves something once; only a registered check keeps
proving it. Collected by gates:pre, where it runs as check:upg-zone-joins.

Both defects it guards are silent. Neither throws, neither renders an error, and
both return a well-formed answer that is merely smaller than the truth: admin
names whose diacritics are not folded join 514 of 1009 rows instead of 1009, and
records passed in the pre-normalize { value, label } shape match nothing at all,
so every zone reports as having no groups.

Each arm is checked in both directions, and the negative arms are the point. The
check records that the fold is worth 495 rows and that the object shape resolves
zero zones, so a later reader asking whether a guard is still needed gets a
number rather than a green tick. It also fails if the unfolded path ever stops
losing rows, because at that moment the assertion would no longer be able to
fail and would stop being evidence.
2026-09-28 15:01:19 +00:00
Doxa Maps
29c9551d30 check:selector-fields in gates:pre: a packet fetch must carry every field its selectors read
Written by the UPG-100 plugin owner. It reads each selector's fields from the evaluator itself,
so it cannot encode a wrong list of zones. It fails on the fetch before cdc788fbf, which left
eight zones' packets empty, and passes after it; both land in this push.
2026-09-28 13:56:53 +00:00
Doxa Maps
c86aa72bd2 check:describe-group runs on demand 2026-09-28 13:56:06 +00:00
Doxa Maps
4a80af39b6 Pin what the vertical legend's bucket buys, and register the check
Six more assertions, and a package.json entry so gates:pre actually collects
this instead of it only ever passing when I run it by hand.

Two facts the legend contract left implicit, both of them the quiet-wrong-answer
shape rather than a crash:

Ticking every REGION does not select every ZONE. The four unfiled zones have no
region by definition, so a reader who selects all seven regions still does not
have xinjiang and its hundred groups — and nothing on screen says so, the total
is just smaller. The bucket makes those zones reachable; it does not make them
reachable by a region sweep. A select-all must therefore sweep zones, not
regions, and this fails if one ever sweeps regions.

A zone whose members do not resolve must render EMPTY, not disappear. Members
resolve for sixteen of thirty-three today; five await a field and nine have no
selector anywhere. A tree that hides what it cannot join tells the reader the
zone does not exist, when the truth is only that we cannot currently join it.
Presence is by id, never by member count.

Both arms were falsified before being trusted: filtering zero-member children,
and mislabelling the bucket as an ordinary region row, each turn four of these
green. The negative control still carries the rest — it asserts the unaugmented
tree really does drop xinjiang, so the bucket is demonstrably load-bearing
rather than decoration.
2026-09-28 13:56:05 +00:00
Doxa Maps
dbbcd5bc45 check:frozen-labels in gates:post: a label resolved once never re-resolves on a locale switch
Written by the UPG-100 plugin owner. It parses templates and scripts and flags a translator
result stored at module or setup scope, or tStatic inside a computed. A production profile or
library/ fails; an experimental profile is reported. It found the search header's eight labels,
fixed in the commit before this, so it arrives green.
2026-09-28 13:37:44 +00:00
Doxa Maps
7b5887ef3f check:hardcoded-english's note: 179 with cross-area keys, 28 self-test assertions 2026-09-28 12:04:36 +00:00
Doxa Maps
d589edb3a2 check:hardcoded-english's note records the tuned count, 178 2026-09-28 12:04:36 +00:00
Doxa Maps
198fd0f090 check:hardcoded-english's note records today's count, 373, with interpolations scanned 2026-09-28 12:04:36 +00:00
Doxa Maps
ca1dc11565 check:hardcoded-english runs on demand; its self-test runs in gates:pre
Not in gates:post: it reports 346 today, most of them gospel-resources prose. It joins when
that reaches zero or every hit is carded.
2026-09-28 12:04:36 +00:00
Doxa Maps
3d2c16a707 check:facet-shape tests the facet gate's injected helper headlessly and runs in gates:pre
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
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.
2026-09-28 10:27:43 +00:00
Doxa Maps
a98cbe8055 check:facet-changes-map — measure MapLibre too, not just Mapbox
BUG-FACET-GATE-CANNOT-MEASURE-MAPLIBRE. The gate read source._data four times,
and the two engines store it differently -- cited from the bundles this repo
loads, the same constructor in each:

  mapbox-gl v3.24.0   this._data = i.data                          the raw GeoJSON
  maplibre-gl         this._data = "string"==typeof i.data
                                   ? {url:…} : {geojson: i.data}

A GATE blind to MapLibre passes a map that is broken there, which is worse than
a plugin carrying the same bug.

★ ALL FOUR READS WERE BROKEN, NOT THREE. The census called :145 "guarded" because
it had `s._data || (s.serialize && s.serialize().data)` -- but `||` fires only
when the left side is FALSY, and {geojson: …} is TRUTHY, so the fallback never
ran. The wrapper has to be UNWRAPPED, not fallen back from. That correction is
the reason this is a shared helper rather than a copied one-liner.

__srcFeatures() goes in the WALK bundle -- the in-page script the gate already
injects -- NOT as an import of library/maps/pin-selection.js: all four call sites
run inside page.evaluate / page.waitForFunction bodies, which are serialised to
the browser, where a Node-side import does not exist. Same logic as
readSourceFeatures(), duplicated across a process boundary, which is the one
place duplication is not a smell.

A remote (url) source returns null, not 0: null means "could not measure", while
0 would mean "the map drew nothing", which is the finding this gate reports.

THE MAPLIBRE SKIP IS RETIRED. It exited 0 while announcing SKIP -- NOT MEASURED,
correct for the limitation but a gate that never looked at half the product. A
source present with an unreadable payload is now a real failure with a
diagnostic, because the known cause of that state is fixed.

check:facet-shape negative-tests the helper headlessly against both engine
shapes, in sr's check-pin-selection style, evaluating it OUT OF THE WALK STRING
so the thing tested is the thing injected. 5/5. Poison-tested: with the unwrap
removed, ONLY the maplibre case fails (4/5), so it catches the regression and is
not indiscriminate.

internal/ is the build owner's under rule 5B -- source sha to studio. Running the
gate itself launches a browser and is rega's.
2026-09-28 09:48:45 +00:00
Doxa Maps
36887f4f28 gates:pre runs check:pin-selection, the headless test of both engines' GeoJSON source shapes 2026-09-28 08:31:24 +00:00
Doxa Maps
f44da45258 A test for the source reader, because a no-op cannot be seen any other way
dx-shepherd asked for this with a7b12b44 and the reason is exact: every caller of readSourceFeatures is
written to do NOTHING when it finds no features, deliberately, so that a legend row for an off-screen
region degrades quietly instead of throwing. That is why the MapLibre bug produced no error, no console
line and no visual difference except a camera that did not move — and why it survived until a packet
printed the wrong 50 people.

check:pin-selection drives the reader with fixtures for both engines and no map at all:
  · the MAPBOX shape (_data IS the FeatureCollection) reads its features;
  · the MAPLIBRE shape (_data is { geojson }) reads the SAME features — the regression that shipped;
  · a REMOTE source ({ url }) returns [] AND WARNS;
  · the serialize() fallback works, a missing source is [] rather than a throw, a bare Feature is one;
  · selectedPinIds ANDs its expressions, and with none returns every pin rather than none.

★ NEGATIVE-TESTED, which is the only thing that makes it worth having: run against the reader as it
shipped, it fails with exactly four findings — the maplibre shape reads 0 instead of 3, selectedPinIds
narrows to nothing, "no expressions" returns nothing, and the remote case returns [] silently. Against
the fixed reader it passes. A test that cannot fail on the code it was written for proves nothing.

★ AND THE REMOTE CASE IS NOW A DECISION, not an accident. dx-shepherd asked what { url } should do. It
returns [] and warns once per source: the features live on a server and cannot be enumerated locally, so
"I cannot read this" is a different fact from "there is nothing here". Collapsing those two is precisely
how the MapLibre bug hid for as long as it did, and no caller can tell them apart from an empty array.

Verified: check:pin-selection, check:mjs-parse, check:doc-scripts, check:package-json-unique-keys all
PASS. gates:pre was NOT run clean here — my main clone sits three landings behind origin/main, so a
newer package.json lists check:targets while that file is absent from the working tree; that failure is
my verification setup, not this change, and I checked it on an untouched tree before reporting.
2026-09-28 08:31:19 +00:00
Doxa Maps
114df6f6c4 check:studio-rail — a fixture self-test for the declared-label rule
The rule is extracted as a pure undeclaredLabels(manifest) so it can be tested
over a fixture without standing up Vite or needing a built tree, and runs before
that setup so `--self-test` works on a fresh clone.

Four planted cases, and the SECOND is the one that matters -- a rule that fails
everything is as useless as one that fails nothing:
  production + no label          must FAIL
  experimental + no label        must PASS   (the stage scope is the design)
  production + a real label      must PASS
  production + whitespace-only   must FAIL   (`label: "   "` satisfies a null
                                              check and still renders blank)

Wired as check:studio-rail:self-test. Verified the extraction changed no
behaviour: the live gate still reports the same 8 violations it did before.

It exists because the first version of this rule PASSED on a manifest that
contained the very defect it was written for -- humanize(id) meant the rendered
label was never empty, so nothing could fail.
2026-09-28 08:31:18 +00:00
Doxa Maps
fbc517c94a A browser gate refuses to launch outside the browser slot; check:browser-gates-guarded keeps it that way
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.
2026-09-28 08:20:21 +00:00
Doxa Maps
6451a2b158 bun run desktop:build <plugin> packages a desktop plugin as an app; check:targets keeps non-web plugins out of the web build
A plugin's <profile>.meta.json targets (web, mobile, desktop, widget; absent = web)
now decide where it is built. internal/plugin-targets.mjs is the one reading.
desktop:build builds a desktop plugin into .desktop/<plugin>/ with a page that
names its engine, builds its backend as a Tauri sidecar for this machine, and runs
tauri build with a --config override; tauri.conf.json and public/ are untouched.
The shell starts the bundled rudder-engine (RUDDER_AUDIO=browser, RUDDER_HOME in the
app data folder) and stops it on exit. A plugin whose targets leave out web
(Rudder: desktop + widget) is no longer built for the web tree: run-parallel,
generate-tree and check-composite-freshness skip it, and its public/ folder is
removed; the dev server still gives the Studio its corner. check:targets
(gates:pre, with a self-test of five planted violations) validates the values,
widget corners and backend folders and fails if a non-web plugin reaches the built
tree. Measured: desktop:build rudder-voice on Linux produced Rudder_0.1.0_amd64.deb
(8.8 MB) with the app and the engine side by side; the app has not been launched.
2026-09-28 06:44:14 +00:00
Doxa Maps
6778a0828f A poster button opens the print dialog by default; downloading is an explicit, explained opt-in
Driver rule 2026-09-28: an export-poster button opens the print dialog with a
preview, never a direct download. PosterWall defaulted posterAction to
'download', so country-posters and key-cities, which set nothing, downloaded.
The default is 'print'. check:poster-action-print (gates:pre) keeps the default
and fails a consumer that opts into 'download' without a
"posterAction-download: <why>" comment. PDF packets and CSV exports are not
poster buttons and stay downloads.
2026-09-28 04:41:00 +00:00
Doxa Maps
12d7f218f3 posters: a SERIES parameter, so a country poster stops rendering the UPG-100 packet
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The country posters shipped carrying UPG-100 framing: the UNENGAGED PEOPLE
GROUPS kicker, 'This packet is 1 of {n} UPG-100 lists', the Project
2000/7000/4700/700 narrowing funnel, and a below-map block telling the reader to
divide the set among THIRTY disciples -- coherent for ~30 UPG-100 zones and
incoherent for ~170 countries. Reported by the Driver.

Not a mislabel: there was NO series parameter anywhere, so 'the packet' and 'the
UPG-100 packet' were one thing and nothing could disagree.

series.json declares each series' funnel flag, string overrides and the three
below-map NETWORKS blocks. Both packet.mjs and generate.mjs read it, because the
leak was in TWO independent places -- the packet strings and the poster's own
furniture, which is not a packet string at all, so fixing either alone leaves the
other printing. SERIES absent or unknown resolves to upg100.

The funnel page drops for country: it narrows every people group on earth to THIS
list, which is not a description of a country. Reusing the existing
missing-vision-funnel path means one way for the page to be absent, not two.

Four packet keys are overridden, not seventeen: 12 of the 17 are funnel_* and go
with the page; the 12 prayer points are invariant, because they are about a
PEOPLE and a country poster still prays for the peoples within it. Overrides
apply to every locale on purpose -- an English country kicker is visibly awaiting
translation, whereas inheriting the translated UPG-100 kicker prints the wrong
series fluently.

The country English is a DRAFT from the Driver's dictated shape, flagged
_draft_awaiting_driver; no translations until he signs off.

check:series-isolation asserts the property, with a self-test over 6 known-bad
inputs. It caught a real leak this commit would otherwise have claimed fixed: the
packet's page 1 lifts the whole SVG from the pre-rendered poster, so a country
packet built against a stale UPG-100 poster still printed 'Thirty disciples'.
Verified at the rendered layer -- country 11 pages, upg100 12, the delta exactly
the funnel page; upg100 networks byte-identical to the text they replaced.
2026-09-28 04:19:27 +00:00
Doxa Maps
2e92a05b30 A preset keeps its profile's defaults: instance props merge over the meta config instead of replacing it
A preset page baked only the instance's props, so every default in the profile's
.meta.json config that the instance did not repeat was lost (go-int: upg-neighbor's
five presets dropped pinHint:false). instanceHtml now takes the meta config first
and the props over it. check:preset-config-merge (gates:post) reads every built
preset page and fails on a lost default; the rule is written into
PRESET-PLUGIN-STANDARD.md. Affected before the fix: upg-neighbor only; no
production preset has profile defaults to lose.
2026-09-28 03:12:45 +00:00