Commit graph

2642 commits

Author SHA1 Message Date
Doxa Maps
16fd9c6207 Pin the whiteboard geometry, and record what the poison arm disproved
Three properties, each with the arm that catches its own defect: the bounded
set's gap survives projection, the drawing stays inside the Mercator clamp, and
a radius expressed in map units doubles with every zoom level.

The first arm taught me something I had assumed the opposite of. It narrowed the
gap to a tenth of a percent expecting the margins to go negative. They stayed
positive: the projection is conformal, so it preserves the ratio exactly, and a
narrow gap survives just as an eight percent one does. There is no distortion for
it to be eaten by.

So "the gap survives projection" is close to a tautology, and a check resting on
it alone would look like a guard while testing almost nothing. What can actually
fail is the ordering of the constants, and that is what the arm now poisons. The
narrowing case is kept as a passing assertion rather than deleted, because the
fact that it is safe is worth stating: the boundary cannot be eroded by the
projection, only by someone editing the numbers.

The clamp arm holds for every chart at once. The ceiling belongs to
extentFraction alone, not to a chart's extent — the scale is extentFraction over
extent and the outermost point sits at the extent, so the product is always the
fraction. Asserted across four extents, identical to three decimals.
2026-09-28 18:57:46 +00:00
Doxa Maps
092585af6c Put the bounded set on the whiteboard too, with its boundary in map units
Both set charts now pan and zoom. The chart still makes one claim — which side
of the line a group is on — so nothing here encodes distance or rank; what zoom
buys is reading the names, because at two thousand dots the inside is a cluster
until you can get close to it.

The boundary is the shared dish layer, which is the same layer a language
family's circle uses, so it is not a new type for either of us. Its radius takes
an exponential-base-2 zoom interpolation rather than a fixed pixel value.
circle-radius is screen pixels: a constant would hold its size while the dots
spread out from under it, leaving the circle correct at exactly one zoom and
wrong at every other — and for a dot near the edge the inside/outside reading
would silently invert, which is the only thing this chart says. Pixels per degree
double with each zoom level, measured at exactly 2.000000 between adjacent
levels, so the interpolation tracks map units exactly rather than approximately.

The boundary is drawn as a line with a transparent fill. A filled disc would say
the inside is a region, when what the chart means is a threshold.

The cross rides in the point collection as its own tier rather than as an
overlay, so it pans and zooms with the drawing instead of floating above it.
2026-09-28 18:57:46 +00:00
Doxa Maps
b68685801e Show the centered set as the whiteboard map rather than a static figure
The panel now mounts the zoomable chart in place of the SVG. Pan, zoom and
labels that appear by tier as the reader moves in were the ask; the engine
supplies tiling and label collision that an SVG version would have to
reimplement, and badly.

The printed component stays SVG, which is not an inconsistency: a sheet has no
zoom and no hover, so paper and screen genuinely want different renderers. What
they share is the ring vocabulary, so the two cannot drift apart in what they
call a ring even though they draw it differently.
2026-09-28 18:57:46 +00:00
Doxa Maps
1163d488b3 Draw the centered set on the whiteboard, so it pans, zooms and labels by tier
The ask changed and so did the call. A static target is fine in SVG; a zoomable
one carrying ~2,100 labels is not. The engine tiles the collection per zoom so it
draws less when zoomed out, and its symbol layers do label collision, which is
the part that would take longest to do badly by hand.

The label staging is one layer per tier with its own minzoom: band names when
zoomed out, ring names in the middle, group names in close. None of that is
tree-shaped, which is why it is a shared layer rather than a copy of one.

Coordinates go through the shared projection, never a scale. It is the exact
inverse of the renderer's transform, so a circle stays a circle; a linear scale
would draw concentric rings as ovals worsening outward. The only number this
chart owns is its extent.

Dimming a deselected ring is a paint expression rather than a filter, for the
reason the SVG version dims instead of hiding: a ring emptied of its dots reads
as a ring with no groups, which is the opposite of what a reader selecting it
just asked for.

The layer id is read back from the handle rather than written from memory. The
first version reached for a name the module does not use; inside a try/catch that
does not throw, so the dimming would simply never have happened — a legend that
appears to do nothing rather than an error. Asking the handle means a rename
cannot silently disable it.
2026-09-28 18:57:46 +00:00
Doxa Maps
8390f06465 Project the set-chart features onto the whiteboard, and report the real ceiling
The projection is passed in rather than built here, so the chart and its checks
share one instance and this module still owes nothing to a coordinate system it
does not define. The dish takes its radius from the projection's exposed scale:
deriving it by projecting two points and subtracting would be right on the
equator and wrong everywhere else.

The guard reports the outermost latitude, because that is the real limit and
shape is not. The projection is the exact inverse of the renderer's transform, so
a circle stays a circle at every extentFraction — measured at 0.000% aspect
error. An earlier reading of mine said otherwise and was wrong: it computed
screen x as longitude times cos(latitude), a plate-carree correction Web Mercator
never applies, which manufactured a distortion the renderer does not produce.

What does bite is the pole. The outermost point reaches 52 degrees at the default
0.17, 77 at 0.35, 83 at 0.45, and Web Mercator is undefined at the poles with
engines clamping near 85.

That ceiling is a property of extentFraction alone, not of a chart's extent:
the scale is extentFraction over extent, so the outermost point is at
extentFraction whatever the extent, and every chart reaches the same latitude.
Measured across extents 100, 228, 260 and 1000 — identical to three decimals. So
a consumer cannot buy headroom by choosing a bigger extent, and a check written
against one chart's ceiling holds for all of them.
2026-09-28 18:57:46 +00:00
Doxa Maps
ff9edd25f7 Shape both set charts as tiered points, and leave the projection to the caller
The shared whiteboard layer draws a point collection where each feature carries
an id, a label, a tier and a priority, and stages its label layers by tier and
zoom. This decides which features exist, what tier each belongs to and how they
rank — which is this chart's business — and it does not convert to degrees.

Ring and band labels are placed on the radii the chart already draws rather than
on a second calculation, so the label and the ring it names cannot drift apart.
Groups rank by population, so when two names collide the larger people wins: a
reader zoomed out should meet the names carrying the most people, not whichever
was drawn first. Bands outrank rings outrank groups, because that is the order a
reader zooming in meets them.

Coordinates out are abstract on purpose. Converting them is a projection, not a
scale — the whiteboard shrinks degree height outward so the map's own stretch
cancels it, and a linear scale skips the cancellation. Measured on a ring of 720
points: with a linear scale the rendered aspect is 0.9968 at the inner ring and
0.7952 at the outer, so the target draws as ovals that worsen outward, worst
exactly where the ring labels sit. With the projection it is 1.0000 at every
ring.

That measurement also settles where the guard belongs: at the inner ring the
linear error is three parts in a thousand and would pass any sane tolerance. An
assertion placed there alone would be inert against the one mistake it exists to
catch. It goes at the outer ring, and at both.
2026-09-28 18:57:45 +00:00
Doxa Maps
eb0ef2b3a0 Rebuild wall-of-people-groups, and regenerate the tree
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
2026-09-28 18:53:16 +00:00
Doxa Maps
fcad0dc1a0 Wall of People Groups: comment times and the unnamed-group fallback in the reader's language 2026-09-28 18:52:57 +00:00
Doxa Maps
8dcadc16d1 The country list never received the configured research URL
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Found by the bug-class sweep. RegionCountryMap is passed :research-url; the
UupgsList beside it is not — zero research mentions in its entire tag. So a host
setting profile-config.researchUrl got it honoured on the map popup's Full
Profile button and silently ignored by every profile link in the country list,
which fell back to a hardcoded doxa.life default.

The shape is worth more than the line: one config, two consumers, only one
bound, and the unbound one carrying a plausible default. A missing binding that
fell back to an empty string would have rendered a visibly broken link; this one
renders a working link to somebody else's site, which is why it survived. It is
invisible in either file alone — the parent's tag list has to be read against
the children's props.

One line. Bundle rebuilt; the binding is in the artifact.
2026-09-28 18:49:16 +00:00
Doxa Maps
a45f1914a6 Let the legend collapse on desktop, the way it already does on mobile
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Mobile has had a fold control since the narrow layout existed; desktop had none,
so a reader who wanted to see the map underneath had no way to move the legend
off it. The state was already there — every section read "not narrow OR open", so
on desktop the OR was always satisfied and the flag governed nothing. Now the
flag alone decides at both widths.

The control is deliberately not a new one. Same 22x20 caret, same rotation, same
aria contract as the shared SemanticTreeLegend's collapse button, which the
mobile legend also copies. Matching it costs nothing and means a reader who has
met one of these legends has met this one.

Collapsed, the panel shrinks to the caret. Hiding the contents alone would have
left a 380px box of empty background over the map: the reader presses collapse
and gets a blank panel instead of their map, which is worse than having no
control at all.

The choice is remembered for the session, so it survives working with the map but
not a fresh visit, and a remembered choice beats the width default — otherwise
dragging a window across the breakpoint would silently reopen a legend that had
been deliberately closed, undoing a decision rather than responding to one.
Storage access is wrapped: a sandboxed embed throws on it, and a legend that
cannot remember is much better than a map that will not load.
2026-09-28 18:45:13 +00:00
Doxa Maps
20355ca552 Rebuild country-posters, and regenerate the tree
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
2026-09-28 18:40:44 +00:00
Doxa Maps
a1be5c7db3 Country Posters: a partner's basemap, night look, search switch and start view reach the map inside it 2026-09-28 18:40:21 +00:00
Doxa Maps
c312fc14b8 Rebuild upg-neighbor all-people-groups wall-of-people-groups prayer-walk-planner, and regenerate the tree
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
2026-09-28 18:35:31 +00:00
Doxa Maps
786a834610 Map errors in every language, and print buttons that show they are busy (UPG Neighbor, All People Groups, Prayer Walk Planner, Wall) 2026-09-28 18:34:16 +00:00
Doxa Maps
01b02e3781 Rebuild the consumers main left stale (build owner, README rule 5)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
check:composite-freshness found stale on main: key-cities
template-bundle; also rebuilt: all-people-groups big-picture cities-internet country-posters dashboard-plus-grid demo-map doxa-maps-spa doxa-research-map doxa-simple-map doxa-vision-card golden-path-check gospel-internet gospel-resources my-upg-100-list people-groups-grid plugin-studio pplr-data-maps prayer-walk-planner regions-dashboard research-dashboard research-map-plus-grid stack-demo upg-neighbor wall-of-people-groups. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
2026-09-28 18:22:47 +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
5880e84616 Cards per row on the shared grid, with Auto as the absence of a value
The Driver asked for a top-right dropdown with Auto plus fixed overrides,
remembered, and said plainly that the CSS already working must not break.

So Auto writes NOTHING. The grid is repeat(auto-fill, minmax(240px, 1fr)),
which already gives one column on a phone and three to five on a desktop by
measuring its container; Auto leaves the stylesheet alone and only an explicit
override sets grid-template-columns. An Auto that computed a number and wrote
it back would look identical until the container resized, and then be silently
wrong — the same picture, with the responsiveness removed.

An override is honoured even when it is a poor idea: five columns on a 390px
phone is a 62px card. That is deliberate. The reader asked for it, Auto is the
default and one click away, and the control shows which is active. Clamping
someone's explicit choice to what we think is sensible is how a control stops
being trusted.

It is remembered per PROFILE, not per bundle. Two grids on one page are two
different lists, and useDashboardLayout already paid for that lesson when a
shared key made one caller inherit another's saved layout. Blocked storage is
caught and ignored: in private mode the default is the correct outcome, not an
error to report.

It sits in its own row rather than in #filters, which is gated on
useSearch || useFilters — a host turning both off would otherwise lose a control
that has nothing to do with either. It modifies the grid, so it lives on the
grid.

Both strings are authored in all eleven catalogues, in the snake_case this
catalogue uses: a camelCase key here would have resolved to nothing and rendered
its own name, which is the defect I shipped in a legend earlier today.
2026-09-28 17:53:39 +00:00
Doxa Maps
594a3bbe53 Make the two Mapbox arms ask for Mapbox, and fail when they measure nothing
These land with the change that would otherwise hollow them out. Both gates
reached the Mapbox engine by filling a token into the published snippet, which
worked only while a token selected the engine. It no longer does, and the
snippet now pins provider explicitly, so both Mapbox arms would have run
MapLibre and still passed.

Both now name the engine. The shelf gate REPLACES the provider key rather than
prepending a second one: a duplicate key parses to whichever came last, which is
a coin-flip that happens to work today.

The more useful half is the vacuity check, because the old assertions were all
of the form "no request used the wrong token" — trivially satisfied by making no
requests at all. Each Mapbox arm now fails if it contacted Mapbox zero times.
In the shelf gate the two flags are twins set from one expression, so an arm
cannot be exempt from both: under maplibre zero requests are required, under
mapbox at least one.

Without that pair, the day a Mapbox arm silently runs MapLibre both arms pass
and the gate measures one engine twice.

I cannot run either: both are Playwright and the browser belongs to rega. Syntax
checked, the browser-gate guard still passes across 405 files, and gates:pre is
green — but the behaviour these assert is unverified by me and needs rega.
2026-09-28 17:53:38 +00:00
Doxa Maps
79a566a594 Publish the provider in every embed snippet, so the opt-in is discoverable
The snippet is the only thing a partner ever reads, and before this none of the
36 mentioned provider at all. So when a token stopped selecting Mapbox, the
hosts affected were exactly the ones who had pasted a real token into this
snippet — and they had no way to see the lever that would give it back.

Emitting "provider":"maplibre" restates the new default, which looks redundant
until you ask what the snippet is for. Stating the default is what makes the
alternative visible. A one-line HTML comment above the element names the opt-in.

Generated, so the change is in the generator; the 36 files follow from a tree
run. Verified 36 of 36 carry both the key and the note.

⚠ THIS CHANGES WHAT TWO BROWSER GATES MEASURE, and they are not mine to edit.
check-map-embed-cells and check-experimental-shelf both fill YOUR_MAPBOX_TOKEN
into this snippet to exercise the Mapbox path. With provider pinned to maplibre
they will get MapLibre and make no api.mapbox.com requests at all — so an
assertion like "every api.mapbox.com request carries the host's token" stops
failing because it stops having anything to check. Each needs to swap the
provider to mapbox in the snippet it builds, the way the shelf gate already does
for its leaf URL with ?provider=. Flagged rather than silently left to surface
as a green run that measured nothing.
2026-09-28 17:53:38 +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
9609cd84ba Say "provisional, pending a product decision" instead of naming a row
Follow-up to 50ebdf89, which kept an internal tracking id on the grounds
that a reader needs to know where the answer comes from. The ruling is
that in shipped code the id should go too, and it is right: a row number
means nothing to someone reading packet-render.js, and it points at a
place they cannot open.

What the comment has to carry is that the value MAY CHANGE, so a later
reader does not treat it as settled and hardcode it somewhere. "Provisional,
pending a product decision" says that without pointing anywhere.

Three sites, all mine. CenteredSetTarget.vue carries the same reference
and is not mine; it belongs to the carded fleet sweep.
2026-09-28 17:53:38 +00:00
Doxa Maps
d1d2d1dec5 Two poster comments state the defect instead of quoting its report 2026-09-28 17:53:38 +00:00
Doxa Maps
35dfd82b83 Drop quotes and transcript references from the packet comments and gates
Per the 17:25Z rule: no quotes, no citations and no transcript references
in strings, commits or code. Studio had to strip these from two of my
commit messages during L64, which is a sign I should be doing it at the
source rather than leaving it to whoever lands the work.

Nine sites across five files, all of them lines I added. The engineering
content is unchanged -- what goes is the attribution, not the reasoning.
"A disabled button is visually indistinguishable from an unresponsive one"
is the durable fact; who observed it is provenance a later reader cannot
verify and that ties shipped code to a private transcript.

Where a decision is genuinely still open I keep the tracking reference
("still open, tracked as row 89") but not the speaker, because a reader
hitting that comment needs to know the value may change and where the
answer will come from.

Scope is deliberately my own unlanded additions. packet-render.js,
PosterWall.vue and upg-100-list.vue carry many more such comments from
before the rule; those are not mine and a fleet-wide sweep is a separate
decision. Flagged rather than silently rewritten.

All seven gates green after the edit, including the two whose own header
text changed.
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
1272aa6dc3 Record why the ring legend needs no overflow assertion
gospel landed 21ebb4f2 -- "Stop the ring legend pushing the centered-set
chart off the page" -- which is the same figure and the same legend I had
just added to the packet, so it was worth checking whether the print side
has the bug too.

It does not, and the reason is worth writing down rather than leaving as
arithmetic I happened to do once: a printed sheet is much taller than
their constrained screen pane. With ALL TEN rings named the column needs
~924px of a ~1715px page.

I wrote the assertion anyway and then deleted it, because mutation-testing
showed it only begins to fail at 51 legend rows -- and there are only ten
rings, so it could never fail for any input this code can produce. That is
a tautology dressed as a safety check, the same class as the "rows close
on the bottom margin" assertion already removed from check-profile-table:
a value compared against a bound it cannot reach reads as coverage and is
not.

What actually protects the layout is the empty-ring filter asserted just
above (ten rows is the hard ceiling) plus rega's rendered check. The
comment now says so, including the detail that gospel's fix leans on
overflow:auto and print has no scroll -- so if this page ever does get
tight, their remedy is not available here.
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
addf520dbe An Experimental map has a "?" that says what it is for and how to use it
The button shows only on a discovered tab with an entry in experimentalHelp.js. It opens a modal
dialog titled with the tab name: focus moves to Close, Tab stays inside, Escape or Close returns
focus to the button, and switching tab closes it. close is a new shell string in all 11 locales.
2026-09-28 17:53:38 +00:00
Doxa Maps
d8364d949a Fix two defects in the floating note, one of which I committed broken
The previous commit did not parse, and I pushed it: check:sfc-parse exited 1 and
I read the exit code after the push instead of before it. The gate names this
exact cause in its own failure text — a backtick inside the shadow-CSS template
literal ends the literal, so everything after it is read as code. I had written
the word dark in backticks inside a CSS comment. The gate was right, it was
specific, and it was already telling me.

The second defect had no symptom a gate could show. The note is a direct child
of .legend-sheet-root, and .legend-sheet-root>* re-enables pointer events for
every direct child at the same specificity as the note's own rule and AFTER it,
so that rule won. The note would have swallowed a map drag across the full width
just above the sheet — the precise thing its comment claimed it must never do.
Stated now after the rule that would otherwise override it.

Both are the same shape: I wrote a claim in a comment and did not check the
claim was true of the code beneath it.
2026-09-28 17:53:38 +00:00
Doxa Maps
6970e7b63b Float the pin note above the sheet so the options keep the full 30%
The Driver scrolls further than he should to reach the legend options on a
phone, and the note under the legend is why — but not by pushing them down. It
was the last child of .legend-content, a natural-height sibling of a flex:1
options panel, so every pixel it took was a pixel the options' own scroller
lost. The tree got a shorter viewport and the same rows moved further away.

Moving it WITHIN the sheet would have recovered nothing, which is worth stating
because it was the obvious reading of the instruction: the sheet's open height
is fixed at 30%, so anything inside it costs the options the same wherever it
sits. Out here the note costs them nothing and they keep the whole sheet.

It is positioned off --legend-sheet-h, which a ResizeObserver already writes for
the engine's own bottom controls, so it tracks the sheet as it opens, drags and
collapses instead of assuming a height. It stays pointer-events:none with the
sheet root: it is text, nothing in it is tappable, and it must never swallow a
map drag.

The note is kept, not removed — he accepted the text and objected only to it
breaking the legend. Its scrim follows `dark` for the same reason the note
itself takes that prop: a white halo behind light text is a smear rather than
legibility.

NEEDS EYES at 390x844: whether it reads over an arbitrary basemap, which is the
one thing moving it out of a white panel puts at risk.
2026-09-28 17:53:38 +00:00
Doxa Maps
b9d78e8926 The paper turns to match the map, because a map is wider than it is tall
Follow-up on DRV1650-SR-POSTER-EXPORT: the print came out vertical, where a horizontal map fits the shape better.

The export asked for 8.5x11 portrait unconditionally, so a landscape map was letterboxed
onto a tall sheet -- white above and below, the map small in the middle. That was the
other half of the wrong vertical aspect; the first half was the tile slicing, fixed in
859bb268.

Orientation now follows the map: landscape when the map's own container is wider than it
is tall, portrait otherwise. The PAPER is unchanged -- still 8.5x11 stock, only turned --
so the sheet stays a size printers have rather than becoming a custom one.

Measured from map.getContainer() rather than from the window, because the map may be one
pane of a composite and it is the MAP's shape being exported, not the browser's.
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
c56c4d1c35 Experimental shelf: the help button's name, in every language 2026-09-28 17:53:37 +00:00
Doxa Maps
837fcd9b5e Experimental shelf: what each map is for and how to use it, for the help button, in every language 2026-09-28 17:53:37 +00:00
Doxa Maps
4b45285b7d Hold the packet's ring names to the screen's, and adopt its exact words
The agreed ring vocabulary now lives in two catalogues in eleven locales
each -- gospelInternet.json for the screen legend, packet-strings.json for
the printed sheet -- and nothing held them together. They legitimately
want different SHAPES (the screen renders the axes as standalone labels,
the packet composes them into one string per ring), so neither can import
the other; but the WORDS must match.

The failure that makes this worth a gate is quiet: one cell is provisional
on the Driver's row 89, and it appears in rings 1, 4 and 7. Answer it in
one file and not the other and the sheet and the screen state different
things about the same ring, each correct in isolation, with no error
anywhere. Only a reader holding both would notice.

Measuring first showed 36 of 77 cells already differed, in two classes.
Most were grammar -- the packet's culture terms follow a comma
mid-sentence, so they are lowercase -- but some were genuinely different
words: "Same family" against "Same language family", German "verwandte"
against "Nahe", Hindi "वही जन" against "वही जनसमूह". Those are drift, and
the screen's wording wins because it is what readers already see. The
packet now adopts every term verbatim, lowercasing only the first
character of the culture half, which is a no-op in zh/ar/hi and leaves
German common nouns capitalised.

That adoption caught a bug in my own assertion: I had hardcoded ring 7-9
as "Same family" from CenteredSetTarget.vue's header, while the shipping
string is "Same language family". A summary in a comment is a description
of the data, never a second source of it.

The gate also refuses to pass when it compared nothing -- rename a key on
either side and every lookup returns undefined, every pair is skipped, and
a silent check reports success. Mutation-tested both ways: answering row
89 on the screen only, and renaming a packet key.
2026-09-28 17:53:37 +00:00
Doxa Maps
fccf6ee2fe Name the rings on the packet's centered set
The figure printed rings without saying what they mean -- honest but thin.
It now names each one with the vocabulary gospel and exp settled, the same
words the screen legend renders, so a printed sheet cannot disagree with
the screen.

COMPOSED FROM band x culture, NOT A FLAT 1..10 TABLE, and that is the
whole design decision. "Same people" in column 1 is provisional on the
Driver's row 89 -- and it appears in rings 1, 4 AND 7. A ten-string table
would need three edits kept in step, which is exactly how two of them end
up saying different things. Composed, it is one string
(ring_culture_same), and if he picks "culture" the ring numbering, the
columns and the colours are all untouched.

Ring 10 stands alone ("Nearby only") because it is not a band-and-culture
reading at all -- it is what is left when no linguistic relation was
found. The gate asserts that separately, and a mutant that composes it
like the other nine fails.

ONLY THE RINGS THAT HOLD SOMEONE. Ten rows of vocabulary beside a figure
is a glossary, not a legend, and a named ring with no dot on it invites
the reader to hunt for something that is not there. ringTally's per-ring
counts drive the list, so it states what this zone actually contains.

Mutation-tested: a wrong band order, listing all ten rings regardless of
count, and composing ring 10 like the others each fail with their own
message. Eight new strings x eleven locales; packet-strings-parity green.
2026-09-28 17:53:37 +00:00
Doxa Maps
3c1d513cc3 The packet gets its bounded and centered sets page
Brief A1 back matter: after the profiles, before the wheel, full detail
only. The lightweight packet is map, triangle, profiles, wheel, points --
these two figures are the part a reader sits with rather than carries.

The layouts are regions' library/components/charts modules, imported
rather than injected: unlike describeGroup's country resolver they have
zero imports and run under plain node, and packet-render already imports
scatterCells from that directory. Verified by importing and CALLING them,
not by reading them. They return data, so the SVG is ours.

Gospel's non-negotiable is carried through and checked rather than
trusted: the bounded set keeps a deliberate gap either side of the
boundary because a dot ON the line is the one thing a bounded set cannot
express, and print has no hover to disambiguate it. Measured on the real
module with 40 groups -- inside max 85.3 of 92 allowed, outside min 119.8
of 112 required. The figure scales through the viewBox, so the gap lives
in coordinates the scaling cannot touch; shrink the figure, never the gap.

UNKNOWN POINTS ARE STATED, NOT PLOTTED. layoutBoundedSet gives them no
x/y, which is the module being right: `reached` undefined means we do not
know which side they belong on, so there is no honest place to draw them.
My first version mapped them through the same dot() helper and crashed on
p.x.toFixed -- a better outcome than silently drawing them at (0,0), which
would have put "we don't know" inside the circle. They go in the tally,
because on a printed page a category the reader cannot see reads as zero.

check-packet-variants' sheet delta goes 2 -> 3, updated in this commit
rather than relaxed into a range. It also now SUPPLIES the sets fixture:
setsPage returns '' without data, so a build() that omitted `sets`
exercised the filter for every page except the newest one, and the gate
sat green through the whole rewrite while asserting a delta that had
become wrong. A fixture missing an input silently narrows what the whole
file covers.

Seven strings x eleven locales; packet-strings-parity green. Gospel's own
ring names live in gospelInternet.json, a plugin catalogue, so there is no
collision with these.
2026-09-28 17:53:37 +00:00
Doxa Maps
e5dd83dad9 check:composites-mount records elements defined through the shared element helper
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
It captured defineCustomElement only in plugin entry files; since the entries build their element
through library/elements/defineMapElement.js, it recorded nothing and refused every composite as
'defines neither this component nor ProfileLoader'.
2026-09-28 17:52:51 +00:00
Doxa Maps
ee5b6f83e8 Rebuild the consumers main left stale (build owner, README rule 5)
check:composite-freshness found stale on main: key-cities
template-bundle; also rebuilt: all-people-groups big-picture cities-internet country-posters dashboard-plus-grid demo-map doxa-maps-spa doxa-research-map doxa-simple-map doxa-vision-card golden-path-check gospel-internet gospel-resources my-upg-100-list people-groups-grid plugin-studio pplr-data-maps prayer-walk-planner regions-dashboard research-dashboard research-map-plus-grid stack-demo upg-neighbor wall-of-people-groups. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
2026-09-28 17:50:51 +00:00
Doxa Maps
a25e0a6f99 Eleven plugin entries import the element helper they call
The element-bootstrap migration called defineMapElement in eleven entry files that never
imported it. A build does not fail on an unbound name; the element does, the moment it loads,
with 'defineMapElement is not defined'. The landing precondition caught it by importing each
profile. check:element-bootstrap now fails when an entry calls the helper without importing it.
2026-09-28 17:40:35 +00:00
Doxa Maps
3d16a1d62c Shipped comments describe the behaviour instead of quoting a person
Seven comments added in this batch quoted the reporter word for word. Each now states the
behaviour it guards.
2026-09-28 17:40:35 +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
05dca6213a check:i18n-parity fails on a Traditional-only character in the zh catalogue
The character set (322, from the UPG-100 owner's census, minus two shared by both scripts) is a
floor, and the PASS line says so; a t2s converter would make it exact if one is ever installed.
2026-09-28 17:40:35 +00:00
Doxa Maps
5dee1ac2a6 zh reads in Simplified throughout: 27 strings that were in Traditional
The zh catalogue is Simplified, but 27 values (the Prayer List and Cards buttons, Close, Show on
map, the share steps, the UPG-100 wall intro, three tab names and several popup labels) were
Traditional, so one button row mixed scripts. Converted character by character, with the mainland
wording for two words (列印 → 打印, 檢視 → 视图).
2026-09-28 17:40:35 +00:00
Doxa Maps
a0bdcd1c87 Translate the four new button keys into all eleven locales
check:i18n-parity was RED: working, packetVariantAria, packetFull and
packetLightweight existed only in en, so all ten other locales were short
four keys with no DEBT entry. Three of the four came in with the Prayer
list caret, so this would have failed there too.

Really translated, not English-filled: check:i18n-never-translated
compares values ACROSS locales and a copy-paste of the English would trip
it. The register follows the neighbouring keys already in each file
(cardDeck, prayerList), and the two packet names stay short because they
are buttons inside an 84px tape, not prose.

zh is SIMPLIFIED, and my first attempt was Traditional (studio caught it).
I had matched the neighbouring cardDeck/prayerList, which are themselves
Traditional -- but they are the exception: a census of the whole zh
catalogue counts 826 Simplified-only strings against 22 Traditional-only,
and 11 of those 22 are in this one file. So the neighbours were the drift,
not the standard. Matching a locale means matching its SCRIPT and
register, not just its language, and the way to establish that is to count
the catalogue rather than read the adjacent line.

Deliberately its own commit rather than an amend of either source commit:
the four keys span two commits, so they belong to neither, and rewriting
one already staged in a landing batch would hand studio a different sha
for work they had picked.
2026-09-28 17:40:35 +00:00
Doxa Maps
64cab53aa5 The card-deck tape stops underlapping the next card on mobile
On mobile the Card deck options tape slid under the cards below it.

.pstocks hangs at top:100%, i.e. below its own card and over the next one.
Every .card is position:relative with NO z-index, so they all sit at the
same stacking level and later cards paint over earlier ones in document
order. The tape's own z-index:2 only orders it WITHIN its card, which is
why it looked correct on the last card of a row and vanished under the
next card everywhere else. On a phone the grid is one column, so the very
next card is directly beneath -- hence "really bad on mobile" rather than
merely wrong.

Raising the CARD while one of its tapes is open lifts the whole stacking
context above its siblings. z-index 5 sits above any card and below the
sticky section header at 6, so a revealed tape never covers the header the
reader scrolls with -- a menu that hides its own navigation would be a
worse bug than this one. The tape also gains a stronger shadow so it reads
as sitting ON the card below rather than tangled with it; without an
opaque, shadowed surface the next card's text shows through, which is the
other half of what "underlapping" looks like.

Covers both tapes -- the card sizes and the new packet variants -- since
they share the disclosure.
2026-09-28 17:40:35 +00:00
Doxa Maps
ff5aceb58f A country tapped on the map now reaches the prayer list
On mobile, selecting countries and pressing Prayer list reported zero UPGs and would not generate a prayer list.

selectedTerritories holds three kinds -- 'zone' (a legend row), 'adm0' (a
country tapped on the map) and 'adm1' (an India state / China province) --
and selectedLists() kept only 'zone'. So a map selection resolved to an
empty list and selectionAsList threw "nothing selected", while the HUD,
which sums t.count straight off the selection, showed a correct non-zero
total. Two sources of truth for one selection is why it read as a nonsense
error rather than an empty state: the app said 87 and the generator said
zero.

The adm0 key already carries the ISO3, which IS country_code on a
people-group row, so a tapped country becomes a country_codes selector the
evaluator already handles. No new selector type, and a mixed selection
unions and de-duplicates for free. The cover label now names the countries
too -- built from lists alone it was empty, and reduce() on an empty array
throws, so the packet would have died on its cover even after the selector
was fixed.

adm1 cannot resolve and that is a data fact, not an oversight: a
people-group row has no province field and the evaluator has no adm1 case.
Folding it into the country union would print a packet for the whole
COUNTRY while the reader had selected one STATE -- a plausible packet that
is quietly wrong. So it is refused BY NAME, which "nothing selected" was
not: that is a lie the reader can see is false with their selection
highlighted on the map.

Also, nothing showed that the action was running: batchBusy only
DISABLED the buttons, and a disabled button is visually indistinguishable
from an unresponsive one, so on a phone you tap, see nothing change, and
tap again. busyAction names which action is running so only that button
relabels, and it is cleared in every finally -- otherwise a failed print
leaves the button reading "Working..." forever.

check-selection-kinds.mjs reads the kinds OUT of resolveTerritory rather
than listing them, because listing them is how this survived: 'adm0'
existed in the resolver and nowhere in the consumer, and a test written
from the same mental list as the consumer would have missed it too.
Mutation-tested: reverting the guard, dropping the selector, and leaving
busyAction uncleared each fail with their own message.
2026-09-28 17:40:35 +00:00
Doxa Maps
63ce2d372c Wire the back-to-top on both surfaces, from the side that owns the scroller
The Driver asked for the arrow on the people-group grid and on the regions list.
Those needed different wiring, and the reason is worth recording because it is
not a preference.

The regions dashboard owns its scroller: .rd-scroll has overflow-y:auto, so the
plugin mounts the button itself and drives its own element. It deliberately does
not touch the page's scroll — this renders inside a shadow root in someone
else's page, and their scroll is not ours to move.

The people-group grid owns no scroller at all. .pgg-root sets no overflow and
.pgg-body has no rule; the grid simply grows and something outside it scrolls.
In this shell that something is .vtl-host, which lives in the layout, outside
every plugin's shadow root, so the grid could not reach it even if it should.
So the arrow for that surface belongs in the layout, beside the circle the
layout already owns, driving the pane it already owns. The grid is untouched.

.vtl-host is per tab, so the arrow follows `active` rather than binding one ref,
or it would scroll whichever pane rendered last.

One component, two consumers, and each drives a scroller it actually owns.
Checked that this adds nothing to the hardcoded-English census: 256 before, 256
after — the three regions entries there are PosterWall prop literals and predate
this.
2026-09-28 17:40:35 +00:00