summarizeRegions() was duplicated in regions-list.vue and
region-dashboards.vue — two copies of the same rollup, so two chances
for the region page and the dashboard to disagree about one number.
Both now import a port of DX-life's config/countries-meta.ts, placed in
library/ because it has two named consumers.
Ported verbatim: slugify's replace chain, FULL_PRAYER_COVERAGE_COUNT,
addToStats's conditions, fitView's constants, lngExtent's largest-gap
walk, WORLD_VIEW, both sort orders. Changed only: TypeScript to JS
(interfaces to JSDoc), and their `import { COUNTRIES } from
'../app/utils/countries'` becomes library/data/countries.json — their
same 242-entry {value,label} array, in their order. toNumber and
emptyStats are exported beyond their surface, and only those two: their
pages need no cross-region total because DX-life has no "all regions"
view, and ours does for the no-selection chooser.
Two things the copies had wrong, both fixed by porting rather than
patching:
- They slugged a country from the API's LOCALIZED label. Theirs slugs
from the canonical English name, deliberately. The live site settled
it: /regions/the-united-states is 200 and /regions/united-states is
404; /regions/the-democratic-republic-of-the-congo is 200 and
/regions/dr-congo is 404. Since the dashboard writes the slug into
the URL hash, our deep links pointed at pages that do not exist
there.
- They dropped centre/zoom/bounds entirely, so our maps derived their
own frame. Theirs computes a fit view per country and per region,
including the antimeridian: Oceania's bounds come out east 187.9,
where a naive max() says 178 and spans the globe backwards.
Also fixed regions-list.vue's formatNumber, still hardcoded en-US after
the same fix landed in region-dashboards.vue, and its region sort, which
used (a.label || 'Other') where theirs compares a.label directly.
Proof: region-vs-live 73/73 and country-vs-live 28/28 UNCHANGED — the
rollup is equivalent. New _probe-countries-meta.mjs is 20/20 and
includes a negative test showing the antimeridian assertion really
discriminates. oceania-and-list, regions-swap, standalone-app,
dashboard, total and search all green. 159 lines of duplication gone.
Driver asked for "horizontal cards" alongside the vertical ones, with the print
dialog's aspect ratio chosen at the print point.
★ LANDSCAPE IS NOT TRANSPOSED DIMENSIONS. The portrait card stacks the photo
ABOVE the text, occupying the top 46%; at 3.5 x 2.5in that same band is a strip
too short to carry a face AND leave room for the rows under it. The landscape
stocks put the photo BESIDE the text instead — full-bleed down the left 42%,
name over a scrim at its foot, data and QR in the right column. So `side` is a
LAYOUT switch the CSS reads, not a cosmetic flag.
It is driven by the stock rather than by comparing width to height: a square
stock would be ambiguous, and the composition is a deliberate property of the
format, not an inference from its numbers.
Two landscape stocks added and exposed in the poster-wall picker:
pokerLandscape 3.5 x 2.5 in -> 252 x 180 pt
postcardLandscape 6 x 4 in -> 432 x 288 pt
Verified at the rendered layer by the new check:print-geometry, which measures
the MediaBox rather than reading the CSS — and read as a picture, not only
asserted on: photo left, "Aheu" over the scrim, country/people/language/religion
in the right column, QR and "PRAY FOR THEM" at the foot, zero overflow.
Port dx-life's fuzzy search into UupgsList onto the repo's existing
@map/components/search/fuzzy.js shim: their 7 SEARCH_KEYS, threshold 0
when exact else 0.4, minMatchCharLength 3, diacritic stripping, their
highlight() construction and match routing, and .search-highlight in
#92b195 from their blocks/_cards.scss:258. A typo now finds the row and
the matched run is wrapped, neither of which the old substring filter
did. _probe-search.mjs: 6/6.
Then, reading the screenshot taken to prove that search worked, three
clone-fidelity defects that every numeric check had certified green:
- The count line above the list read "N People Groups · Cameroon". It
was invented; it is not on their page. Theirs
(UupgsList.vue:577-583) is "Total: N" in .font-size-sm inside a
.repel, with the see-all link suppressed by hide-see-all-link.
_probe-total.mjs: 7/7.
- formatNumber was hardcoded en-US. Theirs (RegionDetail.vue:16) is
n.toLocaleString(`${locale}-u-nu-latn`) — locale separators, Latin
digits. We were wrong in de, fr and es.
- The country strapline formatted its count. Theirs
(CountryDetail.vue:40) is the raw {{ country.count }} — that
component never defines formatNumber. Their asymmetry with
RegionDetail; a clone matches it instead of improving on it.
Why nothing caught the invented line: every check compares properties
of elements present on BOTH sides, so an element present only on ours
is invisible to all of them. _probe-total.mjs now carries a surplus
check — a phrase assertion scoped to .ul-root, since the first draft
swept the whole element and failed on their own legitimate "People
Groups" in the stat cards and the country table header.
Regression: region-vs-live 73/73, country-vs-live 28/28, mobile 44/44
and 26/26, i18n, chooser, dashboard, standalone-app, search, switcher,
goalcards, h1-highlight, total — all green.
A 68-of-68 computed-style diff was reporting the region page identical while two
visible things were wrong.
1. THEIR TITLES HIGHLIGHT THEIR LAST WORD. Every heading on their regions
surface is <h1 class="highlight" data-highlight-last>, and their
useTextHighlight wraps the final word in .color-primary. Measured live:
/regions/africa is ENTIRELY rgb(146,177,149) because "Africa" is the last word,
and /regions/middle-east renders 'Middle <span class="color-primary">East</span>'.
Ours was entirely dark.
★ HOW A 68/68 DIFF MISSED AN ENTIRELY GREEN HEADING: the h1 spec listed
fontSize, lineHeight, fontFamily, textAlign and textTransform — and not COLOUR.
The lesson is not "add colour to the list". It is that a property absent from
the spec is NOT MEASURED, and unmeasured is not identical. Same shape as the
font miss, where both sides named Poppins and only a glyph-width oracle showed
we were not rendering it. Colour now covers the h1, its highlight span, the stat
values, the headline figures and the goal lines: 68 -> 73 properties, still 0
differ. I found this reading their source for a different card, not from a check.
Ported declaratively rather than as their DOM walk. Theirs mutates innerHTML
once onMounted and stamps a processed flag — right for a Nuxt page whose title
never changes. Ours changes on every selection, so an imperative one-shot would
either fight Vue's re-render or go stale after the first subject. Same logic,
expressed where Vue can keep it true.
2. TAB 1 HAD NO PAGE HEAD AT ALL. pages/regions/index.vue:47-58 opens with that
h1 plus a .color-brand-lighter strapline; we rendered only the region cards.
Invisible inside the composite, where the tab strip names the view, and plainly
missing on the regions-list leaf, which is a page in its own right.
★ AND THE IMAGE CAUGHT MY OWN DEFECT. A whitespace-only <span> </span> between
two inline elements COLLAPSES: "Middle East" rendered "MIDDLEEAST". Every
assertion was green — the colour was right, the span was there, the text
content was right once whitespace-normalised. Only the screenshot showed it.
The space now lives inside the text interpolation, and an innerHTML assertion
guards it that was first shown to FAIL on the collapsed form.
Verified: region 73/73 vs live, country 28/28, mobile 44/0 and 26/0 at 390+768,
i18n, chooser and dashboard suites green, no gate newly red.
Mobile was the last dimension with no measurement behind it. Diffed ours against
LIVE doxa.life at 390 and 768, the same way desktop was diffed. Result after the
fix: region page 44 IDENTICAL, 0 differ; country page 26 IDENTICAL, 0 differ; no
horizontal overflow on either side at either width.
★ THE FIND. Their base/_globals.scss html rule carries `scrollbar-gutter: stable`
and we had nothing equivalent, so EVERY mobile width was 13.5px wider than
theirs — their container 353.5px against our 367px at 390.
It was invisible at 1440, because both sides cap at 1200 there. That is exactly
why a desktop-only diff could report 68 of 68 identical while every phone width
was wrong, and it is the clearest case yet for the rule about not trusting a
number measured at one size.
And it CASCADED into a different LAYOUT, not just different numbers: at 768 our
container came out 707.2px against their 693.7px, and their .switcher flips at
700px of container — so their three goal cards STACKED and ours stayed
three-across. One missing property, a visibly different page.
Ported onto .rd-scroll and .rl-scroll, this element's own scrolling box. A
shadow root cannot set the host document's html rule and should not try; our
scroller is the box every child measures against, so reserving the gutter there
reproduces their effect exactly (the width probe now reads pane 375 -> container
353.5 at 390, matching them).
Their 576px card reflow is verified rather than assumed: at 390 our .uupg__card
resolves to "image adopted" / "header header" / "content content" / "footer
footer" and at 768 to the three-column form, both byte-identical to theirs.
ACCEPTED, with its magnitude rather than buried: four vw-scaled clamps land
2-4% low (h1 52.71 vs 53.76 at 768, the prayer bar 22.5 vs 23.39 at 390). Their
clamps scale with vw and ours with cqw, deliberately, so the element sizes
correctly as one pane of the regions-mfe composite — and vw includes the
scrollbar while cqw does not, so the gap is now a systematic ~15px. The probe
prints these as a residual instead of counting them identical; switching to vw
would buy ~1px of heading and break a shipping surface, which is the wrong
trade, but it is the Driver's to overrule.
Desktop unaffected: region 68/68, country 28/28, every width still matching
their container formula, no gate newly red.
The field-access-visas table (580 KB -- how workers enter closed
countries) was found PUBLIC in three copies and LIVE-SERVED at 200:
the key-cities bundle data dir, the research out/ dir, and a stale
staging tree inside the served catalog. Root cause: emit() enforced
PRIVATE_TABLES but the ROUTE bundle-copy loop did not -- a privacy
rule enforced at one of two writers is not a rule. The loop now skips
PRIVATE_TABLES; the redundant source copies are deleted (data-private/
keeps the one canonical file); and the hygiene gate flags the
filename and visa_ fields as HARD findings in every mode -- this data
class never rides the strict-tier softening.
All three public copies and the live URL were purged first and
verified 404 by anonymous fetch before this commit.
Localisation was the lowest dimension on the board at 12. The repo already had
the vue-i18n layer — 11 locales, one JSON per area, eager glob, fallbackLocale
en — so this adds an AREA, not a mechanism: library/i18n/locales/<lc>/
regions.json, 51 keys x 8 locales, every string COPIED from their own
common.json rather than translated by me. The composer takes the namespace from
the FILENAME, so the files are flat like legend.json (a "regions" wrapper would
have produced regions.regions.*).
★ THE REASON IT DIDN'T WORK, AND WHY IT FAILED SILENTLY. With the Spanish
catalogue provably in the bundle, every string still rendered English. The
bundle installed i18n with app.use() but never app.provide('appI18n') — so
ProfileLoader could not drive the locale (it does exactly that at
ProfileLoader.vue:168-169, from profile-config.locale, its "established
contract"), and createAppI18n resolves its locale ONCE from <html lang>, which
every generated leaf page bakes as "en". One line, copied from
doxa-simple-map/index.js:84-86. It degraded quietly because the t() shim falls
back to its key, so a missing catalogue renders as English instead of throwing —
convenient in production, and exactly what hid this.
t() is a shim, not vue-i18n's t(), because their keys ARE English sentences and
several contain dots ('Loading results...', 'Type to search...') which is
vue-i18n's key separator. tm() returns the namespace object and we index it by
the literal string, so the port reads 1:1 with their source: t('Prayer
Coverage'). Plugin-local, per the placement rule — it is four lines shaped
entirely by how these pages were written.
THE DATA FOLLOWS THE LOCALE TOO, which the chrome alone would have hidden. The
prayer API localises its labels from ?lang=, and our components read
cfg.languageCode while the locale arrives as cfg.locale. Measured with ar: the
whole UI translated and every country in the table stayed English. Now the fetch
falls through locale/lang and refetches on change (Angola -> the Arabic list).
RTL: ar sets dir on OUR OWN root via RTL_LOCALES, the house pattern
(doxa-simple-map.vue:246/1346) — never document.documentElement, which belongs
to whatever page embeds us. Verified in a screenshot, not just an attribute: the
goal cards reverse order, the table right-aligns, the progress bars fill from
the right.
A CLONE FIX came free with it. Porting their uupgListTranslations.ts — the
authoritative list of what this component's labels are called — corrected
strings I had written from the markup instead: the rop1 filter is "People Group"
and I had "Affinity Bloc"; the search placeholder is the full "Search names,
country/continent, religions..."; prayer_coverage reads "Intercessors".
Two assertions of mine were wrong again, both failing on correct behaviour: the
back-link compared for equality when it carries their leading arrow, and the RTL
check read document.documentElement.dir when an embedded element must not set it.
Not done: it/ro/zh have no regions.json — DX-life translates 8 of our 11 — and
resolve through fallbackLocale en, which library/i18n/README says is fine.
Oswald (their Cyrillic heading face) is still unvendored.
English unchanged throughout: region 68/68 vs live, country 28/28, chooser, map
and dashboard suites all green, no gate newly red.
Driver: "the print dialog that opens up needs to show all the cards not in a
paper format but a CARD format, the aspect ratio that is used in the card
generator."
The deck set @page{size:letter portrait} and gave each card height:100%, so 95
cards printed as 95 mostly-empty 8.5x11 sheets — and with NO photo, because
image_url was never in the fetch. Measured: 95 of 95 groups DO have a portrait,
so the omission cost every card the one thing that makes it a prayer card.
Now the PAGE IS THE CARD. @page takes the stock's own inches, so one card is
exactly one page: 95 cards -> 95 pages at 180x252pt (2.5x3.5in). Print on
cardstock and cut, or send the PDF to a card printer. The stock is a user
choice, per the Driver's ask to "choose the layout of the print dialog to choose
the aspect ratio": poker 2.5x3.5in (default), postcard 4x6in, A6.
⚠ @page SIZE MUST BE EXPLICIT INCHES. Chromium SILENTLY falls back to letter for
a named size it does not know: `size: A6 portrait` yields 612x792pt with no
error of any kind, while `size: 2.5in 3.5in` yields exactly 180x252pt. The gate
therefore asserts the MEASURED MediaBox — a check that grepped the stylesheet
would pass a deck that prints as paper.
Card content follows doxa-life/promotional-material-generation, the Python data
pipeline feeding the InDesign template: per language it emits Description,
Religion, Area, Country, a QR to that group's page, and the URL. Ported to
JavaScript here so the deck generates in the map app on click. Description and
Area are NOT available from this API — measured 0 of 2107 records carry either —
so the card is built from what genuinely exists: portrait, name, country,
population, language, religion. An empty block reserved for data that never
arrives is worse than a card designed around the data there is.
QR is hand-rolled (library/packet/qr.js, ~200 lines): byte mode, level M,
smallest fitting version, mask 0. A symbol is pure computation, so it costs one
small file instead of a package and runs identically under Node and in a tab —
the standing constraint is "without adding a lot of dependencies on the
internet". A QR that does not scan looks exactly like one that does, so it is
verified by DECODING: an independent reverse-path decoder re-derives the data
placement, unmasks, de-interleaves and parses the header. 5/5 round-trip,
including version selection (v2 for a short URL, v3 for real ones).
I18N: the QR encodes the LOCALISED profile URL, so a Spanish card scans through
to the Spanish page — verified, pray.doxa.life/es/aheu. Strings fall back per
key; checked with a partial Spanish block ("Ora por ellos" rendering while
Language/Religion stayed English).
★ ONE BUG THE NUMERIC GATE PASSED, worth recording. The first deck rendered
"LAO", "thm", "EAN" — raw codes on a card a person is meant to pray from —
because normalizeRecords() flattens each { value, label } dict to its VALUE and
parks the label under <field>_label. A gate counting "4 rows rendered" passed
it: the rows were there, just unreadable. The gate now asserts the values are
HUMAN-READABLE, not merely present.
Verified by check:card-deck across all three stocks: 95 cards / 95 pages, 0
broken portraits, 95 QR codes with real symbols, 0 raw codes, 0 overflow.
Driver, as a first-time user: "I select one and it gives me print options at
the top right of the legend... that's the only way that map would be usable."
The affordance was already built and switched OFF. The shared legend emits
'export' with 'poster' | 'packet' for the selected node, the profile wired
@export, and SHOW_EXPORT_BUTTON was held false with the note "until the export
packet is finished: the button currently produces a broken packet". That
precondition is now met, so the switch is on.
ONE RENDERER, TWO CALLERS. The pages move to library/packet/packet-render.js,
imported by BOTH the build-time generator and the browser export, so the printed
packet and the generated one cannot drift. The card asked for exactly this:
"reuse the existing print actions rather than growing a fourth implementation."
The extraction is behaviour-preserving — the generated packet is byte-identical
to before apart from leading whitespace (the code is now inside a factory), and
check:packet-render still passes 8/8.
★ THE PACKET IS GENERATED ON CLICK, NEVER SHIPPED. One packet is ~3.5 MB (photos
dominate) and 28 zones would add ~100 MB to a repo that ALREADY had a publish
blocked at a 74 MB asset tree. Generating client-side costs zero repository
bytes; the photos come from URLs the records already carry. This is the Driver's
instruction: "instead of fetching the poster from the public folder I want the
client side code to generate the poster real time when you click on the button
so it does not take a lot of mb storage." Bundle cost: +21 KB (494 -> 516 KB),
which includes all 12 prayer points and Mark 5:1-20 in full.
Printing goes through the SHARED openPrintFrame — a hidden same-page iframe, not
window.open(). A popup is blockable and drops the reader out of the map.
Two traps the browser path had to handle, both already known to this codebase:
- pg_slugs BYPASSES the shared selector evaluator (it intentionally throws on
that type, and 12 of the 27 lists use it). Same special-case the map and the
poster wall apply, so all three agree about who is in a list.
- evaluate() returns a Set, not an array.
VERIFIED IN A BROWSER, not by reading source (new gate check:packet-browser):
live records -> shared renderer -> page.pdf(), giving 8 sheets -> 8 pages at
612x792pt, 95 people cards, 12 wheel segments, Mark 5 in full, 2 metacamp.org
attributions, 0 page errors. I18n verified with a partial Spanish block: the
translated headings rendered in Spanish while Mark 5 and the attributions fell
back to English, per key, never blank.
Card BUILD-UPG100-MAP-LEGEND-LEADS-WITH-THE-UPG-100-LIST part (d). Parts (a) and
(b) shipped earlier. Part (c) — removing the People Group tab and retabbing the
legend — stays OPEN: that card is explicitly driver_gated on "what is the
legend's tree once Region/Zone/People Group are removed", and the current tabs
are wired to real hierarchy data.
The Driver's brief, with explicit creative freedom: "some type of map that would
show the regions and countries and let you select one that you want to select,
which will then show you the page results". It was two dropdowns and a table —
a form, not a map.
Now the map IS the chooser. Pins are coloured by WAGF region so the seven read
AS regions the moment it loads, with a seven-pill key rendered in REGION_ORDER
beside it. The key is a control as well as a key: hovering one lifts that
region's pins and recedes the rest, clicking one opens that region's dashboard,
and clicking any individual pin opens ITS COUNTRY's page. The two dropdowns stay
as the keyboard and screen-reader path, and the key items are real <button>s
with aria-labels so check:a11y-button-names has a name to find.
Colours come from library/colors/wagf-region.js — never a table in the
component, because check:one-palette exists precisely to stop that. Rendered in
REGION_ORDER because that palette's CVD separation was validated on ADJACENT
pairs in that order.
HIT TARGET IS PINS, NOT COUNTRY POLYGONS. Polygons would be the obvious choice
and would be dead here: the Mapbox country-boundaries tileset 403s on this box
(measured, 1 request, 1x403, URL-restricted token), so polygon hit-testing would
be both broken and untestable. Pins are our own GeoJSON and always render.
★ TWO THINGS THE SCREENSHOT CAUGHT THAT NO ASSERTION DID — both on the one
screen whose entire job is to invite a choice:
· a world-wide fitBounds pulled the globe into a skewed crescent of pins over
mostly empty white. Twelve assertions were green through that. Replaced with
a fixed world view; bounds are now refused outright when the subject is
everything.
· Mapbox v3 defaults to the GLOBE projection below about zoom 6, so the world
view rendered as a small sphere floating in a 1200x780 frame. setProjection
('mercator') for the CHOOSER ONLY — the cloned pages are deliberately left
alone, because DX-life sets no projection either and their region page shows
the same globe at the same zoom. Matching them is the point.
And one the mobile screenshot caught: a fixed zoom that fills 1200px shows
barely a third of the world at 390px, with Asia off the right edge. The zoom now
follows the CONTAINER WIDTH — log2(width/512), the width at which Mercator's
world fits — clamped to [0,2], re-derived on resize via ResizeObserver. A phone
still crops (Mapbox will not go below zoom 0) but ~24% rather than ~67%, and it
pans.
Verified: 12 chooser assertions, including that the CLONED pages are untouched —
their prayer colouring and their four-row prayer key both return on a selected
region. Region page still 68/68 vs live, country 28/28, app 10/10, no gate
newly red.
MEASURED first, with _qa-cycle/02-builder-cycle/_probe-standalone-app.mjs: 4 of
10 app properties OK. Five of the six failures were ONE missing thing — the URL
never reflected the selection. So nothing was linkable, a reload lost the
subject, Back unmounted the element, a deep link did nothing, and Share shared
the index. DX-life gets all of it free from Nuxt routing; /regions/oceania IS
the state. A custom element has to do it deliberately.
THE HASH, NOT THE PATH: a leaf page is a static file at a fixed URL with no
server to route /regions/oceania, and the hash is the only part an EMBEDDED
element may touch without fighting its host page. Slugs are DX-life's own, so a
link reads the way theirs does. replaceState on first paint, pushState on a user
choice — that is what makes Back mean "the previous subject" rather than "leave
the page". A slug resolves region-first then country, the same order their
[slug].vue matches in; "neither" lands on the chooser rather than a 404, because
for this element the chooser is a legitimate state.
The hash is applied AFTER the fetch, not on mount: it cannot be resolved to a
subject until the regions are known, and resolving early sent every deep link
silently to the chooser.
One watcher on the selection rather than a writeUrl() call at each of the five
places a selection is made (tab-1 event, table row, two dropdowns, back-link) —
those would drift. popstate sets a guard so it cannot push an entry for a state
it just applied, which is the classic loop where Back appears to do nothing.
Also: document.title follows the subject, as theirs does via useSeoMeta, and the
scroll body is a <main> landmark — the probe measured none on the page.
★ THREE OF MY OWN ASSERTIONS WERE WRONG BEFORE THE CODE WAS, each failing on
correct behaviour, and each time my first instinct was that the app was broken:
· h1/share/landmark counts were read AFTER a goBack had unmounted the element,
so "0 h1s" was a reading of a blank page. A reading taken in an unknown
state is not a finding.
· the Back test clicked the SAME region twice — selecting the subject you are
already on correctly creates no history entry.
· the next version clicked two table rows, but picking a COUNTRY renders
CountryDetail, which has no region table, so the second click hit nothing
and both picks read identical.
It now sequences only through controls that exist in each state: pick a region,
clear with the in-page back-link, Back returns the region.
No regression: region page still 68/68 vs live, country 28/28, both browser
suites green, no gate newly red.
Poppins is DX-life's --font-family-base. Ours was NOT rendering it, and a
computed-style diff could not see that: both sides merely NAME "Poppins" first
in font-family, so a first-family comparison matched and the region page
reported 68 of 68 properties identical while every line of body text was a
system fallback.
The glyph-width oracle is what caught it — the same string at the same size
measured 334.58px here against 317px on their page, 5.5% wider, i.e. a
different face. After vendoring: 317 vs 317, exact. Bebas stays 279 vs 279.
document.fonts.check() is not usable for this; it returned true for Oswald and
Brandon Grotesque, which were never vendored, and false for Bebas on DX-life's
own page.
COPIED, NOT RECREATED, and deliberately narrow: weights 400/500/600/700 only —
the four the regions surface actually asks for, measured across their _globals,
_cards, _buttons and _filters (medium x10, regular x4, bold x2, plus the 600 in
the prayer-coverage legend). Subsets latin + latin-ext only. Google's own
unicode-ranges (css2 v24) reproduced verbatim so the subset split behaves as it
does on their site. 44 KB; assets/fonts is now 0.1 MB of a 129.8 MB tree.
useDocumentFont gained per-face weights, because a real family is several files
where Bebas was one. A face without its own weight falls back to the call's
single weight, so the existing Bebas caller is unchanged.
PLACEMENT, per the zone rule: the composable stays in library/ — it has two
consumers already (both regions profiles) and nothing about it is specific to
these pages. The fonts sit in public/assets/fonts beside Bebas, self-hosted,
never a CDN.
BLOCKED ON A DRIVER DECISION: Brandon Grotesque (--font-family-buttons, every
.button) is COMMERCIAL — Google Fonts returns HTTP 400 for it, so it cannot be
vendored from there. Measured 134.34px against their 128px on FULL PROFILE.
Either license it, substitute a close free face and document the deviation, or
supply the file if Doxa already holds a licence. Recorded on
PORT-REGIONS-TYPEFACES-AND-SVG-ASSETS.
Region page still 68/68 vs live, country page 28/28, every gate green.
MEASURED before: the region dashboard matched live doxa.life on 61 of 68
computed properties, and every one of the 7 differences traced to a single
cause — Bebas Neue could not be loaded. Three were the font-family itself, and
four were workarounds it forced: an uppercase hack imitating Bebas's caps, and
two font-sizes stepped down because a wider fallback face overflowed their card
widths. So it was never seven problems, it was one with six knock-ons.
DX-life does not ship the file either — @nuxt/fonts downloads and self-hosts it
for them, which is why their computed family reads "Bebas Neue", "Bebas Neue
Fallback: ...". We could not @font-face from their origin: their file is served
without CORS headers, so a cross-origin face fails silently. The font is a
Google Font under the OFL, so it is vendored here from Google Fonts — both
subsets, their own unicode-ranges (css2 v16) — self-hosted, never a CDN, the
same rule public/assets/vendor/tabulator follows.
library/composables/useDocumentFont.js is shared because @font-face is
DOCUMENT-scoped: a face declared inside a shadow root is ignored, so a custom
element cannot register its own webfont through useShadowStyles. That asymmetry
is the reason this is a separate composable rather than a flag on that one.
Idempotent per document, so a composite registers one face for both panes; both
profiles call it because each has a standalone leaf page where the other is
absent.
With the real face loaded, the workarounds are DELETED rather than kept: the
goal-card heading is back to their 59.6046px, the headline figure back to
59.6046px, and text-transform:uppercase and font-stretch:condensed are gone —
Bebas is already caps and already condensed, and stacking those changed the
glyphs. The three-across switcher still holds at 372/372/372 with no overflow,
which is the geometry that forced the step-downs in the first place.
★ THE CATCH THAT MATTERED. With the font vendored the diff said 68 of 68
IDENTICAL, 0 differ — and it was WRONG. document.fonts reported our face in
"error" status: generate-tree stages an ALLOWLIST of asset dirs into the tree,
fonts/ was not in it, so the leaf resolved assets/fonts/... to a 404 and every
heading silently rendered on the fallback. Both sides were agreeing on a
FALLBACK, which is not a match. A font that 404s is invisible to any check that
reads font-family instead of asking the engine whether the family is usable, so
the probe now calls document.fonts.check() and the staging list carries fonts/.
After the fix: usable true, face "loaded", and the headings render in Bebas.
Two honest notes. The fallback TAIL still differs — theirs carries @nuxt/fonts'
generated metric-fallback names we do not produce — so the probe compares the
FIRST family (the face actually used) and reports the tail as an accepted
difference rather than counting it either way. And document.fonts.check()
returns false on THEIR page, which contradicts it visibly rendering in Bebas,
so that check is trusted in one direction only: it proves ours loaded, it does
not prove parity.
The licence ships beside the font, extensionless: check:public-hygiene refuses
*.txt in a served folder and is right to, while the OFL requires the licence to
accompany redistributed font files. The conventional name satisfies both.
The switch existed and did not reach. resolveMapProvider has long read
profile-config.provider -> ?provider= -> window.MAP_PROVIDER, but
useMapInstance defaulted `provider` to the LITERAL 'mapbox', so the page
switch only reached a map whose bundle happened to pass provider through.
MEASURED: 3 of the 11 bundles that mount a map. The other 8 pinned Mapbox
whatever the page asked — it LOOKED global and was not.
The factory now resolves it when a caller leaves it unset, which moves the
decision to the one place every map already goes through. An explicit
caller still wins (prayer-walk-planner pins maplibre deliberately), and
with no switch set resolveMapProvider returns 'mapbox', so the default is
unchanged.
DashboardMapPanel collapsed anything that was not the literal 'maplibre' to
'mapbox', which outranked the page switch. But dashboard.config.js pins
Mapbox DELIBERATELY — the Driver's ruling for Mapbox Standard's native
starfield globe — so that default had to survive. Both hold by putting the
page switch BETWEEN the per-element config and the bundle default:
1. profile-config.provider this embed
2. ?provider= / MAP_PROVIDER the page — "swap every map on this site"
3. dashboard.config.js this bundle's shipped default (Mapbox)
MEASURED across seven map leaves, default vs ?provider=maplibre, counting
requests to api.mapbox.com:
doxa-simple-map 21 -> 4 engine swaps
country-posters 24 -> 4 engine swaps
research-dashboard 25 -> 4 engine swaps (was pinned)
doxa-research-map 16 -> 4 engine swaps
research-map-plus-grid 16 -> 0 fully keyless
my-upg-100-list 19 -> 19 NOT swapped — see backlog
regions-dashboard 4 -> 4 no map on that leaf
Simple Research reaches ZERO Mapbox requests, which is the case that
matters most: it is the one carrying the search bar, and the geocoder is
the quota risk (GeocoderComponent's maplibre path still geocodes through
Mapbox, so a keyless map with a search bar is not keyless).
Two gaps left, both filed rather than hidden: my-upg-100-list bypasses
useMapInstance entirely and cannot see the switch, and generated leaves
still preload mapbox-gl.js/.css + the geocoder from api.mapbox.com even
under maplibre (library downloads, no token, but still a CDN dependency —
the composite leaf proves 0 is reachable). The second belongs to the tree
verb, which is not mine to change.
22/22 bundles, 7/7 pre, 18/18 post, browser sweep of five StackLayout
consumers and all seven studio dashboards.
The runtime sibling of the UPG-100 poster generator (that one is
build-time over 28 fixed zones; walk zones are drawn live). For each
selected zone the camera fits the zone bounds (maxZoom 17), waits for
idle, and snapshots the keyless MapLibre canvas INSIDE a render frame
-- triggerRepaint + once('render'), because MapLibre does not preserve
the WebGL buffer and toDataURL outside a frame returns a blank white
image that still counts as a 'valid' data URL (measured: the first
sheet shipped a white rectangle; pixel check now reads avg 212
brightness, 0% blank). Camera restored afterwards.
The sheet borrows the poster design language: dark-green title band,
kicker with the zone centre, framed street map with the zone polygon
painted on it, a prayer charge line, zone corners, and the honest
credit (Map (c) OpenFreeMap, Data (c) OpenStreetMap contributors) --
letter portrait, printed in place via the shared
@map/composables/printInPlace.js. Agent flow re-verified end-to-end:
addPolygon -> selectPolygon -> print() raises the dialog once.
(Also restores the public API wrappers a block edit clobbered --
caught by the page's own ReferenceError before commit.)
The rule: a SELECTED region or country must look identical to the DX-life page
you land on when you click it, and the ONLY thing we add is the state their
site has no route for. Three things followed from that.
1. THE BAR CHART IS GONE. It was ours, not theirs. RegionDetail.vue is four
stat cards, the map, three goal cards and the country table — nothing else.
The goal cards and the country table now belong to a SELECTED region only;
they were showing on the no-selection view as totals, which made the chooser
look like a region page wearing the wrong numbers.
2. THE COUNTRY LIST IS THEIR UupgsList, ported. CountryDetail.vue:63-69 nests
<UupgsList :lock-country-code hide-see-all-link />, so cloning that page
means cloning that component — a DataTable of ours was a stand-in, not a
clone. src/UupgsList.vue carries their markup and their class names so the
CSS is theirs rather than a lookalike: .filters / .filters__toggle /
.filters__panel / .filters__active / .filter-chip, the .search-box with its
1.5em text-indent, the .grid[data-width-lg] 400px track, and the
.uupg__card grid-template-areas with its 576px reflow. Their endpoint and
field list too, and .uupg__content stays display:none as theirs is.
Scoped to what the page can actually reach: CountryDetail passes none of
useSelectCard / useHighlightedUUPGs / randomizeList / dontShowListOnLoad, so
the select-card variant, the /highlighted endpoint and the see-all link are
branches no consumer runs.
3. THE CHOOSER is the one deliberate difference, and it is now the only one.
Opening tab 2 first is a state DX-life cannot reach (/regions is the list,
/regions/<slug> always has a subject, [slug].vue 404s otherwise), so it gets
a generic map of everything plus three ways in: a region dropdown, a country
dropdown (both the shared FilterDropdown), and the all-regions table whose
rows were already clickable. Choosing a subject replaces it with the clone.
Also removed a visible difference nothing had flagged: the shared PinHint's
"Tap a coloured dot" coach-mark was floating over the cloned map, and their
page has no such overlay. pinHint:false now, and the fix had to go in the
COMPOSITE — a child profile's meta only configures its own leaf page, while a
nested pane is handed the config regions-mfe.vue builds, and PinHint reads it
off the injected profileConfig. Same cap-4 inheritance gap assetsBase hit, so
pinHint joined INHERITED_PAGE_KEYS as well.
Verified against the LIVE page, not the stylesheet: doxa.life/regions/cameroon
at 1440x1000, filter panel open on both sides, 28 of 28 computed properties
IDENTICAL, 0 differ. That diff found the one real error — their .button sets no
font-size so it inherits 19.5312px, and I had hardcoded 15.625px. Two rows it
could NOT measure are reported as unmeasured rather than counted either way:
.filter-chip is absent on both sides with no filter selected.
Every suite still green: the map draws 934 pins / 51 outline for a region and
61 / 5 for a country, the country page renders 8 UupgsList cards with their
filter toggle and search box, no page has a coach-mark, and all six viewports
still match their container formula.
Two halves of the Driver's directive:
1. FULL PAGE (BUG-PRAYER-WALK-PLANNER-MAP-DOES-NOT-FILL-THE-PAGE).
The BUILT leaf was already full-viewport; the DEV staging page boxed
the element in a centred 640px card -- measured 781x584 at y=206 on
a 1440x900 viewport, exactly 'the map is not filling the full
page'. The dev harness now mounts fixed inset:0 like the leaf
(two-host rule).
2. AGENT-OPERABLE PRINT. PrayerWalkPlanner.print() used window.open,
which is pop-up-blocked without a user gesture -- an agent calling
the documented API got silence. The poster-wall's in-place
hidden-iframe mechanism (e957cbe) graduates VERBATIM to
@map/composables/printInPlace.js (library bar: 2+ consumers,
parameterized) and the planner uses it; @page pins letter portrait
(the 839b0c4 ruling).
Verified end-to-end as an agent would: registry -> addPolygon ->
selectPolygon -> print() => {opened:true,count:1}, hidden iframe
print() fired once, portrait pinned, zero pop-ups.
Simple Research showed a Fly + Clusters toolbar where the search bar used
to be, and the map offered no way to pick an engine without writing a
second map.
FLY + CLUSTERS ARE NOW LEAF-ONLY. LegendTools renders at top:14px centred
on the legend column — the search bar's exact slot — so on the Regions and
Languages tabs (the legendTypes its predicate allows) it reads as having
REPLACED the search bar. MEASURED on the leaf: fly toolbar at y=58 against
the search bar at y=56, the two on top of each other. In a stack whose
bottom half is a grid the reader is scanning, a fly-tour that moves the
camera underneath fights the other half, so the composite passes
legendTools:false. The standalone leaf keeps both.
THE ENGINE IS ONE PROP. resolveMapProvider already read profileConfig
.provider, so research-map.instances.json publishes the maplibre instance
and a partner running thousands of embeds changes one value instead of
maintaining a second map. Mapbox stays the default: it carries the richer
feature set. MapLibre is the keyless option for volume.
MAPLIBRE SHIPS NO SEARCH BAR, ON PURPOSE. GeocoderComponent's maplibre path
still calls makeMapboxForwardGeocode(accessToken) (:526) — the geocoding
REQUEST goes to Mapbox whichever engine draws the tiles. Shipping the bar
under maplibre would keep billing the exact key the option exists to avoid
while LOOKING free. Under mapbox it stays, unchanged.
AND THE LEGEND RISES INTO THE VACATED SPACE. SemanticTreeLegend's
--stl-top defaults to 60px that exists only to reserve room for a search
bar; leaving it when the bar is gone is a dead band. MapToolbar's
has-geocoder collapses its own reserved gap the same way.
MEASURED against the built tree:
leaf mapbox search@56 legend@104 fly@NONE (Prayer) / @58 (Regions)
leaf maplibre search@NONE legend@58 — legend rose 46px
composite search@NONE legend@104 fly@NONE on Regions
7/7 pre, 18/18 post.
KNOWN GAP, pre-existing and NOT introduced here: the mapbox composite still
shows no search bar. mapboxToken is injected per element and the composite
passes neither tk nor tokenUrl to its child, so the child's geocoder gets
an empty token and bails while the tiles render from another path. Isolated
but not fixed.
One conflict, in the generated root page (it inlines the whole manifest).
Resolved the way a derived file should be: taken from main, then regenerated
with bun run tree so it describes the merged manifest, followed by fix:sri.
Nothing else in the tree moved — no bundle was rebuilt, so main's own build
outputs are untouched.
Driver: 'if I search the name of the country and add n to it, like
Austrian or Canadian, I would like for there to be a selection of that
country -- the table under the map shows the result for that country.'
Three changes, one ladder:
1. resolveFuzzyValues(): WHOLE-VALUE Levenshtein against the DISTINCT
values of every search key -- 'Sudann' is a misspelling of the
VALUE 'Sudan', so the rows returned are exactly Sudan's, identical
to picking it as a Place. The windowed matcher stays only as the
final rescue tier.
2. Best match wins: only the minimal-distance value(s) resolve. The
under-the-cap union admitted 47 rows for 'Canadian'.
3. Both fuzzy tiers cap at 2 edits absolute (the did-you-mean bound);
threshold*len alone reached 3 errors at 8+ chars, which is where
the cross-facet noise lived.
Measured (dashboard scope / grid count agree): Sudann -> 105/105
Sudan only; Indonesa -> 36/36 Indonesia; Banladesh -> 3/3 Bangladesh;
Bhutann -> 13/13 Bhutan; exact terms unchanged (tier 1).
NOTE: Canada/Austria themselves have NO rows in this dataset (it
holds only the 175 countries with unreached people groups), so
'Canadian'/'Austrian' cannot select them -- a handful of genuine
2-edit neighbours return instead.
Also ships @map/composables/retryableAsyncComponent.js wired at every
defineAsyncComponent site (ProfileLoader chokepoint + 8 lazy
components): a transient ERR_NETWORK_CHANGED no longer permanently
kills a lazy plugin (3 retries, growing backoff). Live fault-injection
proof pending; the wrapper is the documented Vue onError contract.
Main moved 20+ commits while the regions work was on its own branch. Every
conflict was a GENERATED file — manifest.json, the root page, and the
regions-dashboard bundle's .js and leaf pages. Zero source conflicts: all six
of my source files (the two regions .vue, the new map child, fix-sri.mjs,
sri-consistency-check.mjs, package.json) auto-merged.
Generated files are not hand-merged. They were resolved to main's side and then
REGENERATED from the merged source — BUNDLE=regions-dashboard vite build, then
bun run tree, then bun run fix:sri. `bun run tree` rewrote every bundle's pages
and produced byte-identical output to main's committed ones for all twenty
other bundles, so the only files this commit changes beyond the merge are the
six that had to be rebuilt. That agreement is also evidence main's committed
tree matches main's source.
The pinHint:false regression I saw before is gone: main's 566ad46 committed the
two metas that were previously only on disk, which was the root cause — a tree
regeneration now preserves them instead of dropping them.
Re-verified against the merged library, which is the part that mattered: main
changed useMapInstance.js, StackLayout.vue and ViewSelect.vue, all of which the
regions element sits on. All three suites still pass — the map draws 934 pins /
51 outline features for a region and 61 / 5 for a country, the pin mode still
follows the subject, the country page still renders CountryDetail's shape, and
every width still matches DX-life's container formula at six viewports.
23 of 25 gates green. The two that fail are pre-existing and not in this diff:
check:library-index is an mtime artifact (regenerating the index produces
byte-identical content), and check:backlog-status reports 6 of 546 non-enum
statuses in the shared _qa-cycle board, which lives outside every repo.
"Zone" was our internal word leaking into the UI. The thing the map paints IS a
UPG-100 list -- 27 of them, one per printable packet -- so the tab is named for
that, and the legend opens on it. Region stays, described as what it actually
does: sort those lists by world area when you want that.
ORDER IS DELIBERATELY UNCHANGED, and the comment now says why. Tab INDEX is
coupled to tree DEPTH inside the shared legend -- `activeTab = Math.min(sel.depth,
...)` and again on breadcrumb -- and the tree nests region(0) -> zone(1) ->
group(2). Moving the list tab to index 0 to make it open first would have made
selecting a zone jump to the Region tab instead, and SEVEN profiles share that
component. I started that way and backed it out.
Opening on the list tab uses the mechanism already built for it: `:default-tab`,
added for country-posters with the same need ("it opens to the country's semantic
level in the legend"). One prop at the call site, no shared behaviour touched,
and the component clamps an out-of-range value so it degrades to the top
generation rather than blanking.
LegendMobile takes neither tabs nor defaultTab -- it derives its own view -- so
there is nothing to wire there.
Card: BUILD-UPG100-MAP-LEGEND-LEADS-WITH-THE-UPG-100-LIST (the rename + default
half; print-from-selection is still open and is the packet's entry point).
Driver: 'fuzzy search is too fuzzy -- Bhutan returns more results than
just the country, and there is no interoperability with the other
filters like selecting it as a place.' The first fuzzy pass was a
UNION (exact + every <=k-error neighbour), so the exact term Bhutan
counted 132 rows while Country=Bhutan counted 13 -- two controls
disagreeing about one place.
Now tiered, the conventional search contract: when a term has ANY
exact substring match, only exact matches return -- search agrees
with the facet (13 = 13). The k-errors pass runs solely when exact
yields zero (Bhutann -> 13, Banladesh -> Bangladesh). Applied in the
shared Fuse.search (grid) AND a filterRowsBySearch helper used by the
shell + all four panels (table, facet rail, charts rail, map), so
every surface computes the same tier.
Measured: Bhutan 132 -> 13 (grid 'Showing 13' agrees), Bhutann 13,
Banladesh rescued, gibberish 0, clear 2,107.
Completes the clone. Two things were missing: the map both their pages render,
and the fact that a COUNTRY is a different page from a region.
THE MAP — plugin-designs/regions-dashboard/src/RegionCountryMap.vue.
Layers are DX-life's, expression for expression (CountryMap.vue:186-240): the
country-boundaries fill at 0.12, the 2px outline, the single-worldview filter
that stops the highlight drawing three times, the prayer / in-country colour
cases, the circle-sort-key that puts better-covered pins on top, the
zoom-interpolated radius, 0.75 opacity on neighbours, their seven colours.
The machinery is ours: theirs does `new mapboxgl.Map` in a Nuxt page, which
cannot work in a shadow root — container-by-id does not cross the boundary, the
async CSS link gives a first-initialising map a 0-height canvas, and the
wordmark duplicates per instance. @map/composables/useMapInstance.js already
solves all three, so this supplies only the layers. Points come down from the
parent instead of a second fetch, so the pins and the figures above them
provably describe the same rows.
THE COUNTRY PAGE — CountryDetail.vue is head + map note + map + the people-group
list, with NO stat cards and no goal cards. It now renders that, not the region
page wearing a country's numbers. Its map passes no color-by-prayer, so pins
read in-country vs surrounding, which is what its own note tells you to look
for. The list uses @map/components/tables/DataTable.vue rather than a third copy
of their 662-line UupgsList — this repo already ships that as people-groups-grid,
and nesting the real element is a cap-4 composition question.
Four defects found by measuring, not by reading:
1. A CANVAS AT THE RIGHT SIZE IS NOT A MAP. With a <template> branch per subject
kind, region -> country unmounted one map and mounted another into a
zero-size container: 1200x780 canvas, correct centre and zoom, layers [],
nothing drawn, a blank white card — and every assertion passed. One instance
driven by props now, and the suite asserts queryRenderedFeatures (934 pins /
51 outline for Africa, 61 / 5 for Cameroon) instead of canvas dimensions.
2. isStyleLoaded() IS THE WRONG GATE for adding layers. It also waits for the
style's sources, which never arrive at zoom 6 on this box because the local
token is URL-restricted and its high-zoom tiles 403. Same code drew a region
and drew nothing for a country. Keyed on 'style.load' — the style being
PARSED is what makes addSource/addLayer legal — with a parsed-style fallback.
3. THE PIN MODE WAS FROZEN at whatever it was when the layer was created, so a
country page painted prayer colours. Caught in a screenshot by one yellow
"1+ praying" pin; invisible to every assertion, because the pins were
rendered and the legend was correctly absent. applySubject now re-applies
circle-color and circle-sort-key.
4. THE PROFILE CONFIG ARRIVES BY INJECT, NOT AS A PROP. The element this bundle
registers IS ProfileLoader, which parses the profile-config attribute and
provides the parsed object (ProfileLoader.vue:195). Both tabs read it off
their own string prop, which is always empty — prayBaseUrl, languageCode,
instanceId and assetsBase all silently defaulted. Nothing looked broken until
assetsBase mattered: the vendored Tabulator 404'd twice with no error and the
country list just never drew. Fixed in both tabs, prop kept as a fallback.
Token delivery needs no wiring: the generator detects a map under the bundle and
bakes the ladder itself, so all three leaves now carry it and
check:nested-token passes.
cities-internet/connectivity-map.meta.json and
gospel-resources/resources-map.meta.json carry the pinHint:false
opt-out (no 'Tap a coloured dot' on maps with no dots). They were
authored and BUILT INTO the leaf pages but never committed -- a clean
clone would silently regress the fix on its first rebuild.
Driver fine-tune: 'almost perfect height -- keep the map the same
height, but decrease the height of the dashboard by about 20px.' Map
row stays 340; the 20px comes out of the table's derived share.
Storage key v5 -> v6. Measured live: shell 703 -> 683, map 340,
table 244 -> 224.
Second Driver raise, same routing as the first: shell and map move
together so the whole gain lands in the map row; the table's derived
share stays at its height. Storage key v4 -> v5 so the new default is
seen immediately. Measured live: map row + canvas 340px, table 244px,
shell 703px.
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.
Was URL-encoded JSON (#f=%7B%22affinity_bloc%22%3A%22A008%22%7D) --
unreadable twice over, and a bare API code is unauditable by whoever
receives the link. Now the Stack Overflow /id/slug pattern:
#f=affinity_bloc:A012*south-asian-peoples;religion_family:Hinduism
The CODE stays canonical; the *slug comes from the same labelFor()
resolver the chips and CSV provenance use, and the parser STRIPS it --
so links are locale-independent and survive label renames. Literal
%;:*+ in values are percent-escaped so the delimiters cannot collide;
spaces ride as '+'. Legacy JSON hashes (old shared links) detected by
their leading brace and still restore.
Measured: click -> readable hash; fresh page with it -> scope 881
restored; legacy JSON hash -> 881 restored.
The cap-5 shell's "Simple Research" tab rendered an error panel instead of
the map, in both trees:
Could not load <doxa-research-map> from ../../doxa-research-map/
doxa-research-map.js
StackLayout already anchored sibling-bundle srcs to the tree root, but it
anchored on the /doxa-maps/ segment of window.location.pathname. The studio
mounts at the SERVER ROOT (/index.html), which has no such segment, so the
lookup missed and the literal '../../' was returned untouched. The browser
resolved it against '/', where '../' cannot climb, and requested
/doxa-research-map/doxa-research-map.js — 404, both children, empty stack.
That is why it survived: every gate and every screenshot used a LEAF url,
and the studio is the one host that is not one. The same composite at its
own leaf renders perfectly.
resolveBundleSrc now falls back to bundleTreeRoot() — the bundle's OWN
script URL, which rollup rewrites to document.currentScript.src in the IIFE
build. That is a fact about where the code came from, not a guess about
where the page is, which is the lesson stack-demo.vue wrote down when this
defect appeared for the third time. It returns null in dev ESM and for any
non-<name>/<name>.js layout, so it never guesses. The pathname rule stays
FIRST and the untouched-string fallback stays LAST, so every path that
worked before is byte-identical: this can still only fix a path, never
break one.
research-map-plus-grid also adopts stack-demo's bundleSrc() dev/prod
helper. In dev, vite serves entries at /plugin-designs/<bundle>/index.js and
there is no <bundle>/<bundle>.js to reach, so the dev surface 404'd too.
import.meta.env.DEV is statically replaced, so the built output is
byte-identical to the literal it replaces.
Verified in a browser against the built tree, reading the screenshots:
studio root -> Simple Research paints the world map with its prayer legend,
on the dev server too. Negative-tested: disabling the new fallback and
rebuilding restores the exact 404 and the empty canvas.
No regression across every StackLayout consumer — rmpg shallow leaf, rmpg
deep default/ leaf (the case the pathname rule was written for),
spa -> Simple Research, dashboard-plus-grid, stack-demo — and all seven
studio dashboards. 7/7 pre, 22/22 bundles, 18/18 post.
Driver naming ruling: no 'tree'/'semantic' in the user-facing name;
the drill-down vocabulary is NESTING/FOLDERS. Verified live: the
rail's fr-title renders 'Vertical Legend'.
DX-life's search bakes in real Fuse.js { threshold: 0.4, ignoreLocation,
minMatchCharLength: 3 } (UupgsList.vue), which tolerates typos -- an
extra trailing letter ('Bhutann') or a substitution ('Indoa') still
finds the country. Two things were lost in migration:
1. The vendored @map fuzzy.js 'fuzzy' fallback was a strict SUBSEQUENCE
matcher -- an EXTRA needle char always fails a subsequence, so the
exact typo class Fuse tolerates was the class it rejected. Replaced
with the standard k-errors approximate-substring DP (Sellers):
match iff errors/pattern-length <= threshold, same score contract
as Fuse with ignoreLocation. Highlight indices = best window.
2. The dashboard's makeSearchMatcher was a plain .includes() -- fuzzy
never reached the topbar search at all. It now delegates to the
shared fuzzyMatches() with the exact DX-life options, so the
dashboard and the grid agree on one matcher.
Measured: dashboard 'Bhutann' 0 -> 13, 'Indoa' 0 -> 1,319, 'zzqqx' 0,
clear 2,107; grid standalone 'Bhutann' -> 13 Bhutan cards, highlights
render. Exact terms now also admit <=k-error neighbours ('Bhutan' 13
-> 132) -- the same widening real Fuse applies in DX-life.
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.
Two Driver asks on the UPG-100 poster wall, both measured at the source.
PORTRAIT. The poster path was the only one of the three print paths using
@page{size:auto}; the list and card-deck paths already pin `letter portrait`.
The poster art is a 3ft x 3ft SQUARE, and a square on an auto page has no
reason to prefer the tall axis, so it landed landscape. Pinned to
`letter portrait` to match its siblings.
The print img rule then had to change with it: `max-height:none;width:100%`
scaled the square to the full page WIDTH, which on portrait letter is taller
than the printable height -- it would have overflowed onto a second page.
Constrained both axes so object-fit letterboxes the square inside the sheet.
VERIFIED via page.pdf({preferCSSPageSize}): 612x792pt, ONE page.
IN PLACE. openPrintWindow() opened a popup and document.write()'d into it.
That was deliberate -- an earlier Driver ask wanted a preview window -- and is
now reversed: the dialog must open with no second browser window. A hidden
same-page iframe keeps the entire existing contract (one full <html> string per
caller, the markup's own <img onload> -> print()) while the dialog belongs to
this page. All three print paths stay on the one mechanism.
It also deletes a failure mode: an iframe cannot be pop-up blocked, so the
"Pop-up blocked -- allow pop-ups" branches every caller carried are unreachable.
Their messages now name something the user can act on instead.
VERIFIED in Chromium: 0 window.open calls, 1 iframe, context page count stays 1.
The noopener post-mortem above the helper is KEPT as history -- the code it
described is gone, but "a flag that returns null by spec looks exactly like an
environment problem" is the lesson worth carrying.
The vertical legend's Religion and Language top tiers emit DERIVED
family names ('Hinduism', 'Indo-European') that exist on no row field,
so they narrowed the dashboard but never the people-groups grid below
-- the last two dead axes of the tree-sidebar interop bug. The grid
now runs the SAME derivations (DERIVED_AXES) from the SAME library
modules the dashboard config reads (RELIGION_FAMILIES,
canonicalFamilyName, langFamilyByIso), routed through the shared
valueMatches() by a one-line rowAxisValue() resolver in BOTH filter
loops. imb_language_family added to the grid fetch; composite maps
religion_family/language_family onto the new props; the dashboardOnly
stopgap flags are retired.
Root cause of the first failed attempt: the re-filter watcher
hand-enumerated every filter prop -- the THIRD copy of the axis list
-- so the new props changed propFilters but never triggered a
re-filter. The watcher now watches propFilters itself: one owner,
future axes cannot miss it.
Measured round-trip: Hinduism -> dashboard 693 / grid 'Showing 693';
Indo-European -> 677/677; clear -> 2,107 both.
Driver follow-up: the taller dashboard's display room belongs to the
map, not the table. shellH stays 542, so the table's derived share
returns to its prior height and the map row carries the whole gain.
Storage key v3 -> v4 so a just-persisted layout cannot mask it.
Measured live: map row + canvas 300px, table 244px, shell 663px.
Bangladesh (3 groups) WAS rendering -- queryRenderedFeatures returned
the BGD polygon -- but painted #e8e8e8 against the #f3f3f1 no-data
surface: a 1.06 contrast ratio at 0.78 opacity, pixel-identical to an
empty country. Bin 1 is now #c9c9c9: the ramp's first step must be
separated from NO-DATA at least as clearly as from bin 2. Legend
swatch follows automatically (derived from BIN_COLORS).
One owner: DEFAULTS.shellH, CSS fallbacks kept byte-equal at 542.
Storage key v2 -> v3 so a persisted 502 layout cannot mask the new
default. Measured live: shell 623 -> 663px.
PEOPLE/PLACE/LANGUAGE/RELIGION headers stay pinned while their rows
scroll, and the next section's header pushes the previous one away --
position:sticky top:0 inside the scrolling .fr-body, the exact GitHub
markdown-preview mechanism. Opaque panel background so rows scroll
under the pinned label instead of through it.
Driver: "I searched Bhutan in the search bar and it did not switch the
toggle to show the country was selected in the filters."
MEASURED what actually happens: typing narrows the dashboard live through
bus.searchTerm (scope 2,107 -> 13) while filterSet stays empty -- the data
changed and no control anywhere said why. Invisible state, the same defect
class as the silent grey pane.
The conventional faceted-search answer: free text is its OWN chip beside
the facet chips -- "Search: Bhutan x" -- removable like any of them, and
Clear all clears it too. It deliberately does NOT masquerade as a Country
selection, because the term matches across ALL searchKeys (name, region,
language, religion...) and labelling it a country filter would lie whenever
it matches something else.
One archaeology note that cost a first attempt: the fix initially landed in
DashboardFilterChips.vue, a component the topbar dedup pass had already
UNMOUNTED -- its own comments name the filters band as the single source of
truth for chips, which is where this now lives.
Playwright round trip: type -> chip present, scope 13; Clear all -> chip
gone, scope 2,107.
Driver, after rejecting both experiments: "I cannot scroll right, and the
column numbers have this super white background -- it looks odd, it was to
be invisible... I am not impressed with that design. Use conventional
designs only."
He is right, and the two rejected variants earned it: scroll-to-read hid
the numbers' geometry behind a gesture, and the frozen column needed a
painted backing precisely BECAUSE something could slide beneath it. The
conventional row every chart library ships has neither problem: swatch,
label that ELLIPSIZES at the available width (full name in the row's
existing tooltip), count right-aligned in a fixed-width slot. No horizontal
scroll, no sticky, no background -- the number needs no backing because
nothing can ever slide beneath it.
Measured, both legends: transparent count background, position static,
right-aligned, ONE shared right edge, labels ellipsizing, zero horizontal
scroll. Screenshot read before pushing.