Commit graph

97 commits

Author SHA1 Message Date
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
doxa-maps-88655c70
2b5ff1328b Show the eighth filter, and wrap the row instead of scrolling it
The Driver named eight filters -- people, region, block, country, language,
religion, prayer, engagement -- and the rail rendered seven. engagement_status
was already fully built: a label and getter, the Status section in the tree, and
the Engagement column in the table. It had simply never been registered in the
dropdown list, so the one piece missing was a single entry.

Adding it exposed the constraint that goes with the count. The dropdowns are a
fixed 168px on a 200px pitch, so eight need about 1428px. At a 1440 viewport that
just fits; at 1280 the row reported scrollWidth 1414 against clientWidth 1252 --
a horizontal scrollbar, which is exactly what the Driver asked not to see.

The row now wraps rather than scrolls. Wrapping is the right lever because the
fixed 168px width is itself a Driver decision: a flexible width was what made the
dropdowns jump as a selection changed. The justify-content pair is kept, not
simplified -- its own comment records that `safe` degrades space-evenly to
flex-start rather than pushing the first item to an offset a scroller can never
reach. That hazard belonged to the scroller and costs nothing now, so it stays.

Verified on a settled tree at three widths. 1440, 1280 and 1024 all render eight
filters across two rows at identical offsets, with the row reporting no
horizontal overflow and every filter fully inside the viewport.
2026-09-23 02:49:38 +00:00
doxa-maps-88655c70
d4a8184ad7 Match the tab radius to the rest of the app, and open on the tab you were last on
The vertical tabs were a 999px stadium on a 48px row. That was a real earlier
instruction -- imitate the YouTube active row -- and it shipped, but measured
against the product it is the only thing shaped that way: the collapse button is
8px, the filter toggles are 8px, and 8px is already the most common radius in
the layout file. They now read var(--vtl-radius,8px), so a consumer has one knob
and the three surfaces cannot drift apart. The 48px Material row height is
untouched. The YouTube rationale stays in the comment as history with an explicit
note not to restore the pill from it, because a superseded rationale that still
reads as current is how a reversed decision gets quietly undone.

The .vtl-cap badge keeps its pill. The complaint was about the ratio of radius to
size -- a 16px chip at 999px is a chip, a 48px row at 999px is a stadium.

The studio opened on nothing and said "Choose an element from the list." It now
resolves a tab in priority order: the URL hash, since an explicit shareable
request should beat a remembered one; then localStorage, so a reload is not a
fresh start; then the first row, so the pane is never empty. This lives in the
studio rather than the layout because VerticalTabsLayout is controlled via
v-model:active and deliberately holds no selection state.

Storage is keyed by pathname so two shells on one origin do not fight over one
key, every access is wrapped because localStorage throws in private mode and an
exception in setup would blank the whole subtree, and the key is only written
after the row is confirmed to exist so a bad key cannot poison the next load.

Verified in the built shell on a settled tree: first load auto-selects with no
hint and stores the key; clicking big-picture moves the selection and updates
storage; a reload restores big-picture; and a deliberately poisoned key falls
back to the first tab and rewrites itself rather than rendering an empty pane.
2026-09-23 01:03:15 +00:00
doxa-maps-88655c70
3c594386b4 Give the dashboard a page-scroll layout so the grid beneath it is reachable
The Driver could not scroll down to the people-groups grid. The cause was a
layout mismatch rather than any one property: research-dashboard is a
fixed-viewport app whose table scrolls internally, and the cap-4 composite
stacked it above a grid and expected the page to scroll, pinning the band to one
screen so height:100% would resolve. At a 900px viewport that left the table a
269px window over 54834px of rows, so a wheel anywhere in the lower third landed
on the table and never reached the page.

research-dashboard now accepts layout:page-scroll through profileConfig, the
channel ProfileLoader already exposes as the profile-config attribute. A prop
would not work here: the registered custom element is ProfileLoader, which
renders the profile with no props bound, so a declared prop type-checks, builds
clean and never fires. Default is unset, so every existing embed is unchanged.

In that mode the shell height is auto, the map row carries an explicit height so
it cannot collapse, and the table keeps an internal scroll bounded at 62svh.
Both extremes were measured and both are wrong: at 34% of a screen the table is
a keyhole that captures every wheel event, and released to overflow:visible it
renders all 54834px and puts the grid at y=57777 -- reachable in principle,
useless in practice. A tall bounded panel is neither.

Verified in the cap-5 shell on a settled tree: wheeling over the map and
wheeling below it now both move the page 1500px, which is its entire scroll
range, and the grid lands on screen at top 851. The two gestures agreeing is the
real proof -- the broken state read 300 against 2311, the signature of something
consuming the wheel. The standalone leaf is unchanged: no page-scroll class,
900px shell, internal table scroll intact.
2026-09-23 00:53:29 +00:00
doxa-maps-88655c70
b40fd5b48d Take overscroll-behavior back off the table, and record what the wheel actually hits
The previous commit set overscroll-behavior:contain on .rd-grid to stop it
swallowing the wheel. That is backwards. contain prevents a scroll which bottoms
out in an element from chaining to its parent, and chaining to the parent is the
behaviour that would let the page keep moving once the table is exhausted.
Measured before and after: the symptom did not move at all, because a table with
54834px of content never bottoms out in the first place.

It is left at the default.

The diagnosis that produced it was reached by geometry, and geometry was the
wrong instrument. getBoundingClientRect placed this element at y=673-880, well
below the y=420 gesture, which appeared to exonerate it; a deep shadow walk had
earlier reported its scrollTop moving, which appeared to convict it. Both are
readings of state at some moment, not of what the browser dispatched. A
capture-phase wheel listener reading event.composedPath() settles it: at y=420
the path is rd-td, rd-row, rd-grid, rd-table-wrap. Use the event path when
geometry and behaviour disagree.

The underlying bug is still open and is a layout mismatch rather than a CSS
property: the research dashboard is a fixed-viewport grid that gives its table
34% of the band and legitimately owns the wheel inside it, hosted in a composite
that expects the page to scroll past it. That needs a design decision, not a
fourth attempt at a one-line fix.
2026-09-23 00:40:33 +00:00
doxa-maps-88655c70
285ba39c31 Stop the data table from swallowing the wheel, and grade the gesture honestly
The Driver could not scroll down to the people-groups grid. Three separate
diagnoses blamed the map, including one of mine that shipped. Measured in the
cap-5 shell: a ten-tick wheel with the cursor over the dashboard moved the outer
pane 300px while .rd-grid's own scrollTop went 0 to 2606. The wheel was being
consumed by the table -- a virtualized grid whose scrollHeight is 54834px inside
a ~207px window. That is ordinary scroll-chaining: a nested scrollport eats wheel
events until it reaches its own end, and with 54 thousand pixels of table it
never reaches the end. The Mapbox canvas sits above the cursor and was never
involved.

overscroll-behavior:contain does not stop the table scrolling; it stops it
chaining, so the remainder now reaches the pane.

The gate that should have caught this had two defects of its own. It measured the
gesture the Driver makes and then asserted on a different number, so it passed at
2611px while his gesture moved 300. When that was fixed the threshold was written
as "moved less than 200px" -- a figure picked without comparing it to the value
already measured as broken, which was 300, so it still passed. The threshold is
now derived from a measured baseline: the same gesture performed lower down the
page, where it is known to work. Two identical gestures on one surface should
move it comparably, and there is no constant left to rot.
2026-09-23 00:31:35 +00:00
doxa-maps-88655c70
92fae9937d Stop the pin comments claiming a navy pin and a 7:1 floor that no longer ship
Three comment sites still described the pre-heartbeat product in the present
tense, which is the confidently-wrong failure this file warns about elsewhere:

- the measured contrast table cited the old pale-coral hexes
- 'WHAT IT IS NOW. Fill #1a237e ... under a white ring' described a pin that
  no longer exists, and listed 'reverting to orange' as a live rejection when
  the Driver has since asked for orange
- the constraints list asserted 'navy pin >= 7:1 on every bin. PASS', which
  reads as an acceptance criterion someone would try to restore

An orange pin cannot reach 7:1 against a red ramp at any tuning, so the
criterion is replaced rather than quietly lowered: effective visibility is
max(orange disc, white stroke) per bin, worst 1.83.

Also answers the standing 'MEASURE the pale end' note: the two bands swapped
roles. The disc is strongest on the pale bins where the ring is weak, and
collapses on the dark bins where the ring takes over, so both must stay.
2026-09-22 22:49:22 +00:00
doxa-maps-88655c70
34cbb39a5c Make the heartbeat-zone threshold a variable, not a constant
The choropleth's bin edges were a 5-stop literal (5/20/60/150/Infinity) in
DashboardMapPanel.vue. Those numbers are not a property of choropleths; they
are a property of THIS dataset — ~2000 people groups in focus. Pointed at a
200-group dataset, a 150 cutoff puts every country in the bottom bin and the
map renders uniformly pale: no crash, no error, correct code, meaningless map.
A dataset assumption wearing the costume of a constant.

The top band is the HEARTBEAT ZONE — the countries holding the most people
groups. Its edge now comes from dashboard.config.js as map.heartbeatThreshold,
alongside the other per-dataset facts (facets, columns, kpis). Re-pointing the
dashboard at a new dataset is a config edit, not a source edit.

Splits the old fused BINS array into the two independent facts it always was:
BIN_COLORS (measured against the navy pin, dataset-independent) and the edges
(pure dataset assumption). Fused, moving a threshold meant restating every hex
— silently reverting the contrast work — and restyling meant restating every
edge. Set either alone now.

Setting heartbeatThreshold alone rescales the three lower edges by their
original ratios, so the ramp keeps its shape (long tail of small counts, short
head of large ones) at any dataset size. Legend labels derive from the edges,
so a moved threshold cannot leave a stale "150+" on the legend. Opt-in
'quantile' mode derives the edges from the data actually loaded; it is NOT the
default, because 150 is a deliberate number for this dataset and a derived
default would silently change today's map.

Two bugs found and fixed while measuring:
- Edges could land at 1, creating a bin no count can enter (colorFor tests
  `count < upTo`): the ramp painted 4 of its 5 colours while the legend
  advertised 5. Appeared at threshold 40 and again in quantile mode.
- profileConfig merged shallowly, so a host passing the single key it cares
  about — {map:{heartbeatThreshold:40}} — replaced the whole map object and
  dropped mapStyle:'paper'. The one override a host is most likely to write
  was the one that broke the basemap. `map` now merges one level deep; every
  other key stays shallow.

No defineProps: the registered custom element is ProfileLoader, which renders
the profile with no props bound, so a prop declared on the profile would read
as configurable and never fire. The reachable attribute path is ProfileLoader's
existing profile-config JSON, verified end-to-end.

Default rendering is unchanged, verified by measurement rather than eyeball:
bin assignment over the 114-row committed fixture (59 countries) plus a 17-point
count sweep is identical before and after, with and without config supplied.

internal/check-heartbeat-threshold.mjs executes the resolver from the live SFC
and asserts the bins MOVE when the config moves — a grep for "150" would both
false-positive (it is the legitimate default) and false-negative (a
re-hard-coding at 200 sails through). Its --self-test re-hard-codes the ramp in
memory and requires the gate to reject it.
2026-09-22 22:25:38 +00:00
doxa-maps-88655c70
2ec6c00e68 Stop the map from swallowing the wheel, and grade the gesture the user makes
syncScrollZoomToHost decided whether Mapbox should keep scrollZoom by sampling
the layout at 0, 1200 and 4000ms and then never asking again. That is a race,
not a sync: the cap-5 dashboard tab lazy-mounts on activation, so at every one
of those instants the scrollport ancestor had not reached its final height. The
map kept scrollZoom enabled and ate the wheel. Wheeling over the map moved the
grid 300px; wheeling below it moved 2611px. The cursor is over the map, so the
people-groups grid under the dashboard was unreachable.

The condition is layout-dependent, so observe layout instead of guessing when it
settles: a ResizeObserver on the map container, the scrollport that owns the page
scroll, and body, plus a resize listener. Every event that can flip the answer now
re-asks it.

verify-shell-scroll already measured this. It computed movedByMap for the gesture
over the map, then asserted only on movedTotal, which sums in a second gesture
below the map -- so it reported PASS at 2611px while the real gesture moved 300px.
It now asserts on the number it was built to measure. Worth remembering as its own
failure family: a gate that measures the right thing and grades a different
variable, which fails honestly but never on the condition it exists for.
2026-09-22 22:06:59 +00:00
doxa-maps-88655c70
9533cca050 Land the prayer facet and the scrim helper
Some checks are pending
build-gate / build (push) Waiting to run
The prayer filter the Driver asked for, first in the dropdown order as he
specified. Keyed on people_praying, a live per-row pray-tools field already
read by three shipping consumers, and it reuses getPrayerLevel plus its LABELS
from the prayer-progress colour strategy rather than re-deriving the split --
so the filter can never disagree with the colour of the pin it filters to.

It is a real facet in config.facets with a get() accessor, which is the part
that matters: useDashboardShell builds FACET_ACC from config.facets alone and
filteredRows FAILS OPEN on an unknown key, so a facet declared only in an
adapter would render a working dropdown, land a chip, and filter nothing,
silently.

Also lands library/utils/viewportScrim.js, shared by DataTable,
SemanticTreeLegend and PosterDialog for the modal-scrim fix -- one helper
rather than six copies of the same workaround.

Committed by the coordinator rather than the authoring agents, same reason as
ad6da54: verified work sitting in the working tree is how the shell-scroll fix
was lost earlier today.
2026-09-22 21:50:32 +00:00
doxa-maps-88655c70
ad6da54674 Checkpoint the swarm's in-flight work
Some checks are pending
build-gate / build (push) Waiting to run
Five lanes had 465 insertions across 18 files sitting uncommitted, some for
15 minutes. That is the state that silently lost the shell-scroll fix earlier
today: a verified fix existed only in the working tree, a second agent's edit
to the same file overwrote it, and the reverted version got committed and
described as the fix.

Nothing here is finished and no card is being closed on it. This is a
snapshot so the work survives an overwrite, taken at a moment when every file
had been quiet 15s or more, so no agent was mid-write.

Verified coherent before staging rather than committed blind: template-literal,
mjs-parse, sfc-parse, no-orphan-gates, kpi-unit, composite-reg, script-flags,
quoted-scripts and backlog-status all PASS. check:synced-css fails on
PeopleGroupDetail.vue and doxa-simple-map.vue only -- the same two files and
the same counts as before the swarm started, both untouched by any agent, both
already carded.

Two untracked files included because both are load-bearing: internal/
check-facet-one-field.mjs is wired into package.json with a self-test, and
library/utils/viewportScrim.js is imported by DataTable, SemanticTreeLegend
and PosterDialog -- a shared helper for the modal-scrim fix rather than six
copies of the same workaround.
2026-09-22 21:42:18 +00:00
doxa-maps-88655c70
f33f7afb1c The map must not make lostness look good — green ramp -> ashen clay
The choropleth ran the product ACCENT up the lostness axis. Bins went pale green ->
saturated green as the count of UNREACHED PEOPLE GROUPS rises, so the deepest, richest
green marked the places with the MOST LOST PEOPLE. And #92b195 is not an arbitrary hue:
it is --brand in 48 places, the selected-tab underline, the active-row marker — the
colour this product uses to mean GOOD/SELECTED.

DRIVER: "it does not make sense for the tiles to be green because green means good while
it is not good to be lost, and the darkest spots are where the most lost people are at...
it should not make it look green and nice that these nations are so lost."

The vision is explicit that FAR means "the farthest, 99.9%-lost places... eyes to see
where the Kingdom isn't, and be SENT there". The dark end of this ramp is not a high
value — it is where people are who have never heard. A palette tuned for comfort
anaesthetises the exact response the map exists to provoke. Beautiful is still the goal:
the artwork of a LAMENT, not of a garden.

=== TWO MEASUREMENT FINDINGS, both of which changed the answer ===

1. THE HANDED-OVER CANDIDATES FAIL. EMBER (said 7.04) and CLAY (said 7.03) were
   composited over PURE WHITE. This panel sets style `paper` -> OpenFreeMap Positron,
   whose background layer paints rgb(242,243,240) = #f2f3f0. On the ground that really
   paints: EMBER 6.86, CLAY 6.84 — both BELOW the 7:1 floor; old green 7.09, only just
   over. Confirmed rather than guessed: compositing over #ffffff reproduces 7.04 / 7.03
   / 7.29 exactly.

2. THE LEVER WAS NEVER DARKNESS. The brief framed this as structural — "a hue that reads
   as grave must go DARK, and darkness is what kills contrast". But WCAG contrast follows
   relative luminance, which LIGHTNESS dominates; CHROMA is nearly free. Solved across
   hue 30-50 deg at chroma 0.06-0.12, every restrained candidate lands at 7.15-7.20 — the
   muted ones measure the SAME as the saturated ones. "Lament, not fire alarm" cost
   nothing in contrast. Nobody had separated the two variables.

=== WHAT SHIPS: ASHEN CLAY ===
OKLCh hue ~36-41 deg (the deficit family), chroma held deliberately low (0.013 -> 0.060)
so it reads as ash and dried earth, not terracotta or alarm-orange. The 6-step scale runs
from the product SURFACE #f3f3f1 (= NO_DATA, so the ground is unchanged and lightness
still carries the ORDER) down to the darkest endpoint the pin floor allows on the real
basemap; the 5 DATA bins are the steps below the surface. Interpolated in OKLab so the
lightness intervals are perceptually even.

  NO_DATA #f3f3f1 11.91
  #eee3df 10.82 · #e8d4cd 9.82 · #e2c5bc 8.90 · #dcb5aa 7.99 · #d6a699 7.20

THE THREE CONSTRAINTS, each verified not asserted:
  1. navy pin #1a237e >= 7:1 on every bin at the real fill-opacity 0.78 — worst 7.20.
     PASS, and an IMPROVEMENT on the green it replaces (7.09) and on both candidates.
  2. no red-vs-green dependence. Simulated deuteranopia and protanopia: bin luminances
     stay strictly decreasing, so ORDER survives — read by lightness, never by
     discriminating a hue from green.
  3. accent #92b195 absent, and not even NEAR: it sits at OKLCh hue 148 deg and the
     nearest chromatic bin is 106 deg away. Checked as a hue DISTANCE, not an exact-hex
     match — a near-miss reads as a rendering artefact, which is worse than an obvious
     clash because nobody files it.

Also measured, and never checked before: buildPaint() repaints at 0.92 for in-filter
countries and 0.12 for dimmed ones. Worst pin at 0.92 is 6.53 (old green 6.40 on the same
path) and at 0.12 is 11.06. The 0.92 path is the true worst case for EVERY ramp including
the old one and sits below 7 — a PRE-EXISTING condition this change improves rather than
introduces, recorded so it is measured rather than rediscovered and blamed on the hue.

Second-order numbers re-measured rather than left stale: border #6d766e now 4.23 / 3.84 /
3.49 / 3.16 / 2.84 / 2.56; white ring vs ground 1.11-1.84 (its real job, dot-from-dot
separation vs the navy pin, is unchanged at 13.24); legend swatches 1.26-2.15 on white.

BUG-PINS-READ-AS-EYES-CREEPY verified still held: exactly one circle layer on rd-bubbles,
two colour bands (navy fill + white stroke), no third band, rd-bubble-halo absent. Not
touched, and no band reintroduced.

Repro: _qa-cycle/01-ideation-research/scratch-lament-ramp-contrast.mjs (all ramps, three
targets, both CVD sims), scratch-lament-ramp-solve.mjs (the solve), and
scratch-lament-ramp-swatch.mjs (renders the swatches so the choice can be looked at).
2026-09-22 21:26:24 +00:00
doxa-maps-88655c70
5d12841fe7 Make the charts rail a tab strip, with Gospel Resources disabled
Some checks are pending
build-gate / build (push) Waiting to run
The Driver asked "a long time ago" that the chart become a tab so Gospel
Resources could sit beside it, with Charts selected by default. The rail had a
plain "Charts" heading.

Builds the half that is buildable. The container needs no gospel data and no
ruling; what the second tab ENCODES does -- he described it as the same facet
breakdown re-measured on a different axis, "people groups by their gospel
resources availability", and three decisions are still open: the official
lostness number, key-less versus a registered Joshua Project key, and the UI
shape. Guessing any of them ships a wrong picture that looks right, which is
this codebase's most expensive failure mode.

So the second tab renders DISABLED rather than hidden, carrying its reason in
the title attribute. A hidden tab is indistinguishable from an unbuilt one; a
disabled tab that names what it waits on is a status report the Driver sees
every time he looks at the rail.

The strip inherits .rd-h's exact typography -- weight 700, --fs-md, uppercase,
.5px tracking -- so the rail's visual weight is unchanged and this reads as the
same header gaining an option, not as new chrome. The selected tab uses the
accent underline the facet rail already uses for an active row, so one visual
language covers both rails. .rd-h is kept but was verified unused in this file
and everywhere else under research-dashboard/.

Verified the backtick rule that has broken this build three times: zero
backticks inside the useShadowStyles literal (chars 5459-18249); the one in a
JSDoc comment sits outside it. check:template-literal and check:sfc-parse pass.
2026-09-22 20:19:32 +00:00
doxa-maps-88655c70
c959ed6323 Load the RTL text plugin from one place, and in the composite too
Some checks are pending
build-gate / build (push) Waiting to run
The cap-4 composite bakes research-dashboard's .vue directly, so it never runs
that bundle's entry file and has to repeat everything the entry does. It
repeated Pinia, i18n and the colour strategies. It did not repeat this one.

Measured in the shipped bundles:
  research-dashboard.js ..... 1 occurrence of mapbox-gl-rtl-text
  doxa-research-map.js ...... 1
  dashboard-plus-grid.js .... 0

So Arabic, Hebrew and Persian labels rendered reversed inside the cap-5 shell
and correctly standalone. Not cosmetic here: Arab World has 99 rows and
Persian-Median 49, and a reversed label is a wrong NAME for exactly the groups
this map is about. It failed silently -- no error, no warning, just mirrored
text that reads as a font problem to anyone who does not read the script.

Fixed as a shared library function rather than a fourth hand-copy, which is
what the composite-registration card asks for: one definition, two callers,
nothing to drift. Idempotent by construction, since Mapbox throws on a second
setRTLTextPlugin in a session and that throw is swallowed.

Four of the five entry side effects fail silently; only provide('profileModules')
throws, and that is the one safe to omit because a baked composite does not use
ProfileLoader. So "it renders" is no evidence the entry's work was done.

Found by the map-beauty agent, which I verified independently before acting
rather than taking the bundle counts on report.
2026-09-22 19:55:58 +00:00
doxa-maps-88655c70
00bf294d41 Compact the pane-scroll comments, keeping both declarations
Some checks are pending
build-gate / build (push) Waiting to run
An agent collapsed the multi-line notes on .ps-pane and .vtl-host into
one-liners. Both declarations survive in both synced copies -- overflow-y:auto,
overscroll-behavior:contain, and --profile-min-h:0 -- and check:shell-scroll
still passes on the built tree: 2611px scrolled, grid on screen, no map-tab
scrollbar.

Committing straight away rather than leaving it in the working tree. These are
the exact two files where the scroll fix was silently lost last tick: it
existed only uncommitted, a gate passed against those bytes, and a second
agent's edit overwrote them before anything reached git.

Worth noting a false alarm in my own check: grepping for a formatting-
sensitive string reported one copy where there are two, because the compaction
left them with different whitespace. The semantics are identical, which is why
check:synced-css compares property maps rather than text.
2026-09-22 19:18:11 +00:00
doxa-maps-88655c70
35de2f443a Neutralise the dvh floor on hosted panes so a map tab keeps no scrollbar
Some checks are pending
build-gate / build (push) Waiting to run
Restoring the pane scrollport exposed the other half of the same fix, also
lost: check:shell-scroll passed the grid assertion and then failed with "a MAP
tab grew a scrollbar (131px overflow: 900/769)".

131 is exactly 900 minus 769. A hosted map ships
.profile-loader { height:100%; min-height:100dvh } and the two resolve against
different bases -- height against the pane, which sits inside a .ps-stage inset
by the studio chrome, and the dvh floor against the viewport. The floor wins,
the element is taller than its container, and overflow-y:auto faithfully draws
a scrollbar on a tab that should never have one. Making a pane scrollable is
only correct if it stays a no-op for fixed-viewport elements.

The floor is deliberate and must stay for the standalone leaf, where it fixes
the opposite bug: min-height:100% against a body with no definite height
collapses to 0. So it is neutralised on the hosted subtree instead.

Sizing the host is not enough, and this is the part that had gone missing.
.profile-loader lives inside the map's SHADOW ROOT, where a host-page selector
cannot reach it. Inherited properties can, so ProfileLoader reads
min-height:var(--profile-min-h, 100dvh) -- already in the file at line 282 --
and the pane now actually sets it to 0. Three comments described this
declaration; none of them was the declaration.

Not overflow:clip, which bounds the map correctly and also bounds the
scroll-stack composite whose content is 3380px against a pane-height host, so
the grid goes unreachable and the gate returns to 0px scrolled. One blanket
rule cannot serve both.

Both synced copies. Committed before re-verifying.
2026-09-22 19:09:38 +00:00
doxa-maps-88655c70
043b40ddbd Give the shell pane its scrollport back
check:shell-scroll went back to "wheel moved 0px, grid UNREACHABLE" with
2586px of grid mounted. The cause is not a later edit undoing a committed fix:
git history shows .vtl-host never had overflow-y:auto in any commit. Seven
commits, all `position:absolute; inset:0`.

The fix only ever existed in a working tree. An agent edited the file live,
the gate passed against those uncommitted bytes, and a second agent editing
the same file overwrote them before anything was committed. Every observation
was true and none of it was durable. Worse, 762953a was committed while the
tree was in the reverted state and described as landing the scroll fix.

Restored on .vtl-host and .ps-pane, both synced copies of each, and the reason
is now in the file rather than in a session transcript: the composite reports
scrollHeight 2838 against clientHeight 769 here, 2069px of mounted grid with
nowhere to go, while the document measures 900 === 900 so a wheel anywhere has
nothing to move. It is a host gap, not a plugin bug -- the same composite is
perfect standalone because its generated leaf gives the document the scroll.

The comment says the line has been lost once and points at the gate, because
`inset:0` looks like the simpler form and someone will reach for it again.

Committing before re-verifying, deliberately. A gate result is only as durable
as the bytes it ran against, and this bug is the proof.
2026-09-22 19:04:21 +00:00
doxa-maps-88655c70
762953a054 Land the swarm's dashboard work: colour, rails, cascade, scroll
Some checks are pending
build-gate / build (push) Waiting to run
Sixteen source files from five agents, all verified against source gates
before commit: template-literal, sfc-parse, kpi-unit, composite-reg,
facet-cascade, one-palette, script-flags, no-public-binds, no-orphan-gates and
quoted-scripts all pass. public-hygiene reports 0 findings.

Colour. The choropleth ramp is now derived by interpolating the product's own
tokens (surface #f3f3f1 to accent #92b195) rather than adding a sixth grey, and
that fixes a pincer the earlier research had not named: on the old grey-to-black
ramp the navy dot and the white ring failed at OPPOSITE ends, so no fixed pin
colour could win. Worst-case dot contrast moves 1.13 to 7.29. Second-order
breaks fixed with it -- the country outline would have measured 1.08-1.82 as
white on the pale fill, so it is #6d766e at 2.59-4.34, and the legend swatch
border.

Religion colours no longer key by array index. colorFor takes no index at all;
colour comes from the registry by facet plus value. All 31 distinct live
religion labels now match the map, 0 mismatches. Tree-rail grey rows 142 to 0
against the live 2,107-row API.

Cascade. Clearing a parent facet now clears its descendants, in the selection
bus rather than in any component, because every clear path already funnels
through setFilter/clearFilters -- a component fix would have fixed one button
and left four. The hierarchy is derived from config.facetRail[].drill, so it
cannot drift from the tiers the rail renders. Place turned out to be three deep
(region, block, country), not two.

Rails. Tree rows wrapping 18/66 to 3/66; the tree rail is now the wider of the
two at 296 vs 264, with the map cell unchanged because both pairs sum to 560.

Also lands the shell scroll fix and the facet-cascade gate with its committed
fixture -- 73 sampled rows, no network, so the assertion that reproduces the
empty map runs on a fresh clone instead of silently skipping.
2026-09-22 18:57:39 +00:00
doxa-maps-88655c70
2f5cf655f4 Draw the pin as one circle with a white stroke, not three colour bands
Some checks are pending
build-gate / build (push) Waiting to run
The pins read as eyes. The cause is structural: two stacked circle layers
produced, outward from the centre at z1, navy disc r=1.6, white field r=3.4,
navy stroke 0.6px. Dark centre, light middle, dark rim -- pupil, sclera, iris
outline. A comment in the file already called it "a navy/white/navy sandwich"
without noticing what a sandwich of those bands draws.

Concentric rings sharing a centre are the pattern the visual system fuses into
one object, which is why a bullseye is named after an eye. The inner disc sat
at 47% of the halo radius at z1, the proportion that reads as a pupil rather
than a dot with a border, and worst when zoomed out -- where a world map lives.

The outer navy band is the specific culprit: it closes the iris. Removing it
leaves the same colours reading as an ordinary pin, so this deletes a band
rather than changing a colour. Both Driver rulings survive: blue dots, white
rings, one fixed colour at full opacity.

One layer with a stroke is also the documented Mapbox idiom --
circle-stroke-width draws outside circle-radius, so a single layer already
gives disc-plus-ring, and the old code hand-rolled that and collected a third
band for the trouble. Stroke now scales with zoom, the documented way to stop a
ring swallowing the fill at low zoom. Cartographic practice agrees: Axis Maps
and the ICA Commission both put casings on symbols that OVERLAP rather than a
global opaque halo, and Esri names halos the biggest cost in marker drawing
performance -- paid here on a second full circle layer per point.

Research and sources in
_qa-cycle/01-ideation-research/RESEARCH-pin-design-why-they-read-as-eyes.md

Not yet verified: no hardware WebGL here, so the appearance is unproven. The
pale end of the ramp needs measuring, since the outer band was what gave the
white ring a hard boundary on any ground, and a perception complaint needs the
Driver's eye rather than a contrast ratio.

Also removes the table header caption "Filtering or selecting above updates
this list". Its centring history is kept in a comment: it records a reversed
judgement, not a mistake -- v1 put it in a second header, it was moved to the
existing bar on request, and centred by absolute positioning because flex
spacers landed it 55px off centre. All correct, and it still had to go: it
narrates the one behaviour on this surface that needs no narration.
2026-09-22 18:46:16 +00:00
doxa-maps-88655c70
a11ec14852 Print each KPI percent sign once
Some checks are pending
build-gate / build (push) Waiting to run
The dashboard rendered "99%  % UNREACHED" and "1%  ADOPTED %". kpiText returns
k.value unchanged for kind:'pct' and that value already carries its percent
sign, while the labels in dashboard.config.js carried a second one.

Each half is correct alone. A label reading '% unreached' is a reasonable
label; a formatter returning a ready-formatted percentage is a reasonable
formatter. Only the composition is wrong, and neither file mentions the other,
which is why it was found by reading the rendered screenshot rather than by
reading either source.

check:kpi-unit asserts no kind:'pct' label contains a '%'. The value owns the
unit because it is produced by the formatter that knows the number; the label
says what is being measured.

Parses the config as text rather than importing it -- the research-dashboard
module graph reaches library/colors/_registry.js, which uses import.meta.glob
and throws under plain node. Text parsing keeps it hermetic enough for
build:unlocked. Comments are stripped first, because the config now documents
this defect right above the fixed line and a naive scan fails on the
explanation of the fix.

It also fails when it finds no pct entries at all, rather than printing a
green PASS: if the config is refactored so the parse stops matching, silence
would read as success forever.

Negative-tested: restoring either label fails by name with exit 1.
2026-09-22 18:28:53 +00:00