Commit graph

210 commits

Author SHA1 Message Date
Doxa Maps
65810f872f A back-to-top beside the view-list circle, and lift the Regions attribution clear of it
Two reported mobile bugs (DRV1650 #3 and #4), both settled by rega's
measurements at 390x844 on dx-life /research rather than by reading source.

★ #3 — THERE WAS NO BACK-TO-TOP AT ALL, and I had reported otherwise. I said
VerticalTabsLayout already rendered one targeting the active .vtl-host. rega:
ZERO .vtl-host elements in the live build, button.btt ABSENT from the DOM. The
chain: VTL renders .vtl-host only for `mountedElementRows`, which filters rows
carrying an `element` key — and this SPA passes :groups whose rows carry none,
mounting its own .dms-pane panes imperatively. So activeHostEl is null, and the
layout's button correctly renders nothing. That button is for layouts hosting
their own element rows; it is inert for the composition the gallery uses. I had
read the source and not the configuration.

The SPA now renders its own, bound to the ACTIVE .dms-pane — the scroller rega
measured (clientHeight 712, scrollHeight 12,373, overflow-y:auto) while the
document does not scroll at all. The gallery owns both the circle and the
scroller, so no shadow-root walking is needed. Placement reads --map-fab-beside,
published by the layout only while its circle renders, so the arrow sits BESIDE
the circle and falls back to the plain inset where there is none.
BackToTopButton already refuses to appear when its target does not scroll.

The label goes in all ELEVEN of the SPA's locales. Its translate() falls back to
the KEY, so a missing entry would have rendered the literal string "backToTop"
to every reader — en/ar/zh/ru/hi verified, and the Arabic and Chinese strings
are present in the built bundle.

★ #4 — THE REGIONS ATTRIBUTION SITS UNDER THE CIRCLE, and the geometry was not
what I assumed. rega: at 390 the attribution is details.maplibregl-ctrl-attrib in
the BOTTOM-RIGHT container, expanded to 347x44 at (22,791) — nearly the full map
width — so its LEFT END runs under the 44px fab at (12,788), 1,394 px² overlap.
The bottom-left container is EMPTY (0x0), so a bottom-left-only fix would have
changed nothing.

Both bottom containers are lifted off the published --map-fab-clearance under a
767px query, so a standalone leaf or a <doxa-country-map> with no gallery around
it reads the 0px fallback and nothing moves. The existing z-index:3 cannot help:
the circle lives in the layout, outside this shadow root, so the two are
different stacking contexts. Attribution is a LICENCE TERM of the basemap, which
is why this is a fix and not a polish item.

REBASED ONTO 0428f279c BY REPLAYING SOURCE, not by resolving a conflict: the
rebase collided only in generated bundles, where a conflict is never resolvable
by hand. Three files applied clean; RegionCountryMap.vue had MOVED to
library/components/maps in that same round (my own country-map work), so its rule
was re-applied at the new path by hand — a straight patch would have failed with
"No such file or directory", which is exactly what studio would have hit.
country-map, regions-dashboard and doxa-maps-spa rebuilt; both fixes verified in
the shipped minified bundles, including the new country-map bundle that now
inherits the lift.

NOT PROVEN HERE: the headless DOM proof needs a browser (rega's seat). What I
verified is that the button, its scoped CSS and its ar/zh labels are in the
SHIPPED doxa-maps-spa.js, and the fab lift in both map bundles.
2026-09-29 02:35:35 +00:00
Doxa Maps
457d16e708 A map's own ? can ask its host for help: HelpButton and useHostedHelp send doxa:help
The Experimental maps' explainer lived in a "?" floating at the top centre of the app, which read
as a game piece rather than part of the map. The map already has a "?" control. This lets that
control answer from the HOST: a click dispatches doxa:help, bubbling and composed, so it crosses
every shadow root between the button and the page, and the app answers with the map's text.

library/composables/useHostedHelp.js is the one mechanism, for the HelpButton map control and for
the maps whose "?" belongs in their own bar. It also decides WHERE the button shows: only under a
host that answers (an ancestor, across shadow roots, carrying data-doxa-help), so the same bundle
embedded on a partner page shows no dead "?". HelpButton keeps its open-help emit, so
doxa-simple-map's own help popup is unchanged; hosted is opt-in.
2026-09-29 02:35:35 +00:00
Doxa Maps
de2ec6fe46 bandFill mode: 'fill-all' draws every group and lets the zoom separate them
Rings that zoom apart, as a flag, so either answer to row 130 is one line at
the call site. DEFAULT IS 'fit' — this changes nothing today.

'fit' keeps every pin at least minSep apart at the chart's own scale and reports
what will not fit. That is the right behaviour for a PRINTED sheet, where there
is no zoom and a dot a reader cannot resolve is a dot that is not there.

'fill-all' places all of them. Separation is in map units, so it grows with zoom:
measured on the shipped table, the worst ring is ring 1 (1,190 groups in the
smallest circle) separating to 8px on screen at z=4.45, and ring 8 (1,932) at
z=3.50. The GROUP label tier already starts at 5.2, so every pin is clickable
before the names appear. At the start zoom the band reads as a dense arc of
colour rather than countable dots, which is the intended appearance — and the
reason a caller in this mode must caption the true total rather than a count
that changes with zoom.

The row count had to follow the POPULATION here, not the pin size. Keeping the
'fit' row count would have packed 1,932 pins into the two rows a band allows at
full size, and they would have sat almost on top of each other along the arc —
the single-line defect again, one level down. Ring 8 uses 14 rows in fill-all
against 2 in fit.

Real table: fit places 1,851 of 7,296, fill-all places 7,296 of 7,296, both with
zero pins outside their band. Six new assertions, and the row-growth arm is
poison-tested: making fill-all reuse the fit row count fails it at "2 rows vs 2".
2026-09-29 01:38:08 +00:00
Doxa Maps
b3238db361 A band holds concentric rows, so it holds four times what I was counting
Found by running the SHIPPED table through the layout instead of the uniform
sample the module was built against. The real near-neighbours data is heavily
skewed — ring 8 carries 1,932 groups, ring 5 carries 100 — and against it
bandFill placed 962 of 7,296. It dropped 87% of the map, and every drop was
faithfully reported, which is exactly why no assertion caught it: all 16 asked
"is the overflow reported?" and none asked "is the CAPACITY right?".

The capacity model counted one circle of pins at the band's innermost radius. A
band has depth, so it holds several concentric rows, each with its own
circumference. Counting them is a ~2x correction at ringGap 24 and more at
larger gaps; on the real table it takes placement from 962 to 1,851 on the gate's
geometry and every under-full ring now places everything it is given.

Placement had to follow: a hashed radius with one angular step was fine while a
ring held a dozen pins and wrong once it held hundreds, because two pins could
take nearly the same angle at nearly the same radius while the capacity sum
insisted there was room. Pins are now dealt round-robin into rows, each row
evenly spaced across the usable arc, staggered a quarter slot so adjacent rows
do not line up radially and read as spokes.

Two bugs found on the way, both by the gate failing honestly:
- the stagger first added half a step to every odd row, which pushed that row's
  last pin past the end of the arc and into the label's keep-out — 7 overlapping.
  Staggering WITHIN the slot keeps every pin inside its own slot.
- the shrink arm tested at 200 groups, which only crowded a ring while capacity
  was undercounted. It now tests at 2,000, the honest pressure for a 7,296-row
  table, and still reaches the pin floor and reports.

The new capacity arm asserts a RATIO against the single-row count, not a magic
number: its first version hardcoded ">400" from the component's ringGap of 22
while the gate builds radii at 24 — the code was right and my threshold was
guessed. Poison-tested: forcing rows back to 1 gives 1.05x and fails both arms.
2026-09-29 01:38:08 +00:00
Doxa Maps
819051c08e A ring of bands, as pure functions: rings in, positions out
layoutCenteredSet gives every group on ring N the same radius, so a crowded ring
draws as one line of dots on a circle. The target needs each ring to be
a BAND, groups spread evenly through it, never overlapping each other or the
ring's label.

library/components/charts/ringBands.js is that geometry, with no Vue, no engine
and no DOM, so the Wall poster can consume the same module rather than growing a
second copy of it. bandFill() places; curvedRingLabels() returns each label's arc
AND the keep-out that bandFill refuses to place inside — computed from one source
so "pins never overlap the labels" is true by construction rather than usually.

Determinism is a requirement, not a nicety: every position is a pure function of
the item's own id, so two printings of one packet compare sheet to sheet and a
dot that moves means the DATA moved. There is deliberately no seed. Input order
does not affect any position either, so a caller that filters or re-sorts does
not reshuffle the chart.

An over-full ring SHRINKS its pins to a floor before anything is dropped — a
visible pin beats an unplaced one — and only then reports the remainder with the
ring and the counts.

check:ring-bands, 16 assertions, every arm with a negative control. One arm is
there because this gate passed the bug it now catches: uncapped, a long label on
ring 1 wanted 96% of that small circle, so capacity was zero and all twelve
groups on the innermost ring were reported unplaced while every arm stayed green
— "reported, not silently dropped" is not the same promise as "the ring draws".
Labels now truncate at a share of their own circumference and report how many
characters fit. Poison-tested: restoring the old share reproduces "empty rings: 1".
2026-09-29 01:38:08 +00:00
Doxa Maps
443b163682 Share the ported _globals.scss rules through one module
Seven shadow-root components each carry their own copy of DX Life's
base/_globals.scss rules, because page CSS cannot reach into a shadow
root. Measured first: where the copies overlap they are byte-identical
(the Cyrillic stack 3x, strong{font-weight:500} 2x, scrollbar-gutter 2x),
so this is deduplication, not a fix.

The module exports FUNCTIONS, not strings, and the measurement forced
that: the six copies of the Cyrillic rule share one declaration but have
six different subjects (.rd-goal-card__title, .rd-h2, .rd-title,
.region-card__title, .rl-title x2). A fixed rule string would have to
pick one class and silently stop styling the other five. The caller owns
its selector; the module owns the declaration.

cyrillicHeading takes a selector LIST, not a selector. GoalCard styles
one heading class; RegionSectionCard groups two into a single rule
sharing one declaration block. A one-selector signature emits two rules
instead of one -- the same CSS to a browser, but not the same
stylesheet, and the bar here is unchanged, not equivalent.

Migrated GoalCard and RegionSectionCard. regions-dashboard's two files
port the same rules and stay with their owner; the harness records them
so that migration can run against this same baseline.

The img border-radius rule (_globals.scss:80) is documented as
deliberately NOT ported, because an invisible omission is one that gets
"fixed" later.

PROOF: internal/check-ported-globals-identical.mjs executes each
component's setup, captures the string handed to useShadowStyles and
hashes it. The emitted CSS is byte-identical across the refactor
(987d94b1cd66110d, 897d948b17b21efe, both captured from 45e31f102).

The harness's first version hashed the SOURCE of the call and was
wrong: this refactor replaces a literal with a function call, so the
source changes by construction -- a text-hash gate fails a perfect
refactor and cannot tell that from a broken one. It measured the
artifact when the property under test was the output. It failed exactly
that way on first run.

Shown capable of failing, four ways: dropping letter-spacing, a wrong
font name, 500->600, and splitting the grouped rule into two. All four
go red; restoring goes green. Components that cannot be evaluated are
reported NOT MEASURED (exit 75), never as a pass.
2026-09-29 01:38:08 +00:00
Doxa Maps
a2540d9e73 The legend collapse caret stops turning when the reader asks for reduced motion
The caret (C11) and the reduced-motion gate were written in parallel, and only together did the gap
show: the new caret animates its arrow (transform 0.18s) and its colours with no reduced-motion rule.
The rule sits in the useShadowStyles string, where a shadow root can see it. The arrow still flips
to the right direction; it only stops turning on the way there.
2026-09-29 01:38:07 +00:00
Doxa Maps
c5c01deada Delete 7 unreachable library components, and gate the thing a census cannot see
Seven components with no importer, removed. The interesting part is not the
deletion, it is that an import census was NOT sufficient to authorise it, and
two other agents proved that rather than me.

WHAT AN IMPORT CENSUS MISSED. internal/check-gospel-set-charts.mjs iterated
['BoundedSet.vue', 'CenteredSetTarget.vue'] as literal STRINGS and read their
props off disk. No import, so no census and no build would have warned; deleting
them would have turned a gate red on main with nothing to explain it. gospel
found that in their own file after I asked, and retargeted the arm at the
components that actually render (GiBoundedSetMap / GiCenteredSetMap, 2d2e853a7).
Their retarget also fixed a quieter defect: the seed guarantee had been asserted
against two files NOTHING renders, so it would have kept passing while the
shipping component grew a seed prop.

I did not take either agent's word for it. Verified gospel's retarget on their
sha and ran their gate green before deleting; verified upg100's structural claim
that packet-render.js cannot mount a component at all — 0 resolveComponent, 0
defineAsyncComponent, 0 dynamic import(), with the same probe returning a hit
elsewhere in library so an empty result is a real absence.

DELETED: charts/LanguageTreeChart.vue, components/charts/VisionFunnel.vue,
components/charts/BoundedSet.vue, components/charts/CenteredSetTarget.vue,
components/legends/ListSelectorLegend.vue,
components/dashboards/DashboardBreadcrumb.vue, composables/useMapSelection.js.

NOT DELETED, and worth stating because the names collide: the lowercase
boundedSet.js / centeredSet.js HELPERS are load-bearing — packet-render.js and
GiBoundedSetMap import them directly, and check-gospel-set-charts still does too.
Also left alone: colors/resource.js (a colour STRATEGY resolved by MODE NAME
through _registry.js, 'resource' appearing in mapStore.js and GiResources.vue)
and layouts/app-wrapper-material/nav.js (115 mentions). Both look dead to an
import census and would break a working map.

check:no-dead-library scans for a filename as a STRING across internal/,
library/ and plugin-designs/, and:

  · DISTINGUISHES A READ FROM A COMMENT (gospel's ask). check-packet-variants.mjs
    discusses CenteredSetTarget.vue in prose while asserting against JSON. A
    comment hit is reported, never fatal — a gate that cannot tell the two apart
    cries wolf once and is then ignored. It currently reports 4 such mentions.
  · IS TWO-SIDED (upg100's ask). It also asserts a KNOWN-PRESENT dynamic
    reference is found — LegendDesktop lazy-loads LegendMobile through import().
    Mutation-tested: blinding the scanner makes it FAIL on that control instead
    of passing with "no references found", which is what a one-sided version
    would have done.
  · ignores a longer identifier that merely contains the name, so GiBoundedSet.vue
    is not read as a reference to BoundedSet.vue.

Both arms mutation-tested and every mutated file restored byte-identical.
Registered in all three places and verified by re-reading the parsed
package.json — the suite insert silently hit the scripts KEY instead of the suite
string on the first attempt, which verifying caught and trusting would not have.
2026-09-29 01:38:07 +00:00
Doxa Maps
36f2818b37 Every animating library component now honours prefers-reduced-motion itself
17 of 25 library components that ship motion carried no reduced-motion rule, so
they animated regardless of the reader's OS setting.

The reason it cannot be fixed once, centrally: a @media (prefers-reduced-motion)
rule in the host document does not cross a shadow boundary, and these render
inside one. Measured while surveying: NO generated leaf page ships such a rule
at all — 0 occurrences across the whole built tree — so there was not even a
page rule to inherit. Each component owns its own.

CSS-ONLY AND ADDITIVE. Nothing but new @media blocks; no existing rule changed.
Verified by diff: 0 real removals across 17 files. The two lines git reports as
removed are a trailing comma moving as a new string was chained onto an existing
concatenation, and both original rules are still present — checked by grepping
for them, not by reading the diff.

WHERE MOTION CARRIES MEANING, it is kept rather than hidden:
  · SemanticTreeLegend's spinner MEANS "loading". Stopped dead it is a static
    ring that says nothing, so it is slowed to a step crawl (2.4s, steps(8)) —
    visibly working, no smooth spin.
  · LegendProgressBar's only motion is a hover filter, so it simply stops; the
    bar's FILL is geometry, not animation, and still reports its value.

The rule goes where each component's styles actually go, and that varies more
than expected: 13 append into a useShadowStyles template literal, 3 into
string-concatenated sheets, and VerticalTabsLayout into its VTL_SHADOW_CSS
constant AND its duplicate <style> block, because its own header says the two
copies must stay identical.

check:reduced-motion asserts both halves, each mutation-tested to fail before I
trusted a pass: strip the rule from an animating component and it fails; move a
rule from the injected sheet into a <style> block and it fails too, because on a
useShadowStyles component that rule would never reach the shadow root. Both
mutated files restored byte-identical.

Two of my own errors caught by tooling rather than by review, and worth naming:
my first patch asserted useShadowStyles existed in every target and RAISED on
VerticalTabsLayout instead of silently skipping it — the assert-the-anchor habit
from the last registration failure working as intended. Then I appended CSS
after the label argument on two concat-built sheets, corrupting the injection
key; check:shadow-css-parses named both files and the fix was to insert before
the label. A gate I did not write caught what I got wrong.

Registered in all three places and verified by re-reading the parsed
package.json.
2026-09-29 01:38:07 +00:00
Doxa Maps
094647ebee C11: one legend collapse caret, and it says whether the legend is open
Three copies of the same control: SemanticTreeLegend's .stl-collapse-btn, and
two in LegendDesktop — the data-view caret and the detail-mode button, whose own
comment says it "Matches SemanticTreeLegend's .stl-collapse-btn look". It
matched the look. A control is not its look, and the copies lost both of the
parts that do not render:

  · aria-expanded. A disclosure control that never reports its state leaves a
    screen-reader user with a button called "toggle legend" and no way to learn
    whether the legend is open. This is the same defect I fixed in LegendMobile
    last week, in a component sitting in the same folder as the fix.
  · the RTL arrow. STL carries .stl-collapse-btn:dir(rtl) svg{rotate(0deg)}
    because a panel that slides off the other edge needs its caret to point the
    other way. LegendDesktop has no :dir(rtl) rule at all, so in Arabic its
    caret points AT the panel it would open.

The label also now names the ACTION rather than the control: collapseLegend /
expandLegend instead of a fixed "toggle legend". All three keys were already
translated in all eleven locales, so this adds no translation debt.

THE ARROW AND THE ARIA COME FROM ONE PROP. They drifted apart because they were
two independent pieces of markup that happened to agree; `open` now drives both,
so a future copy cannot get one right and the other wrong.

STATE STAYS WITH THE CALLER. Two consumers drive collapse from uiStore's
tri-state cascade (fullyOpen -> open -> collapsed) and one from a local boolean.
A shared control that quietly adopted either would have broken the other, so
this one owns no state and no persistence — it renders what it is told and emits
the intent.

TWO THINGS DELIBERATELY UNCHANGED. The detail-mode control was an INVISIBLE
28x28 hit target — transparent fill, no border, transparent colour — over the
panel's corner; making it suddenly visible is a look change nobody asked for, so
the slot keeps the position, the size and the invisibility, and only the
semantics improve. And LegendDesktop's four light/dark theme rules stay in
LegendDesktop: they are its themes, not the control's. They outrank the shared
base on specificity (0,3,0 and 0,2,0 against 0,1,0), so which sheet is injected
first does not decide the colour.

Caught while writing it: the first draft styled the caret with <style scoped>,
which defineCustomElement silently DROPS for any child loaded through
import.meta.glob — it would have rendered unstyled in every shadow root while
looking right in the source. That is what useShadowStyles exists for, and what
every sibling legend component already does.
2026-09-29 01:38:07 +00:00
Doxa Maps
c99fe68c38 A country map you can drop on one page, without the dashboard around it
DX-life's country pages render nothing where a map belongs. The map they need
has been in this repo since the regions clone landed — it was just unreachable
without loading the whole regions dashboard, a bundle that fetches the people
group list, builds four stat cards, a region chooser and a table, none of which
a country page wants. So this is not a new map. It is the same component, given
its own element and its own bundle.

  <doxa-country-map profile-config='{"countryCodes":["TCD"],"colorByPrayer":true}'>

RegionCountryMap and useRegionsI18n move to library/ because they now have a
second consumer, which is the only thing that earns a place there. Six import
sites updated in this commit; nothing else moved.

THE CONTRACT arrives through profile-config, the channel every element here
uses, so a generated page and a hand-written host page are configured the same
way: countryCodes, center, zoom, bounds, colorByPrayer, researchUrl,
prayBaseUrl. Two translations happen at the edge rather than by widening the
component — colorByPrayer is a boolean here and colorMode a string inside, and
countryCodes accepts an array OR "TCD,NER,MLI", because an HTML attribute is a
string and JSON-inside-an-attribute is the shape people get wrong.

THE ELEMENT FETCHES ITS OWN PINS, and that is what makes prayBaseUrl part of the
contract rather than decoration. An element handed a country code, a base URL
and no way to fetch would draw a country outline with nothing inside it — the
blank page it exists to fix, in a new disguise. Same endpoint, same fields, same
mapping as the dashboard; it asks for a country instead of the world.

THE FIELD LIST TRAVELS WITH THE MAPPING, in peopleGroupPoints.js. The obvious
version of this commit asks for PEOPLE_GROUP_LIST_FIELDS — the list the stats
need — and every pin still draws, every popup comes up empty, and nothing logs
anything. The popup half is what makes it a map you can read, so the two are
one export and neither can be requested without the other. The dashboard's own
request is unchanged: the concatenation produces the identical string.

countryUrl is additive, not in the confirmed contract. The component's country
link needs a base and DX-life's country pages live under /regions/; a host that
sets nothing gets that documented default.
2026-09-29 01:38:07 +00:00
Doxa Maps
8b8e9fee9c The wall-printed packet gets all five of its card pages
Diffing what the lightweight packet ACTUALLY prints on each path against
its card (map, triangle, profiles, wheel, points) found the wall path
missing two pages and half of a third -- while the legend path matched.
Same class as the sets page: the renderer could draw every page, and
only one of the two hosts supplied the arguments.

Three gaps, three suppliers:

The map. printPacketAs passed no mapSvg and there was no default, so a
wall-printed packet opened on a title-only cover. printPacket now lifts
the bundle's own poster (posters/<id>.html, the same file printPoster
already reads) when the host passes nothing -- via a liftPosterSvg
helper shaped for unit-testing against a real generated poster, no
fetch. Verified on laos: the svg is found and lifted. And PosterWall now
actually passes assetsBase into its print opts; without that one line
the lift's guard would skip silently and the wall would STILL print a
mapless packet -- the seam-without-supplier failure one layer down,
caught in the same pass.

The triangle. No funnel was supplied and none defaulted. The default
states ONLY the steps: listCount and unassigned stay absent, so
funnelPage omits the share line and the tip rather than printing a
denominator that is wrong under a non-default list. Proved two-sided:
steps-only renders the triangle with no share claims; adding listCount
renders both.

The profiles. The wall's fetch carried primary_language but not
imb_gsec, so every wall-printed description ran at half length -- the
third fetch site with the same missing-field defect, found the same way.
2026-09-28 23:13:47 +00:00
Doxa Maps
9799ebdba4 The full packet's sets page gets its data, from every print path
setsPage landed as a renderer capability with no data supplier: it
returns '' without data -- correctly -- which meant the FULL-DETAIL
packet had never once printed its distinguishing back page in real use.
The full-vs-lightweight split existed in code while the full packet was
missing the page that makes it full.

packetSets.js is the supplier: a pure decode+join with zero imports, so
the identical function runs under Node (the generator reads the table
from disk) and under Vite (the browser fetches it) and the join cannot
fork between the two. The table stays with the caller -- the same seam
as strings, christian and describe.

The join is measured, not assumed: 2,091 of 2,107 groups match by slug,
rings distribute 1-10 with real shape, and the 16 unmatched are inked as
UNKNOWN on the bounded figure rather than guessed onto a side --
an un-inked category reads as zero on paper.

Wired at every path that prints a packet: the offline generator (all 28
zones verified carrying the page, e.g. laos: 0 inside, 94 outside, 1
unknown; 94 placed with "25 same dialect, same people"), the legend
Export menu, the poster wall, and the selection packet -- the last three
through one opts.sets loader fetching the SAME served asset-pool copy
the generator reads, so an embedded bundle prints the same page as the
dev tree.

The decode is a third reading of near-neighbors.json's cols/dict format
(nearRows.js documents being a copy of upg-neighbor's, pending promotion
to library/). Flagged in the header rather than hidden: when the loader
is promoted, the decode here collapses into it and only the join
remains.
2026-09-28 23:13:47 +00:00
Doxa Maps
6823f24b12 The map controls and the pin hint printed; the attribution stays
rega printed a full-bleed map leaf on fc93c97e4 and the release worked — 1 page,
canvas 1440x900, map drawn, floor held. Two things printed that have no business
on paper, both found only because someone looked at the sheet.

The control column — zoom in, zoom out, locate, fullscreen, share — printed top
right, over the map it exists to operate. Every one is a pointer affordance
with nothing to do on a page. The engine's own control groups go with it, for
maps that place them outside this toolbar.

The pin hint printed across the bottom: "Tap a coloured dot — someone still
needs prayer". It is an instruction for a finger, addressed to a reader who is
now holding a sheet of paper. Worse than useless there, because it sits under
the map like a caption and says something untrue about it.

The ATTRIBUTION is deliberately left alone. It looks like chrome and it is not:
it is a licence term of the basemap, and a printed map that drops it is a
licence problem, not a tidier page. Only the attribution TOGGLE goes, which is
the button that expands it.
2026-09-28 21:59:29 +00:00
Doxa Maps
c8227bd994 The pin hint only answers to a map canvas; the Experimental "?" moves to the top centre
PinHint self-gated on any <canvas>, so the Wall's own renderer got a tap-a-dot coach-mark over its
dialogs; it now matches only MapLibre and Mapbox canvases. The "?" sat bottom-right, over the map
attribution on several plugins and the zoom-out button on one; map plugins use the corners, so it
moves to the top centre.
2026-09-28 21:59:29 +00:00
Doxa Maps
f0e6c7de49 The mobile legend's toggles announce their state
Every toggle in this file carried aria-label="aria.toggleLegend" and no
aria-expanded, so a screen-reader user was told the control is a toggle and
never told whether the legend was open or closed. SemanticTreeLegend has had
this right all along; this is the copy that drifted, and it drifted on whether
the control is usable without sight rather than on how it looks.

gospel found it while measuring four implementations of the same control. I
verified it here before changing anything rather than taking the report: zero
occurrences of aria-expanded against four toggle labels.

One computed drives all four buttons, so they cannot disagree with each other
about a state they all describe — which is the same failure, one level down,
as four files disagreeing about the control.

This is the accessibility half of the C11 collapse-caret promotion and does not
wait for it: the promotion is a refactor, this is a defect.
2026-09-28 21:59:28 +00:00
Doxa Maps
1717ab3016 The UPG-100 poster list gets the per-row control, with an Auto option
Round 2 item 6. The control was already built in PosterWall -- state,
persistence, grid binding, markup -- and gated behind an opt-in prop that
only the regions dashboard passed. So most of this is enabling it rather
than writing it, which is what "reuse the pattern, don't re-derive" asked
for. I checked that before building anything, because the last time I
added a feature without checking which surface already had it, it landed
somewhere nobody looks.

Auto is genuinely new: the choices were 2 to 5, always forcing a count,
and the brief asks for Auto meaning today's CSS with overrides on top.
gridStyle already returned {} for a non-positive value -- the comment at
that line even says so -- so Auto needed no new layout branch. It needed
to become REACHABLE: a 0 in the choice list, a label, a read path and a
write path. It extends the earlier min-2/max-5/default-2 ruling rather
than contradicting it, since those bound the counts and Auto is the option
that declines to pick one.

Round-tripping the storage values, rather than reading the code, caught a
bug I had just written: getItem returns null when nothing is stored, and
Number(null) is 0 -- which is exactly Auto now that 0 means something. So
every first-time reader would have silently got Auto instead of the
default, and merely ADDING an option would have changed the layout for
everyone who never touched the control. A missing key now returns null and
falls back to the default; a stored 0 reads as Auto.

Auto is also persisted, which needed its own clause: the range check
rejects 0, so without it the one value that is not a count would be the
one value that never survived a reload. A preference that forgets only
some of its values is worse than one that forgets all of them, because the
reader cannot tell it is broken.
2026-09-28 21:59:28 +00:00
Doxa Maps
f172312cab Put the UPG 100 choice on the surface that actually shows it
The Prayer-list dropdown went into PosterWall's per-card print row. The
profile people open -- upg-100-list -- does not render PosterWall at all;
its print path is the legend's Export menu, which still offered two items
and printed immediately. So the feature was built, landed and served, and
invisible on the only screen it was asked for.

Two surfaces legitimately offer printing and both carry a button labelled
Prayer list, so the label was not enough to tell them apart. The grep that
would have caught it -- does this profile mount that component -- takes
five seconds and I never ran it.

The legend's packet item is now a disclosure: it is named UPG 100, it
explains what a UPG 100 list is, and it reveals Full detail and
Lightweight. The explainer matters because this menu item is the only
place a reader meets the idea, so it wraps rather than truncates.

The submenu opens BELOW its trigger by construction, not by positioning:
the menu is a flex column, so a block after the button flows underneath
it. A panel that grows its parent cannot cover its parent -- which is
exactly what the card-deck row could do, hanging out of its card at
top:100% with nowhere to go on a short viewport.

emitExport widens its existing string to packet:full / packet:lightweight
rather than adding a second event; poster and a bare packet still work, so
an older consumer keeps working and one event means nobody can handle only
half the menu. printPrayerPacket takes the variant and forwards it to
printPacket, which already accepts it.

UPG 100 is allowed to be identical in all eleven locales, recorded in
check:i18n-never-translated as a brand name rather than as debt: it is not
waiting on a ruling, it IS the ruling, and the explainer beside it carries
the meaning per locale. Inventing eleven names for one product would be
the failure that gate exists to prevent.
2026-09-28 19:41:29 +00:00
Doxa Maps
59d83ba6b3 The card-deck options clear the print row instead of covering its label
On mobile the size options sat on top of the words they belong to, so the
Card deck label disappeared behind its own menu.

The row hangs at top:100% and is then pulled back up by half the print
row's height. The reasoning was sound and the arithmetic is right at
exactly one line: the footer sticker half-overhangs the banner edge, so
half a row is the correct clearance. It stops being true the moment the
row wraps -- on mobile it takes two lines, and half of a doubled height is
no longer half a row, it is the labels.

A FRACTION OF A VARIABLE HEIGHT IS THE BUG. The sticker's overhang is a
constant, so the offset is now the full row height minus that constant,
which is correct at any number of lines. Measured at one, two and three
lines: the old rule overlapped by 10, 20 and 30px, covering labels from
two lines on; the new one overlaps by a constant 11px -- the sticker
alone -- every time.

Same shape as the box-sizing error on the packet's table rows: a value
that happens to be right at one size and is silently wrong at every other,
which is why it only showed up on a phone.
2026-09-28 19:41:29 +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
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
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
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
e5140f0b41 A back-to-top that sits beside the hamburger circle, not on top of it
A back-to-top on mobile, next to the hamburger circle, for the people-group grid and the regions list. Both scroll their own container,
so the container is a prop and this component owns only the affordance.

It does not know where the circle is, and must not guess. .vtl-fab is 44px at a
12px inset today, and it lives in the layout outside every plugin's shadow root.
Hardcoding 64px here would put the two on top of each other the day that changes,
silently, on a phone. So the layout publishes --map-fab-beside alongside the
clearance it already published, and this reads it — falling back to the plain
inset when there is no circle, which is also the standalone case where nothing
sets it. The circle's geometry is stated once, by the component that owns it.

It also refuses to appear when it cannot work: visible tests that the container
actually scrolls, not only that scrollTop is large. An arrow that does nothing
when pressed is worse than no arrow, because the reader spends a tap finding out.
And it honours prefers-reduced-motion, since a long list jumping is exactly the
motion that rule exists for.

The failure mode here is a var NAME drifting between publisher and consumer:
nothing throws, the fallback applies, and the button quietly sits under the
circle again. Checked rather than assumed — every published name is consumed and
every consumed name is published.

NEEDS EYES: I cannot see 44px or a hover state. The logic is small; whether it
lands beside the circle rather than over it is a render question.
2026-09-28 17:40:35 +00:00
Doxa Maps
3a4d4db48a Reserve room for the floating hamburger under a scrolled mobile legend
The Driver scrolls a legend to the end on a phone and the last rows sit under
the hamburger circle. The circle is .vtl-fab: 44px, pinned bottom-left, rendered
only when narrow and headerless with the drawer shut.

It wins on paint for a reason no z-index can fix. The FAB lives in the LAYOUT,
outside every plugin's shadow root, so it and the legend sheet are in different
stacking contexts; the sheet is z-index 1000 and still loses. Raising the sheet
would not have worked, which is why this reserves space instead.

The layout now publishes --map-fab-clearance, and only while the button is
actually on screen; custom properties inherit through the flattened tree, so it
reaches inside each plugin's shadow root. The mobile legend's scroll area adds
that to its bottom padding, defaulting to zero. No circle, no reserved space,
and a plugin used without this layout is unchanged. The padding is additive, so
nothing that works today moves.

The button's v-if and the clearance are now ONE computed rather than two copies
of the same condition. Two copies is the bug waiting to happen: drift one way
and the legend ends under the circle again, drift the other and there is a strip
of dead whitespace with no circle to justify it. Neither has a symptom any gate
can see, so disagreement is made impossible rather than tested for.

Measured, not guessed: the earlier reading of this card found the top-left map
control at top:8px and concluded the geometry did not match the report. It did
not — that is a second, real collision in two plugins, and a different bug.
2026-09-28 17:40:34 +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
62f8f9483c Record the agreed ring vocabulary, without making it a default
The ten ring names settled between gospel and exp, derived from exp's ringFor()
rather than hand-numbered so the numbering cannot drift from the code that
produces it. The screen legend already renders these words, so a printed sheet
using them cannot disagree with the screen.

Written as vocabulary in the header a caller reads, and deliberately NOT as a
prop default. One cell is still open on the Driver's row 89 — "people" versus
the brief's "culture" in column 1 — and if he keeps his, exactly three cells
change, 1, 4 and 7, and nothing else: not the numbering, not the columns, not
the colours.

A default would bake a provisional word into every consumer that forgot to pass
one, and would make this component the thing that decided a Driver question.
Same reason ShareCalendar refuses to name its own metric.
2026-09-28 16:49:01 +00:00
Doxa Maps
663b73781d Warn the caller that groupings.label is a definition, not a name
Measured on the published file: those labels run 7 to 206 characters and the
long ones are the zone's member list, not its name. Tibet's is a ten-part list
of provinces. Only two of the fourteen carry no member list at all.

Used as a heading on a printed sheet that sets a 206-character title, and paper
has no truncation-with-tooltip to rescue it. The name a reader wants is the
humanised slug, uniform by construction and about 34 characters at worst, with
the label carried separately as the definition it actually is.

This component asserts nothing about the words in labels.rings, so the whole
hazard lives at the call site — which is why the warning belongs in the file the
caller reads rather than in a message they will not see. gospel found it.
2026-09-28 16:49:01 +00:00
Doxa Maps
c7edd82f9c Promote the set layouts to library, as one implementation
gospel's boundedSet and centeredSet move to library/components/charts, and every
importer follows in the same commit: gospel-internet's two components and the
gate. One implementation, no window in which someone edits the wrong copy.
gospel chose this over copying, for the reason the shared scatter already showed.

Two print-safe components come with them, adapted from gospel's rather than
written fresh: theirs fetch and hold board state, these take joined groups and
every label as props, so the packet and the board draw the same figure from the
same code. gospel's already inked their tallies and used no hover, so the
adaptation is the data path and the labels, not the drawing.

The labels had to change, and not for style: gospel's read T('gospelInternet.…'),
and a gospelInternet-namespaced key cannot resolve for another area's consumer,
so the packet would have rendered the English fallback in every locale. Labels
are props now.

Three invariants gospel gave me that the comments did not carry are pinned here:

No seed prop. Position is hashed from the group's own id, so a group lands in the
same place on every render, forever — two printings of one packet are identical
and a reader comparing sheets sees real movement rather than reshuffling. I had
specced a seed; it would have silently rescrambled every dot while the data was
unchanged, which on paper cannot be corrected. Asserted through the parser, and
adding one back turns it red.

Every ring is listed, empty ones included. Under a filter several of the nine go
to zero, and a decoder that omits its empty rows stops being a decoder: a reader
cannot tell "none here" from "this ring does not exist".

And an arrow is a claim about a relationship, so a group with no recorded
neighbour gets none rather than a default one toward the centre.

The unknown-is-counted rule is now in the module header where the next reader
meets it first, at gospel's request: a printed page has no hover, so a category
the reader cannot see is one they will assume is zero.
2026-09-28 16:49:01 +00:00
Doxa Maps
5b51153b82 A life-calendar anchor outside the grid says so instead of drawing nothing
By the regions owner. An anchor week past the grid, negative or fractional passed an isFinite
guard and matched no cell, which on paper reads the same as the deliberate no-anchor default.
anchorPlacement() returns a reason, LifeCalendar prints it, and check:packet-charts covers six
edge cases while pinning that no anchor asked for stays silent.
2026-09-28 16:49:01 +00:00
Doxa Maps
642fc15310 Prayer List gets the cards' caret, and starts printing the packet
The Driver asked for the SAME caret the cards already have on the Prayer
List, with two entries -- Full detail and Lightweight -- and said plainly
not to destroy the print options that exist. So nothing is merged, nothing
is removed, and the Poster is untouched.

There was no dropdown COMPONENT to reuse: the cards' caret is an inline
disclosure, one ref plus a sibling tape of plain buttons, deliberately not
a <select> because a native dropdown collides with other dropdowns on the
host page. So the PATTERN is generalized rather than copied. deckOpenFor
now holds a compound `${id}:${which}` key, which keeps "opening one closes
the others" free -- copy/paste would have given two open-states, and two
tapes could then sit open at once and overlap in the grid gap.

While wiring it, a real defect surfaced: THE WALL'S PRAYER LIST BUTTON HAS
NEVER PRINTED A PACKET. downloadPacket calls printList, a columnar table of
name/country/population/religion. The packet -- map, life calendar,
%-evangelical grid, triangle, profiles, wheel, points -- is printPacket,
which every other surface calls and this wall never did. That is a direct
cause of the packet "not following the design that was asked for" here: no
change to the renderer could reach a button that was not calling it.
printList stays exported for any surface that still wants the list.

The two packets are a FILTER over the existing page array, not a second
code path, so they cannot drift apart in layout, i18n, RTL or print scale.
The filter is keyed and never indexed -- an index filter reads identically
today and drops the wrong page the first time the order changes, and that
order is the Driver's. listPages is deliberately outside the filter: a
variant may drop a FIXED page, never one whose count depends on the data,
because that drops people silently. An unknown variant falls back to full.

internal/check-packet-variants.mjs pins all of it, and was mutation-tested
rather than trusted: dropping list pages, reverting to printList, adding a
second open-state ref, and typoing a variant id each fail with their own
message.
2026-09-28 16:49:01 +00:00
Doxa Maps
a7011c77ee An overlay that cannot be placed must not be drawn at Infinity
The funnel computed each bracket with Math.min and Math.max over the step
indices an overlay touches. On an empty list those return Infinity and
-Infinity, so the bracket rendered at y=Infinity with height -Infinity: an
invalid rect that SVG discards without a word. The overlay vanished and took its
groups with it — no error, no gap, just a chart missing a band nobody knew to
look for.

An overlay is unplaceable when its `within` is missing, empty, or names a step
that does not exist, and all three are easy to write. `within` is the unusual
half of the API, so an overlay written as { id, label, groups } looks complete
and is silently undrawable, and a typo in a step id deletes a band rather than
failing.

bracketSpan now returns null for all three, the component lists them in ink
instead of dropping them, and the arithmetic moved out of the template where it
could be asserted. An overlay with no known relationship to the steps must not
be drawn, because a drawn bracket asserts a span.

Found by reading the template rather than by a test, which is worth recording:
these components are in L61 and had never been rendered once. The negative
control pins the cause — Math.min of an empty span really is Infinity — so the
assertions rest on the measured behaviour and not on my account of it.
2026-09-28 15:52:47 +00:00
Doxa Maps
628edf4457 The three packet charts, and two constraints that live in a prop
VisionFunnel, LifeCalendar and ShareCalendar, all props-driven, no hardcoded
list, print first: fixed px on the packet's 816x1056 canvas, nothing revealed by
hovering, and the packet's own ink rather than a new palette, so a reader cannot
tell which page came from the component and which from the renderer.

The funnel draws steps as stacked trapezoids and overlays as brackets BESIDE
them, never inside. Being inside a step is precisely the claim an overlay cannot
make: 362 of DOXA's 2,107 groups are not frontier at all. It asks
overlayPlacement whether an overlay is a slice rather than deciding, and prints
each overlay's parts as text, because a bracket nobody can hover over explains
nothing. Given steps that do not nest, or that mix publishers, it refuses and
prints why instead of drawing a plausible picture.

It is also not mirrored under RTL, which is worth saying because the obvious
reading of "mirror it" is wrong here: the triangle is vertically directional, so
a horizontal flip reverses nothing meaningful and inverts any text inside it.
What mirrors is the label and bracket side, so those use logical properties.

Two decisions now live in prop declarations, and a default is how either dies
quietly. ShareCalendar refuses to name its own metric: the shipped figure is the
evangelical share while the brief says "% Christian", and one default would
reintroduce the confusion this project already retracted a P0 over, with nothing
looking wrong. LifeCalendar's anchor defaults to null because the packet prints
for a reader we do not know and shading their life is a guess presented as fact.
Both are asserted through the SFC and JS parsers rather than a grep, since a
regex cannot tokenise JavaScript, and a sibling prop that does carry a default
is the negative control proving the arm is not vacuous. Adding
default: '% Christian' turns it red, which I checked rather than assumed.
2026-09-28 15:01:19 +00:00
Doxa Maps
4f73d83438 One scatter implementation, and the gate that could have caught the bar
packet-render's lifeGrid already held a carefully reasoned shading: deterministic
so a packet reprints identically, and scattered because a contiguous run of 32
fills most of row 1 of 52 and prints as a solid bar across the header, which is
the ordinary chart the life grid exists to avoid. The message is rarity, and the
eye has to hunt for the 32 among 5,200.

That logic is now in packetCharts.js and packet-render imports it, so the
printed packet and the reusable chart cannot drift into two answers. The move is
behaviour-preserving: I ran the extracted function against the pre-refactor code
over 49 seed and count combinations with zero differences. That probe was
one-off, so the durable half is a golden fingerprint here — the exact cells for
one seed. If the algorithm is ever tidied, those change and every packet already
printed stops being reproducible.

The assertion that matters most is the one that did not exist when the bar
shipped. The existing check asserts the shaded COUNT is between 0 and 5,200,
which is equally true of a bar and of a scatter: a count cannot distinguish a
distribution, so no amount of that gate passing was ever evidence. rega's eyes
caught it, and nothing else could have. Now the spread is asserted — 32 cells
landing on 27 different rows — with the contiguous block as the negative
control, so the arm can fail in exactly the shape it exists to reject.

The rounding flag upg100 asked to keep reachable stays on the return value, so
the packet can print the honest caption when a share is too small to fill one
cell rather than inking one and asserting "some".
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
fe9fe34530 Set an alarm on the zone/region id collision, which data currently hides
gospel found a latent ambiguity in the emit contract while wiring, and checked
it instead of building past it. selectedId emits one bare string with no kind,
and region ids and zone ids share a namespace. Re-measured here rather than
taken on report: 33 zone ids, 7 region ids, and the intersection is exactly
europe, whose region is itself. So the string "europe" cannot tell a consumer
whether the Europe region row was clicked, meaning every European zone, or the
Europe zone leaf, meaning that one.

It is not live today, and only for a reason that is a coincidence rather than a
design: region europe contains exactly one zone, so both readings give the same
set. It goes live the day a second European zone is filed, and then it fails the
quiet way this whole file exists to catch — a smaller result, no error, no empty
state.

So this is an alarm, not a fix. The consumer builds the tree, so only the
consumer knows which rows are regions, and gospel is namespacing theirs. Two
arms: the colliding set is pinned and says re-derive if it changes, and each
colliding id's region must still contain exactly one zone. The second fails with
the instruction rather than the symptom, naming the zones and telling whoever
hits it to namespace their region rows.

Both falsified before trusting them: filing a second European zone fires the
ambiguity arm with both zones named, and adding a second colliding id fires the
drift arm with both ids listed. Data restored, clean diff.

The same note is on selectedId, because the next consumer to build a tree from
this zone data inherits the collision, and the doc as written invited it.
2026-09-28 15:01:19 +00:00
Doxa Maps
9500375be8 Vertical legend emits a zone id, and cannot drop a zone
Reworked to the consuming dashboard's contract. It emits a zone id or null on
zone-change, not a selector: an id is serialisable into a query parameter, a
profile config and a deep link, which is the chain that dashboard already
resolves its list from, and a predicate is none of those. It also keeps the
component promotable -- a selector would have to know what it selects over, and
the consumer holds four different row shapes across its tabs. The join stays in
one place, where the diacritic folding and the province bridge already are.

A leaf emits its zone id; a region row emits the region's id and the consumer
expands it, because that is where the zone data lives.

And it cannot silently drop a zone. Four joinable zones are absent from the
region file today, one of them the consumer's largest at a hundred groups, so
grouping by region loses them with no error and no empty state -- just a shorter
tree. Given the full zone set, the legend renders the difference in an explicit
bucket, each one selectable. That makes the drop structurally impossible rather
than something to keep checking. Omit the set and the tree is untouched, so the
guard is additive.

Its check includes a negative control that the unaugmented tree really does omit
the largest zone, so the bucket is demonstrably not decoration.
2026-09-28 13:55:52 +00:00
Doxa Maps
ff7b2daf7a A vertical legend that selects a list, for any dashboard to filter by
The Gospel x Internet dashboard needs the Driver's vertical legend on the side
to select a people-group list and filter its groups by it. This is that legend,
wired as a selector.

It WRAPS the shared semantic tree legend rather than replacing it. That
component is already the vertical legend and already drives the 26-zone
tapestry, so every fix it receives -- theme-awareness, the progress bar, the
mobile sheet path, scroll-to-selected -- keeps applying. A second widget would
have to re-earn all of it.

It emits the SELECTION, not resolved group ids, and that is ownership rather
than taste. Resolving a zone to ids means joining zone membership against the
group rows, and that join is not trivial: the membership keys are ASCII while
the province data carries diacritics, so a naive join silently loses hundreds of
groups. A legend that resolved ids would hold a second copy of that join and
the copies would drift -- appearing as a wrong count, which nobody notices. The
consumer already holds the rows and the join.

A region row selects the union of its zones, and both a leaf and a parent return
arrays, so a single zone is not a special case the consumer will eventually get
wrong. A cleared selection is representable, because a dashboard must be able to
drop its filter.

The selection rule sits in its own module with its own check, including a
negative control that a parent does not select only itself -- which would filter
to nothing while looking like it had worked.

The tree is a prop. Its builder lives in the plugin that owns the zoning, and a
cross-plugin import is barred, so promoting that helper to the library is a
separate coordinated move.
2026-09-28 13:55:52 +00:00
Doxa Maps
ce82467dae The search header's filter labels follow a mid-session language switch
The eight labels came from tStatic inside a computed. tStatic reads a plain window global the
computed cannot track, so its only dependency was props.labels and the row stayed in the boot
language after the host switched locale. They now go through useOptionalT, which calls the
host's vue-i18n (a reactive locale) when installed and falls back to tStatic when not; English
stays the fallback and a host's own labels still win.
2026-09-28 13:37:44 +00:00
Doxa Maps
726b11ae79 A lazy view that fails to load says so and offers Retry; map controls carry data-control
retryableAsyncComponent: after the three retries the pane rendered blank. It now shows
"This view couldn't load." with a Retry button (messages keys, 11 locales). Retry swaps in a
fresh async component, since Vue keeps a rejected load for the life of the page.

Each map control wrapper passes data-control (zoom-in, zoom-out, fullscreen, theme, location,
help, share) to its button: an untranslated hook for probes, where title and aria-label are
localised.
2026-09-28 11:42:54 +00:00
Doxa Maps
b2142ac0b0 DataTable + StackLayout: physical left/right -> logical, where the rule mirrors
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Six conversions, one deliberately kept physical. Same method as the legends.

CONVERTED
  DataTable.vue:238  .tabulator-cell border-right -> border-inline-end. A per-cell
    trailing border is the COLUMN SEPARATOR between adjacent cells; in RTL the
    separator has to sit on the other side or it draws on the table's outer edge.
  DataTable.vue:269  .dt-actions   margin-left:auto -> margin-inline-start:auto
  DataTable.vue:293  .dt-close     margin-left:auto -> margin-inline-start:auto
    Both are "push me to the trailing end of a flex row", which is the left in RTL.
  DataTable.vue:299  .dt-dl ul padding-left -> padding-inline-start. A list's
    marker indent follows the reading direction.
  StackLayout.vue:161,305  .stl-meta margin-left:auto -> margin-inline-start:auto
    (twice: the light and dark theme blocks).

★ KEPT PHYSICAL, AND IT WOULD HAVE BEEN A REGRESSION TO CONVERT
  DataTable.vue:245  '.dt:dir(rtl) … .tabulator-cell{text-align:right}'
  It is already SCOPED TO :dir(rtl), so right IS the trailing edge wherever it
  applies. More importantly the cells carry unicode-bidi:plaintext, which makes
  `end` resolve PER CELL from that cell's own first strong character -- which is
  the exact behaviour this line exists to override (English cells left-aligning
  inside an rtl table and clipping at its left edge, rega L20). text-align:end
  would silently undo the fix. Comment added at the site so the next reader does
  not "correct" it.

No consumer overrides any of the four classes: .dt-actions, .dt-close, .dt-dl and
.stl-meta appear in no other file in library/ or plugin-designs/, so a physical
override cannot defeat the logical rule and produce a half-mirrored table.

LTR is unchanged: inline-start/inline-end resolve to left/right under dir=ltr.
check:sfc-parse PASS. 13 plugin files use DataTable, 9 use StackLayout.

library/ is the build owner's under rule 5B -- source sha to studio; rega eyeballs ar.
2026-09-28 09:48:45 +00:00
Doxa Maps
462dd67982 legends: physical left/right -> logical, where the rule should mirror
Seven conversions, one deliberate keep, and a stale comment.

CONVERTED -- each is text that should follow the reading direction:
  LegendDesktop.vue:138,139  the count and population chips, text-align:right ->
    end. They are numeric badges at the trailing edge of a row; in RTL the
    trailing edge is the left.
  LegendDesktop.vue:154,157  the stats and footer VALUE columns, right -> end.
    Same argument, and they already used padding-inline-end, so the rule was
    half-logical and read physical.
  SemanticTreeLegend.vue:711,1105  two button labels, text-align:left -> start.
    Label text in a flex row; it follows the reader.
  SemanticTreeLegend.vue:764  .stl-tab-info border-left -> border-inline-start.
    This is a DIVIDER between two adjacent chips. In RTL the info button sits on
    the other side of its sibling, so the divider has to move with it or it
    draws on the outside of the pair.

KEPT PHYSICAL, deliberately:
  SemanticTreeLegend.vue:805  .stl-hdr-num::after { left:50%; top:50% } with
    transform:translate(-50%,-50%). That is the CENTRING IDIOM for a 24x24 WCAG
    2.5.8 hit target, not a direction: it computes identically in both, and
    converting it would add review noise that hides the real conversions.

PROVEN, not asserted: measured the GLYPH POSITION in a browser rather than the
computed keyword, because getComputedStyle returns text-align as the keyword
("end"), so a naive comparison reports DIFFER in LTR for rules that lay out
identically. Measuring the span's left edge in a 200px box:
  LTR  right=201px end=201px IDENTICAL   left=8px start=8px IDENTICAL
  RTL  right=201px end=8px   MIRRORS     left=8px start=201px MIRRORS
And for the border, where computed style does resolve: LTR 1px/0px both ways
(identical), RTL 1px/0px vs 0px/1px (mirrors).

Also fixes a comment that said "margin-left:auto pins it to the right edge"
above a rule that already read margin-inline-start:auto. Prose naming the
physical side is how a reader talks themselves back into a physical property.

library/ is the build owner's under rule 5B -- source sha to studio.
2026-09-28 09:07:29 +00:00
Doxa Maps
cc6e7cb6c4 PosterDialog prints by default instead of downloading, like PosterWall
The poster dialog had only a download path: its Render button saved a file, the
same trap PosterWall's old default was (gospel's Driver-lens audit, card 7).
A new action prop defaults to 'print': a PNG poster opens the print dialog in
place (printImageBlob, sized to the chosen page), a PDF opens in the browser's
viewer, and a blocked print frame or popup falls back to the file. 'download'
stays an explicit opt-in.
2026-09-28 05:30:58 +00:00
Doxa Maps
47cfc6ed34 Legend: "Go up to …" is read in the reader's language
The parent crumb's title and aria-label were typed in English inside the library
legend; they now read legend.a11y.goUp, in all eleven languages.
2026-09-28 05:30:58 +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
cdb3ee1e81 A data refresh is not a deselection: the legend keeps a selection its new nodes still contain
rega measured the WAGF Regions tab on Simple Research: the polygon highlight lands 51-85 ms after the
click, then 433-667 ms later it is gone — fill-opacity back to 0.7, line-width back to 0.6, no row
marked, every region at full colour. That reads exactly like the Driver's "the legend isn't connecting
with the map", and it is worse than not connecting: it connected and then undid itself.

Those two values are the fingerprint. 0.7 and 0.6 are written only by this profile's CLEAR paths, every
one of which is reached from a deselect — so something sent select(null) half a second after the click,
and nobody clicked twice.

SemanticTreeLegend watches the IDENTITY of its nodes array, and every tree feeding it is a computed that
returns a NEW array each evaluation. Any late async that touches a dependency — pins still arriving, a
lazily loaded layer, a refresh — rebuilds the array with IDENTICAL CONTENT, and the reset in that
watcher then cancelled the reader's selection: expandedIds cleared, selectedNode nulled, select(null)
emitted. The reset is right for genuinely new data; what was wrong is that it could not tell the two
cases apart.

It asks one question now: does the selected row still EXIST in the new nodes? If it does, re-point at
the new object and leave the tab and the expansion alone, because nothing about the reader's state has
become invalid. If it is gone, the reset runs exactly as before and the deselect is honest. Re-pointed
rather than kept, because the old object belongs to the discarded array and holding it would compare the
highlight against a node the tree no longer contains.

This is shared, so it is every tree legend on every map, not only mine: any profile whose data arrives or
refreshes after a click was silently losing that click.

NOT changed, and deliberately: the WAGF Regions TAB click also compiles shaders for regions-fill and
regions-border (rega: 2.9-4.1 s on SwiftShader), because those layers are added on that click. Warming
them at boot would mean paying for two more layers on every reader of every tab to save a one-time cost
on one tab, and the 6.4 MB regions GeoJSON is lazily loaded there on purpose. SwiftShader inflates a
compile by roughly an order of magnitude — the pins variant measured 3.2-3.5 s there and +0.5 s of real
boot delta — so the honest expectation on a GPU is a few hundred milliseconds, once, on a tab that is
already loading megabytes. That is a trade I am declining to make rather than one I missed.

Verified: gates:pre 12/12. Needs eyes: rega, the same WAGF Regions row click — the highlight should now
still be there at 5 s, and the variant compile may appear on that click once the selection sticks.
2026-09-28 03:12:45 +00:00
Doxa Maps
9b150b902d Square panel: no child brings its own outline
ShareButton is not a .mcb. It wraps its button in a .share-wrap div carrying
border-radius:50% and a conic-gradient ring on ::before at inset:-2px, so the
button rules missed it and it kept a round rainbow halo inside the square
panel -- breaking the single-card illusion at the corner the eye lands on.

rega measured it at 390 and asked whether square should flatten it. It
should: in this variant the panel owns the outline, so a child may not bring
its own. The round default is untouched.

Gated, with a control asserting ShareButton really does carry a 50% radius,
so the two rules are not decorative.
2026-09-27 23:23:42 +00:00
Doxa Maps
e994877984 Legend progress bar draws no empty segments, so its left end is rounded again
A zero-width segment button was still rendered: an invisible tab stop, and the
first child that took the bar's rounded left end. Segments with no width are
now left out.
2026-09-27 23:14:15 +00:00
Doxa Maps
55573df1d2 Rudder learns whether this server should have an engine even when its event stream is buffered; the legend bar keeps its rounded ends
The dev backend answers /status with the same offline status as /events, and
Rudder reads it on mount and whenever the event stream drops, so a proxy that
buffers the stream no longer leaves it saying "Runs on your computer" on a
server that should run the engine. The local-microphone notice no longer shows
off this computer. The legend progress bar paints its grey band as a rounded
strip and rounds the first and last segment, which the 24px touch targets had
squared off.
2026-09-27 23:14:15 +00:00