Commit graph

116 commits

Author SHA1 Message Date
doxa-maps-88655c70
02fc4bd45c Regions tab 2: clone DX-life's region dashboard
Tab 2 was a subject band, four tiles and one bar chart. DX-life's /regions/<slug>
is five blocks, and the gap was the whole point of the tab. Ported from
RegionDetail.vue, block for block:

  • back-link / title / summary, centred          RegionDetail.vue:47-51
  • four stat cards                               :53-70
  • three DOXA goal cards in their .switcher      :88-141
      Prayer Coverage — the segmented 100+/1+/none bar with its legend
      Adoption Progress and Engagement Progress — progress bars
      each goal sentence verbatim; they are the DOXA goals, not our copy
  • the five-column country breakdown             :144-171

Every figure comes from the rollup this element already computed, so tab 1 and
tab 2 still cannot disagree about a number. Nothing was invented to fill a card.

WHAT HAPPENS WITH NOTHING SELECTED, which was the open question: DX-life has no
answer to borrow — /regions is the list, /regions/<slug> always has a subject,
and [slug].vue 404s when the slug matches neither. A 2-tab element can't do
that, since tab 2 is reachable before anything is clicked. So it keeps their
invariant (the dashboard always has a subject) and makes the widest honest
subject the default: All Regions, every figure summed, the table switched from
countries to regions so it still compares like with like, and a line saying how
to narrow it. The back-link returns there instead of navigating.

Two substitutions, both forced by Bebas Neue being uncloneable (their font file
is served without CORS) and both measured, not guessed:
  • goal-card h2 pinned to --font-size-2xl (38.147px), the FLOOR of their own
    clamp. Measured: 'Engagement' is 380.1px at their 59.6046px in our stand-in
    face and 243.2px at 38.147px, against 261.55px of inner width. Their size
    made each card's min-content ~490px, so the third wrapped to its own row —
    579/579/1200 instead of their 372/372/372.
  • goal-card figures stepped one rung to --font-size-3xl and pinned nowrap, so
    '21 / 2,107' cannot break after the slash and read as two numbers.
Also min-width:0 on the switcher children: a flex item's automatic minimum is
its min-content, which is what blocked their basis from governing.

Verified against the LIVE site, not against the stylesheet: 33 computed
properties on doxa.life/regions/africa at 1440x1000 are now IDENTICAL, 0 differ,
and the four stat figures match their API exactly (315 / 41 / 43,045,275 / 300).
That diff is also what caught a real error — I had the low end of their 1.33
spacing scale shifted a step (xs is 1rem = 10px, 2xs is 7.5188px, 3xs is
5.6532px), so table cells and stat labels were a step tight. Every ported value
is now exact token math rather than one decimal place.

23 assertions across the three selection states, plus the tab-1 swap: reverting
this file to HEAD and rebuilding fails 21 of them.

Not ported: RegionDetail's CountryMap. It needs a per-region centre/zoom/bounds
rollup and a token-bearing map child, and this profile ships isMap:false — a
separate piece of work, not a detail. The comparison chart stands where their
map goes until it lands.
2026-09-23 23:33:51 +00:00
doxa-maps-88655c70
ea390cee7f Regions: honour DX-life's 1200px container cap, and its mobile clamps
Our regions cards stretched to the full pane at every width. Measured at a
1920px viewport they were 1880px wide; DX-life's /regions caps at 1200px and
centres. Ported their .container verbatim from
app/assets/styles/composition/_container.scss:

    width: calc(90% + 16px); max-width: 1200px; margin-inline: auto

so the column is fluid until ~1316px of width, then pinned at 1200px. Applied
to both tabs — RegionDetail.vue and CountryDetail.vue both carry
class="container", so the dashboard half is capped by the same rule.

The mobile half was a separate miss: the first port resolved every clamp() at
its DESKTOP maximum, so a 390px phone got a 47.7px region title and 41.6px of
side padding. DX-life gives that phone the clamp FLOORS — 30.5px and 17.7px.
The clamps are clamps again, in container-query units rather than vw: this
element is also one pane of the regions-mfe composite, where the viewport is
not the width available, so cqw honours their decision (scale with the width
you have) instead of transcribing a unit that only works in their layout. At
the leaf page the pane is the viewport, and the two resolve identically.

Measured against their formula at six viewports, both tabs, real browser:
1920/1440 -> 1200.0, 1280 -> 1168.0, 1024 -> 937.6, 768 -> 707.2, 390 -> 367.0,
each within 1.5px of min(0.9*pane + 16, 1200), no horizontal overflow. Title
font-size tracks clamp(30.5, 4cqw, 47.7) exactly: 47.7 at 1280+, 40.96 at 1024,
30.72 at 768, 30.5 at 390. Reverting both files to HEAD and rebuilding flips
all 12 assertions to FAIL, so the measurement is not vacuous.
2026-09-23 23:07:52 +00:00
doxa-maps-88655c70
1d4fe185e3 Full table headers via horizontal scroll, and a full-screen table
Some checks are pending
build-gate / build (push) Waiting to run
Driver, on the people-groups table under the map: "I would like to add a
horizontal scroll bar so that instead of ABBREVIATING the headers, we can have
the FULL headers in the table", and "I would also like to add a FULL SCREEN
button to the table, so that if you want you can open up the table to be full
screen."

These ship together on purpose. The table now shows seven data rows by default
because we are reclaiming vertical space; a deliberately small table is only an
honest trade if the reader can get a big one on demand. The cap and its release
are one change.

HEADERS ARE NO LONGER ABBREVIATED. text-overflow:ellipsis is gone from .rd-th.
Ellipsis destroys information with no way to recover it -- "Engageme..." is not
a column name, and unlike a truncated VALUE it has no menu behind it. Horizontal
scroll preserves it: the full text always exists and is always reachable. This
is the same mechanism already applied to the chart legends (nowrap +
overflow-x:auto), deliberately reused rather than a second invention.

THE FLOOR IS MEASURED, NOT ESTIMATED. Columns are flex:<w> 1 0, which can never
overflow -- flex-basis 0 plus shrink means every column collapses to whatever
the container offers, so scrollWidth always equalled clientWidth and overflow-x
had nothing to scroll. Each column now carries a min-width derived from its own
header. A first pass guessed 7.1px/char, which put the summed floor at 675px
against an 878px scrollport, so it never bound and no scrollbar could appear at
any width. Measured live by cloning each header out of flex flow: worst case is
9.75px/char (Language 78px/8ch), so 9.9 covers every label, +12px reserves the
sort indicator. Summed floor 742px + 30px checkbox.

That floor sits BELOW the 1440px scrollport on purpose. At 1440 the eight
columns genuinely fit and there is nothing to scroll; forcing a scrollbar there
would invent a defect. It binds at 1280 and below, which is exactly where the
truncation being complained about was happening.

ROW WIDTH IS SHARED, NEVER INTRINSIC. An intermediate pass used width:max-content
to create the overflow. It did -- and sized every row to ITS OWN content, so a
row holding "Lao People's Democratic Republic" laid out wider than the row above
it. That is not a table but a stack of independent flex lines: screenshot-
verified ragged, with Country/Region/Language stepping left and right on
consecutive rows while the header, correctly aligned, disagreed with all of
them. The measurement said so too and was read past: scrollWidth was 969 at the
top of the list and 1051 after scrolling, i.e. the table's width depended on
which rows happened to be rendered. Head, rows and footer now share one explicit
width (summed floor as min-width, width:100%), so nothing is intrinsic and the
column grid holds at every scroll offset.

The sticky header stays pinned on the vertical axis only, which is correct: it
must travel sideways with the body or the labels detach from the columns they
name. The footer is a SIBLING of the scrollport, so it is translated in lockstep
from the scroll handler -- otherwise the population total slides out from under
its own column.

FULL SCREEN is CSS pseudo-fullscreen, not requestFullscreen(). The native API
promotes the element to the browser's top layer, detaching it from the cascade
this table is built on, and needs allow="fullscreen" to work in the iframe embed
this dashboard actually ships in. Pinning the wrapper with position:fixed keeps
the element where it is in the tree, so inherited tokens and ancestor rules keep
applying and only the two rules we mean to override are overridden. Esc exits.

The seven-row cap is released with max-height:none !important, and !important is
the honest tool rather than a specificity war: the first attempt lost 0-2-0 to
the shell's 0-3-0 selector and rendered a 900px box containing a 182px table --
the exact failure its own comment warned about. That shell rule is also being
rewritten concurrently, so matching its selector would break on the next edit.

library/components/map-controls/FullscreenButton.vue was read first and does not
fit: it resolves its target as the SHADOW HOST, so pointed here it would
fullscreen the whole dashboard rather than the table; it renders MapControlButton,
a circular control built to float over a canvas; and it depends on vue-i18n,
which this bundle does not use. Making it fit means adding a target prop and a
chromeless variant to a component with seven map consumers to serve one non-map
caller, so this stays local until a second table wants it.

Verified at 1280 and 1440, real browser, live data: headers complete with
text-overflow clip and no cell clipped; scrollWidth 867 vs clientWidth 718 with
the last column's left edge moving 1045 -> 896; rows recycle (abai-sungai ->
deaf-comorans) with the rendered count bounded at 14; zero ragged columns and
zero head-vs-row drift at every scroll offset; fullscreen 244 -> 900px with the
keyhole 182 -> 838 and 45 rows rendered, restoring exactly on exit.
check:fits-screen 17/17.
2026-09-23 20:25:08 +00:00
doxa-maps-88655c70
afe6abe556 Plugin studio: show what a composite is made of, in one panel
Some checks are pending
build-gate / build (push) Waiting to run
A "Composed of" panel now sits under the plugin title, above the live
preview. For a composite it lists one row per directly-nested child:
name, element tag, and a capability badge, each row a link to that
child's own entry. A leaf plugin shows no panel.

Composition is no longer navigation. The rail had carried a collapsible
folder per composite, which re-listed children that already had their
own home — people-groups-grid held six nav nodes, so there was no single
name to call it by when reporting a problem. The rail is back to one
node per plugin in its own capability tier (31 nodes, 31 manifest rows,
none repeated) and the relationship lives on the plugin's own page.

Depth is one. A nested composite shows as a single row and does not
expand its own children; its breakdown is one click away, on its page.

The badge reads the manifest's declared profile type, so retyping a
profile moves its badge with no code change here. A declared child that
is published nowhere is counted as "not published" beside the heading
rather than dropped in silence — big-picture names bp-research-header,
which is an internal part, not a plugin. The list caps its own height:
the layout's header row is auto-sized, so ten uncapped rows would have
taken height from the preview instead of overflowing.

Rows are buttons with descriptive aria-labels, reachable and activatable
by keyboard. No emoji glyph on a row: this box has no emoji font, so
every one rendered as an empty box, and the badge says it better.
2026-09-23 20:21:48 +00:00
doxa-maps-88655c70
a69386582c Reclaim height: seven data rows, slimmer headers, charts scroll sideways
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "we are trying to reclaim height."

TABLE = SEVEN DATA ROWS, stated in ROWS rather than pixels. The old formula
derived the keyhole by subtraction (shell - map - bar - foot), so the row
count was whatever fell out -- measured 300px = 11 rows. Now
calc(var(--rd-table-rows,7) * var(--rd-table-row-h,26px)), so the promise is
the declaration: change the row height and seven rows stays seven rows.
His own arithmetic settles an earlier ambiguity (he had said both 77 and 7):
seven is DATA rows, excluding field, footer and table header.

HEADERS 137px -> 125px: topbar 52 -> 44, stats 30 -> 26, both now vars so a
host tunes them in one place instead of three grid-template-rows that must
agree.

CHARTS scroll sideways instead of wrapping. This is the third pass on that
trade and it resolves rather than reverses it: pass 1 was nowrap + ellipsis +
overflow-x HIDDEN, so labels were truncated and unrecoverable -- the defect
he rejected. This is nowrap + overflow-x AUTO, so the full label still exists
and is reachable. Legend cap 176 -> 120.

Measured at 1440x900: grid-template-rows 44/55/26/476/244, keyhole 182px,
exactly 7 data rows, shell 863 -> 849 with the map gaining 116px.
2026-09-23 20:03:52 +00:00
doxa-maps-88655c70
e5c66272c2 Tighten the vertical legend's row spacing without touching the type
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "the spacing between the rows is too thick, they take up way too much
height -- the padding should be decreased per row."

Row vertical padding 3px -> 1px, min-height 22px -> 18px, section header
padding 8px -> 5px.

Font-size is deliberately untouched at 12px. It is not a lever here: the
Driver previously rejected shrinking it ("super tiny text that cannot be
read"), and the note beside .fr-lbl records that the width for the count came
from the bar and the gutters for the same reason. Only whitespace moved.
min-height 18px still clears a 12px glyph at line-height 1.3, so no descender
is clipped.

Measured live at 1440x900 with four sections open, 69 rows rendered:
  row height 24px -> 20px
  computed padding 1px/1px, font-size 12px unchanged
  rail content height 1966px -> 1424px (542px saved)
2026-09-23 19:58:39 +00:00
doxa-maps-88655c70
02baa2d42b Switch the choropleth from heartbeat-red to the greyscale ramp
Some checks are pending
build-gate / build (push) Waiting to run
Driver ruling: "instead of the red, we need to use the grey scale from grey
to black." The greyscale preset was built earlier at his request as a
swappable alternative; this is him taking the swap.

One config key moves both the choropleth and its legend together, which is
why it was built as config rather than scattered hex. Ramp is
#e8e8e8 / #a9a9a9 / #6f6f6f / #3a3a3a / #060606.

Verified in the shipped bundle: colorRamp "greyscale", the five stops
present, sfc-parse passes.

Worth knowing for the follow-up: the colour research measured the ramp
change as roughly neutral for pin visibility (worst case 1.96 greyscale vs
1.84 red). The real fix for pin contrast is the lightness-conditional stroke
-- dark ring under a light pin, white under a dark one -- which lifts the
worst case to 4.51 and works on either ramp. Not done here; that is its own
change.
2026-09-23 19:57:08 +00:00
doxa-maps-88655c70
ca4343c25d Regions mounts its cap-4 composite; UPG-100 drops its duplicate share and shrinks its dots
Some checks are pending
build-gate / build (push) Waiting to run
REGIONS WAS RENDERING AS A CAP-1. The regions-mfe composite (nested-elements-mfe
over regions-list + region-dashboards, with the shared Select View strip) had
shipped, but the shell entry still named the bare `regions-list` leaf -- the
cap-1 half -- so one flat list rendered and the composite sat unused. The cause
was in the shell, not the regions bundle. Compare the UPG-100 entry, which names
`upg-100-mfe` and is why that tab has tabs.
Measured in the shell after: Select View present, options "Regions List" /
"Region Dashboards".

DUPLICATE SHARE BUTTON. The map carried its own ShareButton while the composite
header already mounts one. Removed the map-level control here rather than in the
shared component, because sharing is per-map policy: Prayer Progress is the map
meant for embedding elsewhere and keeps its own button; a map only ever seen
inside a composite does not need one. Unused import dropped.

DOTS WERE OVERCORRECTED. An earlier pass raised z=1 radius 4 -> 6 to fix "hard to
see". Sound reasoning, wrong amount: it made this the largest-dotted map in the
set (all-people-groups is 2.2 at z=0). Now 5 at z=1, keeping a deliberate step
above the dense map without leaving the family; the halo carries visibility.

Gates: template-literal, sfc-parse, sri all pass.
2026-09-23 19:37:55 +00:00
doxa-maps-88655c70
1cf4e89dab Resolve a composite's child bundles against the tree root, not a guessed depth
Some checks are pending
build-gate / build (push) Waiting to run
Driver-reported console text:
  Could not load <doxa-research-map> from ../../doxa-research-map/doxa-research-map.js

A composite declares children as src: '../../<bundle>/<bundle>.js'. That literal
is right from a one-deep leaf and wrong from a two-deep one, and the generator
emits both:
  .../research-map-plus-grid/index.html          ../../ -> /doxa-maps/          200
  .../research-map-plus-grid/default/index.html  ../../ -> /doxa-maps/rmpg/     404

So the path encoded an assumption about the host's depth that the host does not
control and cannot see. It survived because every gate exercised the shallow
leaf, which is genuinely fine.

StackLayout now derives the tree root from the document's own pathname (up to
and including the /doxa-maps/ segment) and rebuilds the path from there --
depth-independent by construction, so a third leaf depth cannot reintroduce it.
Absolute, protocol-relative and root-relative srcs pass through untouched, and
a missing /doxa-maps/ segment returns the original string, so this can only fix
a path, never break a working one.

Measured, both leaves, real browser with live data:
  one-deep  mapEl=true mapH=792 canvasH=856 0 4xx
  two-deep  mapEl=true mapH=792 canvasH=856 0 4xx   (was 404, map absent)

Also carries source that had been left behind by its own built output:
the cities/gospel legend bounding (shipped in 5008a6f as output only) and the
per-profile config carrier in generate-tree.mjs, plus a new fits-screen clause
that wheels inside every column rather than one.
2026-09-23 19:34:28 +00:00
doxa-maps-88655c70
d97cf9eab5 Say why a tab could not load instead of painting an empty grey pane
Some checks are pending
build-gate / build (push) Waiting to run
The Simple Research tab rendered as a featureless grey rectangle with no
message, no overlay and nothing in the console. It reads as a broken app,
and it hid a plain missing-file condition through four separate reports.

Two silent failure paths produced it, one layer apart:

- doxa-maps-spa swallowed a failed manifest.json fetch. Every tab except
  Big Picture is composed from that manifest (Big Picture alone is baked),
  so losing it leaves each tab with no scriptUrl. mountTab then returns
  early and the pane stays in state 'idle' -- which renders neither the
  loading hint nor the error hint. The manifest fetch now tries the built
  tree location as a second candidate and, if every candidate fails, sets a
  message the pane displays.

- StackLayout's per-section catch deliberately left a failed section's host
  empty so the rest of the stack still renders. Keeping the stack alive was
  right; leaving the band blank and silent was not. A failed section now
  shows which element failed and which URL it came from, and still does not
  abort its siblings.

Tree-relative scriptUrls are also resolved against the manifest they came
from rather than the current document. The two roots are identical in the
built tree and differ elsewhere, which turned every composed tab's bundle
into a 404 against a path that exists nowhere.

Measured, built tree: research-map-plus-grid mounts both children, 4778px
of content, live canvas, zero 4xx -- unchanged. dashboard-plus-grid,
stack-demo and research-map-plus-grid all render with a painted canvas and
no failure cards. Where the bundles genuinely cannot be reached, the pane
now names the element and the URL instead of showing grey.
2026-09-23 19:14:27 +00:00
doxa-maps-88655c70
bca3d58c3e Regions element gets the Select View tabs, shared with UPG-100
Some checks are pending
build-gate / build (push) Waiting to run
The Regions bundle shipped only tab 1, so the Select View switcher the
UPG-100 list shows had nowhere to live. Adds the composite that carries
it over two panes -- Regions List and Region Dashboards -- plus the
Region Dashboards tab itself, which summarises a region or a country
from the same live endpoint and the same rollup tab 1 uses.

Clicking a region heading or a country row on tab 1 now forwards that
selection and swaps to tab 2 with it applied.

The switcher itself is extracted into library/components/ViewSelect.vue
rather than copied a third time: it had two implementations already, in
the app shell and hand-copied into upg-100-mfe. It takes the views and
the current id as props and knows nothing about maps or panes, so the
placement stays with each header. UPG-100 now renders from the shared
component and is unchanged on screen -- same 45px height, 13px labels,
8px radius.
2026-09-23 19:08:00 +00:00
doxa-maps-88655c70
5008a6ff5f Bound the cities and gospel legends so the map controls stay clear
Some checks are pending
build-gate / build (push) Waiting to run
Both maps drew their legend as a full-width strip (left:8px right:8px --
measured 1264px of a 1280px map, 98.75%), which ran underneath the floating
control rail. MapToolbar sits at inset-inline-end:3px with 32px buttons, so
any box reaching right:8px intersects it at every viewport; the rail's higher
z-index kept the buttons clickable, which is why the defect survived three
reports as "looks broken but works". Gospel ran under four of five buttons,
cities under two, the difference being only legend height.

The legend is now a bounded, left-anchored panel imitating the shared
SemanticTreeLegend: left:8px with width var(--map-legend-width,380px). Being
bounded on the left, it cannot reach the right rail at all -- a geometric
guarantee rather than a tuned z-index. Below 480px it keeps a 46px gap so the
rail still clears. Measured after: legend 380px of 1280 (29.7%), zero
intersection with all five buttons, 857px clearance, and a dispatched click on
the top control landing on button.mcb via composedPath. Mobile at 390px: 336px
wide, 11px clearance, zero intersections. Both files kept byte-identical.

Both maps also showed the shared "Tap a coloured dot" hint while shading
country polygons and drawing no dots at all -- a hint pointing at something the
reader cannot see. PinHint documents a pinHint:false opt-out, but the
standalone leaf page built its config from a hardcoded object, so no profile
could reach that key. <profile>.meta.json now carries an optional `config` map
merged into the leaf's BASE_CONFIG, with generator-owned keys reserved and the
carrier stripped before the manifest is written so its published shape is
unchanged. Both maps opt out; the hint is gone from both.
2026-09-23 19:07:13 +00:00
doxa-maps-88655c70
5520b32173 Size the UPG-100 dots for a hundred marks, not twelve thousand
Some checks are pending
build-gate / build (push) Waiting to run
At world zoom the dots were radius 4 with a 1.2px halo -- the same scale
all-people-groups uses for ~12,379 marks, where density itself carries the
signal. This map draws ONE HUNDRED, alone on a whole planet, so the same
radius reads as almost nothing.

Radius 4 -> 6 at z=1 (+50%), tapering back to the original 9 by z=8 where
geography supplies the context. Halo widened 1.2 -> 1.8 in step, so it stays
a frame rather than a smudge: a 1.2px ring around a 6px dot is
proportionally thinner than around a 4px one, which would have spent the
size increase and lost the contrast that made small dots findable at all.

Verified in the shipped bundle: new interpolation present, old values gone,
0 data 404s, legend mounts, canvas at real height.
2026-09-23 18:47:36 +00:00
doxa-maps-88655c70
1bf42e2110 Let the dashboard element size to its content when it is page-scrolling
Some checks are pending
build-gate / build (push) Waiting to run
:host carries height:100%, which the fixed-viewport host needs and a
page-scroll host must not have: in a tall scroller, 100% is thousands of px,
so the element claimed a large empty tail below its own content. The shell
inside already switched to height:auto for this reason; the host never did.

A host cannot be matched by a class on its own descendant, so the pageScroll
flag is reflected outward as a data attribute and :host([data-page-scroll])
switches to height:auto with the min-height dropped.

Labelled UNPROVEN in the file, deliberately: dashboard-plus-grid imports this
as a plain Vue component rather than mounting the custom element, so the rule
is not reachable from there -- and that composite was measured stacking
correctly regardless (dashboard 0..864, grid 864..3031, gap 0). The rule
applies where research-dashboard is mounted as its own element.
2026-09-23 18:41:07 +00:00
doxa-maps-88655c70
6ba3ad76aa Legend rises into the removed search bar's space -- but only where it was removed
Some checks are pending
build-gate / build (push) Waiting to run
The UPG-100 legend was pinned 58px below the map top by a profile !important
rule whose job was to clear the geocoder pill. Measured with a real token in
both hosts: the standalone leaf DOES mount the geocoder (top 12, h 35), so 58
is correct there; the cap-5 shell mounts NO geocoder at all, so the same rule
stranded the legend below a pill that was not there -- the dead gap reported
repeatedly.

One host must not be fixed by breaking the other, so the offset is now
conditional on the pill existing: default 8px, pushed to 58px only when
:has(.mapboxgl-ctrl-geocoder) finds a rendered pill in the tree.

Also parameterizes the shared SemanticTreeLegend's offset as
var(--stl-top, 60px) with the max-height mirroring it, so any profile that
drops its search bar can raise its legend without an !important arms race.

Measured after: leaf legendOffsetInMap=58 with geocoder present; shell
legendOffsetInMap=8 with no geocoder. Full authoritative rebuild included --
composite freshness PASS, SRI PASS 45/45.
2026-09-23 18:30:00 +00:00
doxa-maps-88655c70
cb07c556e1 Plugin studio: collapsible composition sections per composite bundle
Some checks are pending
build-gate / build (push) Waiting to run
Every composite bundle (one whose manifest entry carries `composes`) now
gets its own collapsible folder in the rail, with the composite itself as
the top row and its composed children underneath, grouped/labelled the
same way the existing capability-tier rail already is. Reuses the
existing folder collapse/expand mechanism (VerticalTabsLayout, the same
one Experimental already uses) rather than inventing a second
interaction. Driven entirely from manifest.json's composes edges, so a
future composite needs no code change to appear. A child can legitimately
appear in more than one section (e.g. people-groups-grid nests into four
different composites) — that duplication is the point, not a bug.
2026-09-23 18:18:04 +00:00
doxa-maps-88655c70
eaa2c35286 Give the dashboard its own height handle, separate from the map/table split
Some checks are pending
build-gate / build (push) Waiting to run
Dragging the map/table divider in page-scroll mode was also resizing the
whole element, because --rd-map-h alone answered both "how is the map/table
space split" and "how tall is the element" -- Driver: "you improperly bound
that control to the separator line between the map and the table... that's
not the request I was making to resize the height of the page."

--rd-shell-h is now the independent total-height budget; the new bottom
handle (.rd-rz-shell) is the only thing that writes it. The existing
map/table divider still drives --rd-map-h, but only as the map's share of
that fixed budget, so it redistributes between map and table without
changing the sum. Table/facets/rail max-height all re-derive from
--rd-shell-h so the sidebars keep spanning exactly map+table at every drag.

Also closes a 28-30px sidebar gap the re-derivation exposed: the table
budget only ever subtracted the header bar's height, never the summary
footer's, so facets/rail undershot the table's real bottom edge.
2026-09-23 18:16:30 +00:00
doxa-maps-88655c70
5d87bd9db1 Poster print options: two per line, clear of the blue hover outline
Some checks are pending
build-gate / build (push) Waiting to run
The fieldset is anchored bottom:-26px and grows UPWARD as content wraps, so
at one-button-per-line (three lines tall) its top reached into the image --
which is where the blue hover outline lives. Widened the row from 82% to 96%
so two labels share a line.

Measured on all three poster cards at 1440x900: 2 button lines, height 50px,
top 3px BELOW the image bottom, zero overlap with the outline.

Includes the tree regeneration so every leaf's SRI matches its bundle
(a scoped BUNDLE= build alone leaves leaves stale -- check:sri PASS 45/45).
2026-09-23 18:10:35 +00:00
doxa-maps-88655c70
2a8318433f Remove a second stray backtick from research-dashboard shadow-CSS
Some checks are pending
build-gate / build (push) Waiting to run
Shadow CSS is emitted inside a JS template literal, so a backtick in a comment
terminates the string and the bundle stops compiling. Second instance in this
file; the first one already cost a broken build this session.
2026-09-23 17:25:55 +00:00
doxa-maps-88655c70
0ebb3e34ac Sidebars span map AND table; facet rail opens with its own scrollbar
Some checks are pending
build-gate / build (push) Waiting to run
The side columns stopped at the map's bottom edge, leaving the people-groups
table outside them. Cause was a single rule capping both side columns at the
map's height in page-scroll mode:

  .rd-shell.rd-page-scroll > [data-region="facets"],
  .rd-shell.rd-page-scroll > [data-region="rail"]{ max-height:var(--rd-map-h); }

The grid already spanned both rows correctly ("facets map rail" / "facets table
rail") -- this max-height was overriding it, which is why earlier passes that
adjusted the rail's CONTENTS never moved the bound. Replaced with min-height:0,
letting the grid do the bounding it was already doing.

Facet rail sections people/place/language/religion open by default again, with
.fr-body's own overflow-y:auto providing the scrollbar.

Measured, both hosts, 1440x900, real browser:
  dashboard-plus-grid (page-scroll): facets 140..2158, table bottom 2158, delta 0
  research-dashboard (fixed-viewport): facets 140..900, table bottom 900, delta 0
  facet rail scrolls: scrollHeight 1966 in clientHeight 708
2026-09-23 17:24:43 +00:00
doxa-maps-88655c70
4bed933001 Keep map scroll-zoom enabled now that the page-scroll map row is bounded
Driver: "I can't zoom in and out on the map. The scrolling is causing that
to change." Scroll-zoom was force-disabled whenever an ancestor scrolled, a
fix from when the dashboard band had no bounded height and a wheel over the
map was the only gesture available in the lower two-thirds of the page.
--rd-map-h changed that precondition: the map row is now a fixed, bounded
height, so the cursor naturally leaves it once a user scrolls past. Verified
on the live mapboxgl.Map instance (not a pixel-diff proxy): zoom now moves
1.45 -> 1.95 on a wheel over the map in the cap-5 shell, standalone
dashboard-plus-grid leaf, and standalone research-dashboard leaf, with no
change to pane scroll position in any of them.
2026-09-23 16:57:36 +00:00
doxa-maps-88655c70
f53dbcea72 Drop the redundant verb from the print labels, and clear the artwork
Some checks are pending
build-gate / build (push) Waiting to run
TWO SMALL FIXES, both Driver-reported by eye.

LABELS. The fieldset's legend already reads "Print Options", so "Print a" in
every button was three redundant words competing for the row's width, and the
printer emoji beside that legend added no information. Labels are now the object
only -- "UPG-100 list" / "UPG prayer poster" / "card deck" -- which fits two on
the first row and one on the second, as asked.

The aria-label keeps the full sentence ("Print the <list> UPG card deck"): a
screen-reader user does not get the legend's framing as reliably as a sighted
user reading down the fieldset, and a control announced as "card deck" alone is
ambiguous. Short visible label, descriptive accessible name.

POSITION. The options row overlapped the poster artwork by 12px. My first
measurement said that 12px was letterboxed white margin, because the image is
object-fit:contain -- but these posters fill the card's WIDTH, so contain
letterboxes at the SIDES and the bottom 12px is real artwork. Reading the
screenshot showed the legend printed across the poster's own footer line.
Geometry said white space; the image said otherwise.

bottom:-14px -> -26px. Overlap measured 12px -> 0px, and the screenshot now
shows the poster's footer text fully legible with the legend on the card's white
edge below it. The negative offset is kept rather than moving the row into flow,
because the tape header at top:-11px uses the same overhang idiom and the 26px
row gap was sized for that pairing.
2026-09-23 16:54:54 +00:00
doxa-maps-88655c70
1a00cac2f7 Fix regions-list clone: real chevron SVG, correct title/link weights, grid indent
Some checks are pending
build-gate / build (push) Waiting to run
Second pass against Driver feedback. Measured getComputedStyle() on the live
doxa.life/regions page and diffed against ours:

- Chevron was a text glyph (&rsaquo;), not DX-life's real SVG icon
  (public/assets/icons/arrow-chevron.svg#chevron-up). Ported the actual path
  geometry and rotation.
- Region title was bold + letter-spaced + uppercase; the live page measures
  weight 400, letter-spacing normal, no text-transform — Bebas Neue's own
  glyphs carry the bold/condensed/caps look, DX-life applies nothing on top.
- Country links were weight 400; live page measures weight 500.
- countries-grid was missing the page's global list indent (35.2px
  padding-left, measured live).
- region-card__footer had a stray ~19.5px bottom margin from the <p>'s
  user-agent default; DX-life's rule is margin-top only.

Colours/fonts/radii/spacing here are an intentional exact clone of DX-life's
own design (Driver's instruction) — not this repo's material-light chrome
tokens. Do not "fix" this back to our house palette.
2026-09-23 16:36:35 +00:00
doxa-maps-88655c70
b71b4bbfa3 Fix UPG-100 poster print-options row to wrap instead of stretch
Some checks are pending
build-gate / build (push) Waiting to run
.pbtn had flex:1 + white-space:nowrap, forcing every print button to
stretch full-width and forbidding its label from wrapping — one very
wide button per line. Now .pfs wraps and .pbtn sizes to its own
content, so short labels sit two per row when they fit and long ones
wrap to their own line without stretching or clipping.
2026-09-23 16:35:45 +00:00
doxa-maps-88655c70
3ac4175e98 Land four agents' dashboard work: resize handle, slimmer stats row, closed rails
Some checks are pending
build-gate / build (push) Waiting to run
Four delegated agents left verified work uncommitted in shared files. Committing
it together because it is interdependent and all of it passes the gates; each
agent's own measurements are in _qa-cycle/02-builder-cycle/delegation/.

RESIZE HANDLE (resize-sections). A draggable separator between the dashboard
band and the grid, so the band's height is the user's choice rather than a fixed
compromise. Driver: "if I were to open this on a large monitor and I wanted to
resize the height of the dashboard compared to the grid, being able to do that
would be really cool." Verified with real pointer events: 522px -> 672px on a
150px drag, stable before/during/after, no snap-back; keyboard-operable with
aria-valuenow tracking the live value. A real bug was caught pre-ship: 200
localStorage writes on a single keypress.

TABLE/MAP DRAG FIX (table-resize). The handle did nothing in page-scroll mode --
measured 522px before AND after a real 200px drag, every frame. The shell is
height:auto there and grows with content, so the percent-of-shell formula was
meaningless. It now measures against the map element's own top edge captured at
drag start, re-deriving each frame rather than summing a delta. Four drags both
directions with persistence: 417.6->336, 336->283, 283->548, and 548->470 after
a reload that restored 548.

SLIMMER STATS ROW (slim-headers). The KPI strip's content is an 18px line inside
a row fixed at 42px -- 24px of empty space. Tokenized as --rd-stats-h:30px,
recovering 12px with no information loss, and tokenized rather than inlined
because 42px repeated in three row templates that must stay in sync.

CLOSED FACET SECTIONS (sidebar-collapse). Driver: "once the data fills all the
sidebars it causes the dashboard to become really tall... without data it looks
fine." Measured: five open sections rendered 69 rows, the rail's own scroller
never engaged (clientHeight === scrollHeight === 2117px), band 2325px against a
900px viewport. All five now start closed -- the default changes, not the
mechanism; per-section state and toggle() already existed.

Build exit 0, 21 bundles / 30 profiles. check:sri, check:settled, sfc-parse,
template-literal, one-palette, synced-css all pass.
2026-09-23 16:27:39 +00:00
doxa-maps-88655c70
79b12baee9 Add prayer-walk-planner: experimental polygon prayer-walking map
Some checks are pending
build-gate / build (push) Waiting to run
Draw/delete/batch-select polygons on a keyless MapLibre+OpenFreeMap basemap,
print a per-zone prayer-walking plan (title + coordinates) via an in-browser
print dialog, everything persisted to localStorage only — no backend, per
the JAMstack constraint. Root SFC mounts directly as the custom element
(no ProfileLoader — needs no token/dataSource/profile-switching), so
defineExpose() bridges addPolygon/deletePolygon/selectPolygon/print/list
straight onto the DOM element for agent-driven use; window.PrayerWalkPlanner
mirrors the same API for discovery. Researched keyless satellite imagery
(Esri World Imagery, NASA GIBS/MODIS, USGS orthophotos): none qualified —
Esri restricts free use to OSM tracing only, GIBS/MODIS tops out at 250m/px
(useless for a neighborhood walk), USGS is US-only — so this ships the
keyless vector basemap already proven in library/styles/registry.js.
2026-09-23 16:22:56 +00:00
doxa-maps-88655c70
4a8b872891 Slim the research-dashboard's KPI stats row from 42px to 30px
Some checks are pending
build-gate / build (push) Waiting to run
The Driver asked for the three dashboard headers to read slimmer so the
people-groups grid peeks below the fold as a scroll hint. Measured each
header's actual content height: the KPI strip's content is 18px tall
(a single baseline row) inside a grid row hardcoded at 42px, so 24px was
dead space around it — the largest untouched margin of the three. The
topbar and research-filters bands are already at a Driver-fought floor
from prior cards, so they're left alone. Tokenized as --rd-stats-h since
the literal appeared in three grid-template-rows declarations that must
stay in sync.
2026-09-23 16:21:47 +00:00
doxa-maps-88655c70
2772486663 Replace poster-wall's Download/Prayer-List buttons with three real print actions
Some checks are pending
build-gate / build (push) Waiting to run
Driver, 2026-09-23: the UPG-100 list card should offer three PRINT options, each
opening the browser's own print dialog on a rendered preview (never a silent file
download), in this order: Print a UPG-100 list (the point of the app), Print a UPG
prayer poster, Print a UPG card deck.

- printList100() and printCardDeck() reuse printPoster()'s print-preview window
  mechanism (extracted to openPrintWindow()) rather than inventing a second one.
- Both fetch real per-people-group records via the same normalize.js +
  selector-evaluator.js pipeline the sibling map profile already uses, so the map,
  the list print and the card deck can never disagree about which PGs belong to a
  list. Found and fixed along the way: 12 of 27 lists carry a `pg_slugs` selector
  that selector-evaluator.js intentionally throws on (a documented special case in
  upg-100-list.vue) — my first pass called it unconditionally, which silently ate
  every click on the India/China lists before reaching window.open(). Caught only
  by driving a real click and polling the HUD, not by reading the code.
- Card deck is new code (no card/deck generator exists in this repo or in
  root-dx-life to port — searched first); one card per people group, print-paginated
  with `break-after:page`, styled after the poster-wall's own tape/card vocabulary.
- Removed the now-orphaned exportPoster()/downloadPacket()/download() — the raw
  Download button and the "Prayer List: coming soon" placeholder were exactly the
  two buttons the Driver objected to.

Verified live: instrumented window.open/window.print via Playwright, clicked all
three buttons for real. Each opens exactly one window and calls print() exactly
once (List: 1/1, Card Deck: 1/1; Poster: 1 window opened, print() correctly does
NOT fire when the full-res poster 404s in this default, bundlePosters:false tree —
the documented degraded state, confirmed by screenshot).
2026-09-23 16:20:04 +00:00
doxa-maps-88655c70
873a99d1a6 Pay the agent discovery tax once, in the repo
Some checks are pending
build-gate / build (push) Waiting to run
MEASURED: agents produced 21 report docs totalling 2,079 lines of prose and 27
scratch probe files against a handful of product commits. The cause is the spawn
prompts, which is to say me: each carried ~15 environment rules, several cited
lessons, a three-clause invariant, a mandatory screenshot read and a
what-I-could-not-verify section. Per report that is 7-12 headings and 10-16
verification mentions. The prompt made PROVING the job and BUILDING the side
effect.

Two costs compounded. Every agent re-derived the same environment from scratch
-- which port, is the tree settled, does stale SRI block the script, does this
box have WebGL. And several spent that discovery budget, hit a build queue they
could not control, and died on a session limit before committing; their work
survived only because it was found untracked (one 511-line gate was a `git
clean` away from being lost).

This file pays that tax once. Spawn prompts can now be short: what to build,
which files you own, and the one assertion that would falsify it.

It also states the rule that was missing: ship a commit and PUSH, then write two
sentences -- not the reverse. A long honest report attached to no commit is the
failure mode. And it sizes verification to blast radius explicitly, because
over-verifying is a real cost rather than a virtue.

check:doc-scripts passes, so every command quoted here exists in package.json.
2026-09-23 16:05:47 +00:00
doxa-maps-88655c70
6d9ace4cc7 Clone DX-life's /regions design exactly into regions-list.vue
Some checks are pending
build-gate / build (push) Waiting to run
The Driver's exact-clone complaint: our element kept DX-life's markup shape but
reinvented the styling — lighter header green, small radius, generic font, and
no footer link at all. This replaces every design value with DX-life's own,
resolved from their SCSS tokens and cross-checked against getComputedStyle()
on the live https://doxa.life/regions page.

Key finding: DX-life's <html> sets font-size:10px as its rem root, so every
rem token in their CSS resolves to 62.5% of the raw number (their 3.5rem
border-radius is 35px, not 56px). Ported: dark #3b463d header band, 35px
radius, bold/condensed/uppercase title (Bebas Neue approximated — their font
file has no CORS header so it can't be cross-origin loaded), and the missing
"See <Region> dashboard" footer link bottom-right with its permanent underline.
Also fixed a real wrap bug the port introduced: the countries-grid bullet was
flex/baseline-aligned and stranded the (count) beside line 2 on multi-line
country names; now a plain inline row with an absolutely-positioned dot.

Colours/fonts/radii here are an intentional, Driver-directed exception to this
repo's material-light chrome tokens — the clone wins for this element.

Full property-by-property table: _qa-cycle/02-builder-cycle/delegation/RESULT-regions-design-clone.md
2026-09-23 16:01:58 +00:00
doxa-maps-88655c70
b59fb66cc9 Add Regions as the third Dashboards row, completing the seven
Some checks are pending
build-gate / build (push) Waiting to run
The Driver named seven plugins across three sections. Six shipped; Regions was
the gap.

  Dashboards          Big Picture · Simple Research · Regions
  Prayer Mobilization UPG-100 List · Prayer Progress
  Gospel Research     Cities with Internet · Gospel Resources

What ships today is tab 1 of 2. regions-dashboard currently builds a single
profile, regions-list -- the WAGF region list refactored from DX-life's /regions
page, which emits region-selected / country-selected. The scrollable
region/country detail dashboard those events will drive lands as an ADDITIONAL
profile in the same bundle, and this row needs no edit when it does, because the
row names the BUNDLE and the shell resolves the profile from the manifest.

Listed now rather than held back: a working list that reaches the rail is worth
more than a complete feature nobody can open.

Verified in a browser on the built tree at 1440x900, recursive shadow-DOM walk:
four rail sections render as Dashboards / Prayer Mobilization / Gospel Research
/ Experimental; the seven curated rows appear in the Driver's order with Regions
third; 14 tabs total, 14 unique, zero duplicates, zero console errors.
check:sri PASS, check:settled PASS before and after.
2026-09-23 15:43:43 +00:00
doxa-maps-88655c70
3b08fb5962 Stop Big Picture showing the same total twice
The composite's own header renders "<N> shown" from @groups-filtered, and the
nested grid rendered its own "Total: <N>" line 139px below it. Measured live:
"2107 shown" and "Total: 2,107" — the same number twice, both above the fold.

That is the redundant-header shape the standing rule forbids (the hint lives in
the EXISTING header, never a second one). show-total=false here matches the
pattern already applied in dashboard-plus-grid.vue:279 for the same reason.

The grid keeps its own total when embedded ALONE, where it is the only count on
screen — the prop is host-controlled, not removed.

Work started by a delegated agent that hit its session limit mid-task; verified
the cited sibling pattern exists before committing. check:sfc-parse and
check:template-literal pass.
2026-09-23 15:32:28 +00:00
doxa-maps-88655c70
4c74bd717e Restore three-section tab structure: Dashboards / Prayer Mobilization / Gospel Research
Big Picture now lives in its own Dashboards section alongside a renamed Simple
Research (was Research Map, recategorised as the mobile-default dashboard).
Prayer Mobilization narrows to UPG-100 List + Prayer Progress; the renamed
Gospel Research section takes Cities with Internet + Gospel Resources.

The device rule changes shape to match: every row now stays in the rail on
every width (nothing is hidden any more), Big Picture is disabled rather than
removed on mobile with a "desktop only" badge and an explanatory toast on
click, and the auto-selected tab swaps to Simple Research on mobile / Big
Picture on desktop.

Added a loud, module-load-time assertion that no tab id is declared twice
across the curated sections, on top of the existing silent runtime dedupe --
this is the gate the duplicate-key regression needed and didn't have.
2026-09-23 15:21:33 +00:00
doxa-maps-88655c70
c48d1d401a Make the legend show the colour the map actually paints
Some checks are pending
build-gate / build (push) Waiting to run
The swatch rendered the raw bin hex. The map never paints that colour: the
choropleth composites at fill-opacity 0.78 over Positron's #f2f3f0, so every
swatch showed a colour that appears nowhere on the map.

Measured, chip vs actual fill:

    #f4cec4 -> #f4d6ce   dE  4.07
    #ecaea1 -> #edbdb2   dE  7.53
    #e08678 -> #e49e92   dE 11.93
    #ce544b -> #d6776f   dE 17.13
    #b10010 -> #bf3541   dE 21.14

The error grows with the bin, and at the top it is the size of a whole bin step:
the gap between the top two bins is 25.45 as painted, while the legend's own
error on the heartbeat bin is 21.14. A reader comparing the two darkest swatches
was comparing two colours neither of which was on the map -- a strong candidate
for the report that "60-149 red is the same red as the heartbeat zone",
independent of the ramp itself.

Composited in the legend rather than dropping the map's opacity: 0.78 is
deliberate, it lets country borders and labels read through the choropleth, and
this file's own contrast notes are measured at 0.78. The legend was the half
that was lying.

Verified: all five bins produce the measured target values, and non-hex and null
inputs pass through untouched. check:one-palette, check:synced-css,
check:template-literal, check:sfc-parse all pass.
2026-09-23 15:20:30 +00:00
doxa-maps-88655c70
f48cad8dbb Widen the red ramp, add a greyscale ramp, and gate cap-4 interoperability
Some checks are pending
build-gate / build (push) Waiting to run
THE RAMP. The Driver reported that 60-149 reads as the same red as the 150+
heartbeat bin. The old ramp was mathematically even and he was still right --
that tension was the finding, not a contradiction. Measured in plain sRGB
distance the top step (60.0) looked like the LARGEST in the ramp; that
measurement was lying, because RGB distance is not perceptual. Re-measured as
deltaE the old steps were 15.5 / 15.8 / 16.7 / 17.7 -- nearly flat, while chroma
collapses at the dark end so hue and saturation stop carrying information there.

The new ramp deliberately ACCELERATES toward the dark end: 14.1 / 17.9 / 21.4 /
24.9. Top-bin separation 17.67 -> 24.94, a 41% improvement on exactly the pair
he named, and total lightness range widens from 37.2 to 49.0 L*.

A named greyscale ramp ships alongside it, grey to black, as he asked ("just in
case i need to swap from red to black scale"). Its steps measure 22.8 / 22.4 /
22.4 / 22.8 -- near-perfectly even, every step well above the ~10 threshold for
clear distinguishability. It is an OPTION, selected by name; the red ramp
remains the default, so no prior decision is silently reversed.

Bin EDGES are untouched -- check:heartbeat passes, which replays the bin
assignment and fails if a default moves.

THE INTEROP GATE. verify:interop drives the actual compiled cap-4 composites in
a real browser against live data, closing the gap verify:gate1 leaves open. On
its first run it PASSED both desktop directions: a facet-rail click narrowed the
grid to "Showing 1,309", and selecting three rows moved the camera from
28.000,18.000@1.45 to 73.666,13.162@2.48 -- read back off the live map, not
inferred. It asserts the grid's live count line rather than card count, because
card count is a proven false-negative (always 24).

It ships with verify:interop:self-test, a two-mode negative test -- grid-prop
freeze and camera freeze -- each required to fire its own combination-named
failure. A harness that has only ever reported PASS is unproven.

Gates: sfc-parse, template-literal, one-palette, synced-css, mjs-parse,
heartbeat, doc-scripts all pass.
2026-09-23 14:53:51 +00:00
doxa-maps-88655c70
1a782109c3 Give the shell pane a scroll, and drop the engagement filter
TWO CHANGES, both Driver-reported.

1. THE SHELL WOULD NOT SCROLL, so the grid under the Big Picture dashboard was
unreachable. Measured before: window.scrollY stayed 0 and .vtl-content reported
scrollTop 0 against max 4155 -- it HELD 4155px of content and would not move; 0
grid cards reachable. The same composite on its own leaf page scrolled fine
(scrollY 3712, 9 cards), so this was a HOST defect, not broken interop.

Cause: every mounted tab got position:absolute;inset:0;height:100%
unconditionally. That is right for a fixed-viewport map, which fills the pane
exactly. It traps a StackLayout composite -- dashboard-plus-grid and
research-map-plus-grid are natural-height by design, and pinning them to inset:0
forces ~2600px of real content back into one viewport with nowhere to go, while
BOTH the element and its container were absolutely positioned with no scroller.

Fix follows the bounded-middle shape already proven here: the PANE stays bounded
and gains overflow-y:auto (plus overscroll-behavior:contain so a bottomed-out
wheel does not chain to the document); .dms-slot becomes position:relative with
min-height:100% so it can grow past the pane. Removing the pane's bounds instead
was measured previously and produced a worse defect -- the grid landed far below
the fold.

Verified in the shell on the built tree: pane overflow-y auto, scrollTop 0 ->
4155 of a 4155 max, grid cards visible 0 -> 8, .rd-grid present, 0 failed
requests. Read the screenshot as well as the numbers: the people-groups grid
renders with photos, country and affinity-bloc labels, Full-profile buttons and
Load more. Fixed-viewport map tabs are unaffected -- they fill the pane exactly,
so scrollHeight equals clientHeight and overflow:auto paints no scrollbar.

2. ENGAGEMENT AND STATUS FILTERS REMOVED on instruction. engagement_status is
gone from the dropdown row (seven remain, all fitting the first row on the
measured 178px pitch) and the Status section is gone from the facet rail. The
facet accessor, the table column and the FACET_TO_PROP mapping all stay, so the
grid can still filter on it and nothing downstream breaks -- only the two
offered controls are withdrawn.

Gospel Access stays. It is measurably a different question: over 2,108 live rows
the two fields disagree on 25 -- 12 groups are Engaged at gsec rung 1 and 13 are
Unengaged at rung 2.

Gates: sfc-parse, template-literal, one-palette, synced-css, facet-cascade,
facet-one-field all pass.
2026-09-23 14:41:58 +00:00
doxa-maps-88655c70
f7cee9640e Add regions-list element: tab 1 of the Regions Dashboard plugin
Scrollable list of WAGF regions and their countries, refactored from the
DX-life /regions page into a standalone custom element. Clicking a region
or country emits a selection event instead of navigating, so a host shell
can drive a second tab from it.
2026-09-23 14:24:04 +00:00
doxa-maps-88655c70
c27d16cd4b Show the film link the list already had, for 218 languages that showed nothing
Some checks are pending
build-gate / build (push) Waiting to run
resources-list rendered links only from `resources[]`, which holds SCRIPTURE
editions (eBible entries with ot_books/nt_books/redistributable). The JESUS Film
lives in its own fields -- has_film/jesus_film plus film_url -- and was never
read. Measured over all 862 languages in resources.json:

    resources[] non-empty ............ 120
    film_url present ................. 306
    film_url but resources[] EMPTY ... 218   <- a real link the UI never offered

So 218 languages displayed no link at all while carrying a working one. Arabic,
Sudanese (apd, 66 people groups, 18.7M speakers) has
https://www.jesusfilm.org/watch/?languageId=13562 and rendered nothing.

The pill is deliberately left alone. It reads full_bible/has_scripture, so
"none found" is TRUE -- of scripture, which those 218 genuinely lack. Widening
it to mean "no resources at all" would make it say something false about a
language that has a film. The row now says both things: the pill reports the
scripture gap, the links report what exists.

Film links are marked (muted, with a leading marker) rather than styled as
another Bible, because a film is a real resource but not a text and this list is
about text availability first. Guarded on film_url itself, not on the boolean
flags: a truthy flag with no URL would render an anchor to nowhere.

Verified in a browser on the built tree: default "No scripture found" tab now
renders 218 film anchors where it previously rendered zero, all 218 on rows
whose pill reads "none found"; the sample href matches the apd row in the data.
"Has scripture" shows 120 rows with 155 scripture anchors plus 88 film; "All
languages" 862 rows, 306 film -- matching the pre-change data counts exactly.
0 failed requests.
2026-09-23 13:58:18 +00:00
doxa-maps-88655c70
8b3d638de7 Correct two measured claims in the filter-row note
Both were assumptions written as measurements, and one of them kept a P0 card
shut.

(1) "GSEC is the underlying field ... engagement_status is the label derived
from it." They are independent fields. Over 2,108 live rows they disagree: 12
groups are "Engaged" at gsec rung 1, and 13 are "Unengaged" at rung 2. Because
the note claimed the dropdown already covered "the phases of engagement", the
phases looked built when they were not.

(2) "168px on a 200px pitch, so eight occupy 1600px and MUST wrap." The pitch
was never measured. It is 178px: seven dropdowns fit the first row ending at
x=1250 and the eighth wraps, at both 1440 and 1280, with no horizontal
scrollbar. The conclusion held; the arithmetic behind it did not, which would
have misled the next author sizing this row.

Gospel Access stays out of the dropdown list because the Driver named eight, not
because a ninth would overflow -- the layout would tolerate one. A five-rung
ordinal reads better in the tree rail regardless.

Comments only; no behaviour change.
2026-09-23 13:24:06 +00:00
doxa-maps-88655c70
1449d47573 Add the Gospel Access filter, the ladder the binary flag was flattening
The Driver asked for "the phases of engagement, measured in the live data."
engagement_status cannot express them: over 2,108 live rows it is 2,081
Unengaged / 27 Engaged, so filtering on it separates 1.3% of the corpus and
tells a researcher almost nothing about where a group actually sits.

imb_gsec is the ordinal ladder underneath that flag, present on 2,106/2,108:

    0   No Evangelicals, Churches or Resources ........... 225
    1   Less than 2% Evangelical, No Active CP Activity . 1,853
    2   Less than 2% Evangelical, Concentrated CP ......... 23
    4   2% or Greater but Less than 5% Evangelical ......... 4
    6   10% or Greater Evangelical ......................... 1

Cross-tabbed against the flag, ALL 225 groups with no evangelicals, churches or
resources -- the most lost category in the data -- are merely "Unengaged",
indistinguishable from the 1,841 groups that have some evangelical presence.
That distinction is the one the map exists to show.

Verified safe to filter on before wiring, the same way cluster/people_group
were: 5 distinct {value,label} pairs and NO value-vs-label cross-collision, so
valueMatches() selects the same rows whichever vocabulary arrives. It is a real
row field, unlike the derived language_family/religion_family facets, so it is
mapped in FACET_TO_PROP from the day it lands rather than joining the
dashboard-only class.

Wired through every layer that would otherwise silently drop it: the shared
sources.json defaultFields (the dashboard reads rows through that seam), the
grid's own fetch field list, the prop, the API-field-name filter key (imb_gsec,
not gospelAccess), the re-filter watch list, and the composite's prop binding.

Verified in a browser on the built tree with live data: the section renders all
five rungs with counts matching the API exactly; clicking "No Evangelicals"
takes dashboard scope 2,108 -> 225 and the grid to "Showing 225"; clicking
"Concentrated CP" gives 23 and "Showing 23"; deselecting returns both to
baseline. 0 failed requests.

Gates: facet-one-field, facet-cascade, facet-changes-map, csv-shape,
label-joins, one-palette, sfc-parse, parse all pass. verify:gate1 still 13/13
against 2,108 live records.
2026-09-23 13:19:22 +00:00
doxa-maps-88655c70
9076ec1d61 Mark the two dashboard-only facets, so they stop looking like broken buttons
language_family and religion_family are clickable, they highlight, and they
narrow every chart above them -- and they cannot move the grid below. Measured
2026-09-23: dashboard 2107 -> 677/306/222/151 and 2107 -> 693/649/610/83, grid
unchanged every time. To a user that is indistinguishable from a dead control;
the only way to learn it was to click and squint.

Wiring them is not the fix and would be worse. Both values are DERIVED -- a
language family resolved via langFamilyByIso for ~96% of rows, a religion family
computed from the first letter of a religion code -- so they exist on no row
field. Mapping religion_family onto `religion` would compare "Hinduism" against
a CODE and filter the grid to zero.

So the honest fix is a label, not a wire. A facet now declares
`dashboardOnly: true` in dashboard.config.js; DashboardFacetRail reads that from
config rather than hardcoding facet names, and renders a muted half-circle plus
a title and an extended aria-label saying the row updates the charts only.

These rows stay fully clickable, deliberately unlike `zero` and `other` rows,
which are inert because they would do nothing at all. Dimming them the same way
would understate what they really do.

Verified in a browser on the built tree: 41 of 66 rows marked, 41 glyphs
rendered, marked facets are exactly language_family and religion_family and
nothing else, 40 still clickable (the one that is not is a zero row that
correctly keeps its mark). Behaviour unchanged -- a marked row still takes the
dashboard 2,108 -> 677, an unmarked row still filters at 548.

Gates: facet-cascade, facet-one-field, facet-changes-map, one-palette,
synced-css, composite-srcs, template-literal, sfc-parse all pass;
a11y-button-names passes at 769/769 buttons named.
2026-09-23 13:04:52 +00:00
doxa-maps-88655c70
bd2a77e82d Resolve the shared asset base per bundle, so nested tabs stop 404ing
Eleven profiles across six bundles each carried the same line:

    profileConfig?.assetsBase || './assets'

That default is right on a standalone leaf page, where the generated host passes
'../../assets' and the tree root is two segments up. It is wrong the moment the
element is nested in the cap-5 shell, because a relative path resolves against
the SHELL's page, not the child's. Measured in a browser on the built tree:

    leaf  /doxa-maps/gospel-resources/resources-map/index.html ... 0 failed
    shell /doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html ...... 404 assets/tables/gospel-by-country.json
                                                                   404 assets/boundaries/world-adm0.geojson

No build-time gate could see it: './assets' is a valid string in both hosts, and
only the host decides what it resolves to. check-composite-srcs proves a nested
src exists on disk, which is a different question.

resolveAssetsBase() in library/utils/embedBase.js now derives an absolute base
from the bundle's own script URL (or the treeBase the shell already forwards),
and deliberately REJECTS a relative assetsBase from config -- honouring it would
reintroduce the defect through the config meant to cure it.

The fix is in the child, not the shell, so a new bundle that fetches assets
works nested on day one with no edit to doxa-maps-spa.vue.

Verified: shell 404s 2 -> 0 on Gospel Resources; Cities & Internet, UPG-100 and
Big Picture also 0 asset 404s; leaf hosts unchanged at 0.
2026-09-23 12:14:52 +00:00
doxa-maps-88655c70
546916c41e Say which population the studio's count describes
The Plugin Studio header read "18 bundles · 27 elements" while the manifest
holds 19 and 28. The arithmetic was always right: rowsFromManifest skips the
studio itself, because it cannot be one of its own tabs, and 19-1 / 28-1 is
exactly what the header showed.

The problem was that nothing said so. The golden-path self-test — linked from
the golden-path doc as of this flight — reports 19 bundles and 28 profiles from
the same manifest. Two authoritative surfaces disagreeing by one, neither
explaining why, is how a reader stops trusting both; and the studio is the first
thing an admin agent opens, so a number that looks wrong there discredits
everything beside it.

"listed here" is the whole change. It names the population, so the difference
from the catalog total reads as a deliberate exclusion rather than an off-by-one.
The count stays derived from this.rows, which is the honest source — it is a
statement about what the studio shows, and now it says that.

Verified the exclusion accounts for the gap exactly before touching anything:
manifest 19/28, minus the single plugin-studio bundle and its one profile, is
18/27. Then read the rendered header at 1600px — 220px wide, sitting beside the
language selector without crowding it — and confirmed it in the regenerated
screenshot.

Nine gates pass, including check:synced-css: there are two .ps-count rules in
this file, the synced copies that must stay byte-identical, and this change is
JS rather than CSS so both are untouched.

Same shape as the self-test scoping documented earlier today: a number that is
correct for the question it answers, with nothing saying which question that is.
2026-09-23 10:49:06 +00:00
doxa-maps-88655c70
7547abb109 Keep the poster-wall header pill on one line whatever the count
The wall's header pill reads "<n> UPG · Show on map" and is a fixed 82% of the
card. A three-digit count pushed it over that width and it wrapped, so "116 UPG"
cards rendered a two-line pill beside one-line "92 UPG" cards in the same row.
Measured at 1440px on the shipped wall: the same element came out h=34 for
three-digit counts and h=22 for two-digit ones, a 12px step that reads as two
different components sitting side by side.

One property, and the correct value was already three lines away: the sibling
footer pill (.tape-l) carries white-space:nowrap and .tape-h did not. So this was
an inconsistency inside one component rather than a design question. The
.hcount rule sets font-variant-numeric:tabular-nums, which says the author
already wanted digit width not to move things — wrapping undid that intent.

Now h=22 on every card regardless of count, confirmed in the regenerated
screenshot: the three-digit pills in the India and Africa rows sit on one line
like the rest.

Found by looking at the shipped screenshot while verifying
BUG-UPG100-POSTER-PRINT-DIALOG, which the standing brief asks to check visually
and which had closed with verification_kind "state" and no recorded evidence.
The poster wall itself is sound — real posters, named people groups, PRINT
OPTIONS on every card.

Nine gates pass, including template-literal, which matters because this CSS
lives in a JS string and a backtick in a comment there has broken the build
twice before.

Not fixed, and not a product defect: the "Print poster" labels show a tofu box
on this machine. check:shot-fidelity already names these six bundles — the
markup carries the right codepoint and the shot box has no emoji font. The fix
is a font here, never removing emoji from the product.
2026-09-23 10:35:24 +00:00
doxa-maps-88655c70
94b356d2b3 Tell the user when the data source is down, not that their filter matched nothing
people-groups-grid caught a failed fetch, logged to the console and cleared its
loading flag. The template's `v-else-if="!displayedUUPGs.length"` branch then
rendered "No people groups match this filter." So when pray.doxa.life was
unreachable, the element told the reader their FILTER was wrong.

Measured by blocking that host in a browser: two console errors logged, and the
only thing on screen was the ordinary empty state. A user adjusts filters that
were never the problem; an operator glancing at the page sees normal operation.
The console held the truth and nobody opens it — least of all on a partner site
or inside the cap-4/cap-5 composites, which is exactly what this element is for.

Now distinguishes the two. A `loadFailed` ref drives its own branch: "People-group
data could not be loaded. This is a connection problem, not your filter."

Also checks response.ok, which was a second silent path. fetch() only rejects on
a NETWORK failure, so a 404, a 500 or an HTML error page all resolve — and
response.json() on them either throws into the same silent catch or yields an
object with no `posts`, which sets an empty grid and reports success. Both were
indistinguishable from "nothing matched".

Styling adds no colour: the palette is material-light only and --mut is what
this component already uses, so the failure line is separated by weight and a
hairline rather than an alarm state the design language does not have.

Verified both directions. Host blocked: the new message renders, no cards.
Host reachable: no message, the grid renders. And `bun run verify:gate1` still
passes 13/13 against 2,107 live records, including its "nonsense query -> 0
(empty-state path)" case — so genuine zero-match still works and is now a
different thing from a failed load.

Found by reading EXPLORE-dxlife-people-groups-refactor.md (2026-09-18) in the
staleness scan. Its PLAN is superseded — steps 1-5 describe editing DX-life's
own UupgsList.vue, which the standing law forbids and which this flight solved
by cloning instead. But its RISK register was still testable, and risk 3 named
this exactly: "external data dep on pray.doxa.life/api/people-groups/list". Half
of that was already mitigated — prayBaseUrl is injected three ways — and this
was the unmitigated half. Risk 6 ("FIVE dropdowns not four, confirm with the
Driver") is also settled: four are rendered.

A doc can be stale as a plan and still carry a live finding.
2026-09-23 09:31:10 +00:00
doxa-maps-88655c70
4c8f6b4a6d Share the mobile composite too, and make the url absolute so the embed works
research-map-plus-grid had the same defect fixed yesterday on its desktop
sibling: sharing the composite emitted a link to doxa-research-map, the child it
nests. The gate said it exactly — "share URL reopened <doxa-research-map>, not
<research-map-plus-grid>" — and a recipient got the map alone, losing the grid.

Checked all four cap-4 composites rather than assuming the fix generalised.
my-upg-100-list owns its Share button directly and is fine. big-picture composes
a header plus the grid, owns no map and ships no Share button at all; the gate
reports "window.mapboxgl UNDEFINED" against it, which is its map precondition,
not a share failure — that bundle is out of this gate's scope and I put it in
the probe by mistake. So one real instance, now fixed.

Tier 1 again, for the same reason: this child also hardcodes a prop
(research-map.vue:2149 `:embed-path="'doxa-research-map/research-map/index.html'"`)
and the ladder is `embedPath || cfg?.shareEmbedPath`, so a prop outranks host
config and tier 2 is unreachable. Delivered through the profile-config attribute
rather than Vue provide, because this composite mounts its children as real
custom elements via src — there is no provide chain to reach them.

The url had to be ABSOLUTE, and the first version was not. resolveShareUrl
returns a tier-1 shareUrl verbatim (embedBase.js:95, `return whole.trim()`, no
resolution), and ShareButton feeds the same string into its embed snippet:
<iframe src="{shareUrl}">. A relative '../../…' would have resolved correctly in
the share LINK, because the browser resolves it against this page, and then
404'd in the EMBED, because it resolves against the partner's page instead. That
would have passed the gate — the gate navigates the link — while breaking the
deployment method the link exists for. Tier 1 is documented as "a WHOLE url" for
precisely this reason. Now computed with resolveEmbedBase() so it stays correct
on doxa.life, on nuxtbase.app and in a dropped tree.

Verified the desktop sibling did not have the same latent flaw: it produces a
fully absolute url, confirmed in the live gate output.

smoke:share-all now carries eleven targets. Full suite 11/11 pass · 0 fail.
Eleven gates pass, including sri, composite, composite-srcs and synced-css.
2026-09-23 08:32:50 +00:00
doxa-maps-88655c70
fe8e785c96 Share the composite, not the child it nests
Sharing from dashboard-plus-grid produced a link to
research-dashboard/research-dashboard/index.html — its own nested child. A
recipient opened the inner dashboard alone and silently lost the grid half of
the composite. The gate said it precisely: "share URL reopened
<research-dashboard-map>, not <dashboard-plus-grid>".

The nested dashboard's DashboardTopBar owns the Share button, and inside the
composite that button had no idea it was a passenger, so it shared its own
address.

Two attempts, and the first one is worth keeping in the record because it built
and shipped and changed nothing. I set profileConfig.shareEmbedPath — tier 2 of
ShareButton's documented ladder, and the natural-looking channel. The ladder is
`embedPath || cfg?.shareEmbedPath`, and DashboardTopBar passes an explicit
prop (`<ShareButton :embed-path="SHARE_EMBED_PATH" />`), so a child that has
already made up its mind always wins. The value was baked into the bundle,
resolveShareUrl reads that key, and the rebuilt bundle failed the gate
identically — a fix that is present in the artifact and unreachable at runtime.
Tier 1, `shareUrl`, is the only rung above a prop, and its docs say exactly what
it is for: "a WHOLE url the HOST supplies wins".

Tier 1 takes a whole url, so it is computed rather than hardcoded, via the same
resolveEmbedBase() helper ShareButton itself uses: profileConfig.embedBaseUrl →
window.MAP_EMBED_BASE_URL → the root the built bundle derives from its own
script url. The link therefore stays correct on doxa.life, on nuxtbase.app and
in a dropped tree with no configuration. An absolute url would have pinned every
share to whichever host happened to build it.

Also adds the two uncovered bundles to smoke:share-all. Nine bundles ship a
Share affordance and the hardcoded --url list covered seven; research-dashboard
and dashboard-plus-grid were absent, which is why this survived. The list is now
ten targets over nine bundles. Note the structural difference from
smoke:embeds, which enumerates from manifest.json and therefore cannot omit a
bundle that ships later — a hardcoded list silently omits whatever arrives after
it was written, and that is what happened here.

This is the two-host defect class exactly: research-dashboard standalone PASSES
this gate, and the identical component inside the composite FAILED. No
single-host gate can tell those apart.

Verified: the composite now reopens <dashboard-plus-grid> with a canvas, and the
full suite is 10/10 pass · 0 fail, so nothing regressed. Eleven gates pass,
including sri, composite, composite-srcs, synced-css and one-palette. The share
gate aborts rather than reporting a false failure when handed Firefox, which has
no WebGL here — it told me to re-run with chromium, and it was right.
2026-09-23 08:16:18 +00:00
doxa-maps-88655c70
34ef223619 Name the UPG-100 legend's tabs, instead of shipping the placeholder
The legend read "GENERATION 1 / 2 / 3" above a list of world regions. A UUPG
list has no notion of a generation, so the label described nothing and invited
the reading that the tabs filter the map by some cohort. They do not.

The cause is a fallback, not a typo. SemanticTreeLegend derives its tab strip
from the `tabs` prop and, where an entry is missing, substitutes
`{ id: 'gen'+(i+1), label: 'Generation '+(i+1) }` so the strip has something to
render. This bundle passed no `tabs` at all, so all three were placeholders.
country-posters and doxa-research-map both pass real tab sets; doxa-simple-map
passes an explicit null. This was the only bundle relying on the fallback.

What a tab actually is: a DEPTH LEVEL of the tree, which is why selecting a node
jumps the strip to that node's depth. The three levels were read out of
data/legend-tree.js rather than guessed — regions are assembled by grouping
leaves under regionFor(), zones are the middle nodes (Frontier Asia (uncovered),
India, Deaf (affinity bloc A017)), and the leaves are the individual groups each
dot belongs to. Hence Region / Zone / People Group, each with an `info` note in
the shape AFFINITY_TABS and REGIONS_TABS already use.

The ids are bundle-scoped, and that was measured rather than chosen on style.
tabLabel() resolves `tabs.<id>` against the shared i18n catalog FIRST and only
falls back to the English label when no entry exists, so a tab id is a global
namespace. The first version used id:'region' and the legend rendered "WAGF
Region" — library/i18n/locales/en/tabs.json already maps that key to the
country-posters taxonomy. id:'group' collides the same way and would have been
right here only by coincidence, since the catalog means the ROP-2.5 sense. The
prefixed ids own no catalog entry, so the labels in this file are what render.

Desktop only, deliberately. LegendMobile computes
`hideTabs = nodes ? !tabs : _isFlatTabMobile`, so with nodes supplied and no
tabs the mobile sheet hides the strip entirely — it never showed the
placeholder. Passing tabs there would reveal a strip that is currently hidden,
which is a design change rather than a bug fix.

Verified by driving the built bundle in Chromium and reading the three chips
back: Region / Zone / People Group, each carrying its info tooltip, where the
same probe returned "WAGF Regioni / Zonei / People Groupi" before the id fix.
Then the README shot was regenerated and read as an image — the strip renders
REGION / ZONE / PEOPLE GROUP and the column header now says REGION where it said
GENERATION 1. Element count moved 311 -> 314, the three info buttons.

Eleven gates pass, including sfc-parse, template-literal, sri, composite,
one-palette and synced-css.

Found while verifying BUG-UPG100-DOTS-HARD-TO-SEE, which is genuinely fixed: the
dots carry a solid dark stroke that frames them against both the pale zone fills
and the white basemap, confirmed by reading the image rather than the card field.
2026-09-23 06:52:18 +00:00
doxa-maps-88655c70
ad223d3b1a Read the pin colour and stroke from config instead of hardcoding them
The selection ring beside these already read cfg.map.selectedColor while the pin
fill and its stroke were literals, so half the pin styling was parameterised and
half was not, for no reason. Both now follow the same shape: a config read with
the current value as the fallback.

This matters beyond tidiness. The heartbeat threshold was made a variable so the
dashboard can point at a dataset that is not these 2107 people groups, and a
different corpus can want a different pin -- the orange was chosen against THIS
choropleth, and its legibility is a property of the pair, not of the colour.
Leaving the pin fixed would have made the dashboard reusable in its data and
fixed in its palette.

Verified: with no config the built map reports circle-color #ef6c00 and
circle-stroke-color #ffffff, byte-identical to the literals they replaced.

Not verified: that an override takes effect. Two runtime injections left the
defaults in place, which is expected rather than broken -- like SEL_COLOR, this
is read once when the element upgrades, and I could not prove my injection beat
that moment. The config channel is proven by check:heartbeat, but statically,
never through a live attribute. The comment says so rather than implying a
capability nobody has exercised.

The white stroke is noted as load-bearing: it carries the pin over the dark bins
where the orange falls to about 1.03 contrast, and it is what stopped the pins
reading as eyes. A host that sets it to the fill colour brings that back.
2026-09-23 05:19:44 +00:00
doxa-maps-88655c70
9fff7c4af2 Let the poster print-preview window actually open
The preview branch was unreachable for every user. window.open with `noopener`
returns null by specification -- severing the opener relationship is precisely
what the flag does, so there is no handle to hand back. The document.write that
builds the print sheet ran against that null, so every click fell through to the
fallback: the raw .webp in a tab, no print dialog. The card claimed it opened an
in-browser print preview with a dialog.

Measured in a default browser, no headless flags: window.open('','_blank',
'noopener') returns null, the identical call without the flag returns a usable
handle. I had attributed the null to this headless box last tick, and that was
wrong -- it is what the flag means, everywhere.

noopener stays on the fallback, which only navigates to a URL and never needs a
handle, so the protection is kept where it costs nothing. The window we write
into carries same-origin markup we generate, so the opener reference it regains
is our own.

Verified after rebuilding: the click now opens ONE window titled "Doxa UPG-100
poster — Northwest India" carrying the @page rule, where it previously opened two
and wrote into neither. That window then shows "Poster not available yet." from
the img onerror, which is correct and documented: the full-size posters are 42MB
and deliberately not in the default tree, so the preview degrades honestly
instead of hanging. Set bundlePosters or point assetsBase at a host serving
posters/ to see the image.
2026-09-23 04:31:43 +00:00