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.
THE LINK (Driver pasted localhost:5174/research/pitaguari/): the card's
"Full profile" base defaulted to root-relative /research/, which resolves
against whatever origin hosts the element -- right on doxa.life, wrong on
the dev server, an iframe, or a partner site. Same class as the ../../
child-src bug. Adopted the pattern the shared PeopleGroupDetail already
uses: window.DOXA_SITE_URL override, else the absolute canonical origin.
TWO owners had to move, and the first fix alone measured as a no-op: the
computed's fallback AND the prop's own default -- a non-empty prop default
is always truthy, so the canonical branch was unreachable until the prop
default became ''. Unset must mean unset.
Playwright on a non-doxa origin: all 24 rendered links now
https://doxa.life/research/<slug>, zero relative.
LAYOUT KEY v2: the Driver sees unequal sidebars because his browser
restores a layout saved under the OLD defaults (rail 264), and a restored
value outranks any default by design. Defaults changed materially today, so
the storage key is versioned: everyone starts once from the new symmetric
defaults; a fresh drag re-personalises from there.
Driver: "when I click on a people group the header of the table is flooded
with buttons that overflow and don't make sense" -- and, on one of them:
"it does not need a Clear Selected button in the header because the footer
of the page already gives me that."
THE CLEAR X IS GONE from the header pill. Verified what actually clears
before repeating the premise: the thead master checkbox and the detail
drawer's close both call clearSelection, so the removal deletes a duplicate
trigger, not the capability.
THE FLOOD HAD TWO CAUSES, both measured at 1280 where the middle column is
686px: the centred hint, and -- after hiding it -- the in-flow title
squeezed to 48px and wrapped INTERNALLY ("People"/"Groups" on two lines),
growing the bar to 43px and tumbling the spacer. While a selection exists
the title and count duplicate what the pill and the section header already
say, so all three yield, state-scoped, and return on clear. The title also
gets white-space nowrap unconditionally: a title may never two-line itself.
Playwright, both 1280 and 1440, real dispatched clicks, full round trip:
select -> bar 32px, one line, zero overflowing controls, no clear x;
uncheck -> count 0, title and count return.
"The number column needs to use some type of TABLE css so the numbers
really are set apart as a column, uniform and justified right, so it does
not move when I scroll left and right."
He is right that table layout is the honest tool: display:table guarantees
every row's count cell shares ONE column edge in every browser ever
shipped. The previous subgrid attempt measured correct here but subgrid is
25 years younger than table layout; the table needs no support caveat.
Rows are table-rows, swatch/label/count are cells; the count cell keeps
sticky right:0 with an opaque background so a scrolling label slides under
a pinned column.
Measured, both legends: display table, cells right-aligned, exactly ONE
unique right edge at rest AND while scrolled. And with the rail widened to
296 in the previous commit, neither legend currently overflows at all --
the wider sidebar bought the words their room, as intended.
Driver: 'the charts sidebar can be wider so it is the same width as the
left sidebar, which will put the map in the exact centre.' rail 264 -> 296
everywhere the number lives (inline var, both CSS fallbacks, the
composable's DEFAULTS, the doc comment) -- one quantity, every owner moved
together this time.
Measured: facet 296, rail 296, map margins 297 both sides, centred true.
Driver: "it is a priority to reclaim height... so I can see half of the
first row of the people groups grid." Measured after: shell bottom 623,
first card top 640, half the card inside a 900px viewport -- the faces show.
THE HEIGHT: map 360 -> 260 and shell follows the same row arithmetic
(260 + 32 + 7x26 + 28 = 502); headers slim again (topbar 44 -> 38, stats
26 -> 22). Seven data rows unchanged.
THE ZOOM: a shorter map now shows MORE world, not less. Default zoom
1.45 -> 1.1 opens on the whole sphere, minZoom 1 -> 0.5, and the mercator
"no duplicate world" floor is skipped on the globe -- a sphere has no
second world, so the floor only stole zoom-out range.
TWO MORE SECOND-OWNER BUGS FOUND ON THE WAY, both now dead:
- research-dashboard.vue passed its own defaults {mapH:360, shellH:720} to
useDashboardLayout, silently overriding the composable's DEFAULTS -- the
bundle carried BOTH and the stale copy won, so two reclaim passes changed
nothing. The override is deleted; one owner.
- one grid-template-rows fallback spelled 40svh WITHOUT a space and
survived the fallback sweep -- at 900px that IS 360, re-pinning the row.
ALSO: the chart count column is now a real GRID column (label 1fr, count
auto, rows joined via subgrid), because per-row flex put each count at its
own row's right edge the moment the legend scrolled -- "less like a column,
more like cards". Measured: one shared right edge before and after scroll.
RELIGION ROLLUP. "It should not mention Ethnoreligion and Islam multiple
times -- just the high-level religions." The rollup already existed as the
religion_family facet; the donut was simply pointed at the fine-grained
religion facet. One word moves it. Measured after: Hinduism 693, Islam 650,
Ethnoreligion 610, Buddhism 83 -- each named once. The full Sunni/Folk
breakdown still lives in the facet rail's Religion tree. religion_family is
dashboardOnly by design, so a slice click narrows the dashboard but not the
grid below -- documented, and the right trade for a glanceable donut.
COUNT COLUMN. Uniform, right-justified, immovable, in BOTH donut legends:
min-width 3.4em + text-align right on the shared .rd-li rule, on top of the
existing sticky right:0 and tabular-nums. Measured: every count 42px wide,
right-aligned, digits in one scannable column.
Known nit, deliberately left: Religion can show two "Other" rows -- one is
a real family label, one is the topN overflow bucket. Worth a rename of the
overflow bucket later, not a silent merge of different things.
Driver: "there is no way to exit full screen or export csv when you enter
full screen on the table."
The bar carrying both controls IS inside the wrap, but the overlay used
position:fixed, which inside a shadow root resolves against any
transformed or contained ANCESTOR rather than the viewport. In the cap-5
shell that pinned the wrap to a pane whose top sat above the visible area,
so the bar -- and its exit and export controls -- rendered off screen. The
leaf host has no such ancestor, which is why every leaf measurement passed
while the shell was broken: the two-host rule again.
element.requestFullscreen() is the wheel the platform ships: guaranteed
viewport coverage regardless of ancestor transforms, native ESC, and a
fullscreenchange event so the button state follows every exit path. Two
shadow-boundary details mattered and are documented in the code:
document.fullscreenElement retargets to the shadow HOST (and
host.contains(inner) is false across the boundary), so the sync reads
getRootNode().fullscreenElement; and our ESC handler delegates to
document.exitFullscreen() so all consumers stay in sync. The .is-full
overlay stays as styling hook and API-less fallback.
Verified in the shell with a screenshot read before pushing: native
fullscreen engages, "Exit full screen" and "Export CSV" both visible
top-right, full column headers, ESC exits.
Driver: collapsing the table is supposed to increase the size of the map;
instead it introduced white space under the collapsed bar.
The collapsed template still used 1fr for the map row, and 1fr cannot grow
in an auto-height shell -- no fixed total to divide -- so the freed space
pooled under the bar. The page-scroll collapsed state now derives from the
same shell budget as the open state: map = shell minus the bar, on both the
row and the element. One owner, every state.
Two rules read --rd-shell-h with DIFFERENT fallbacks: the table keyhole fell
back to 602px (seven rows) while the sidebar cap fell back to 80svh = 720px.
Nothing sets the var at rest, so the sidebars believed the shell was 119px
taller than the table did. The spanning side columns stretched the auto
table row to fit, and the 118px surplus painted as the grey band under and
around the table -- and made divider drags look broken, because the row
would not follow; the slack just moved.
The one-quantity-one-owner rule applies to FALLBACKS too: same var, same
fallback, everywhere. Cap unified at 602px.
Measured through real drags, screenshot read before pushing:
at rest gap 1px, 7 rows, table dead-space 118 -> 2px, sidebars -2px of table bottom
divider up 80 gap 1px, 10 rows, sidebars still flush
shell +120 gap 1px, 15 rows, shell 733 -> 855, sidebars follow
In page-scroll the map row was 1fr, which stretches to the implied content
height while the map element stays pinned at --rd-map-h -- the row grew,
the element did not, and the difference painted as dead space above the
table. Reported three times. The row now uses the same var as the element,
so they are one quantity and the slack cannot exist.
This exact rule was applied earlier today and reverted on a false signal --
a probe said no shell, but the real cause was a dead dev server, not the
CSS. Re-verified this time against a live server: map bottom 488, table top
489, gap 1px, seven data rows, with a screenshot read before pushing.
Mapbox renders space and the starfield engine-side through the fog API
(documented in their globe guide: stars and atmosphere are customized with
the fog property). Applied their documented example values with one change:
space-color is pure black on the Driver's instruction, star-intensity 0.6.
The container behind the sphere is plain black now, no gradient -- on
Mapbox the engine paints its own space so it never shows; on an engine
without fog (MapLibre) black is the honest fallback.
Verified by reading the rendered screenshot: stars in black space,
atmosphere glow at the limb, vivid blue oceans, the choropleth legible.
Every wording of the ask described Mapbox itself -- the globe space style
with stars, the atmosphere, water like the Mapbox style. Mapbox Standard
renders all of that natively; no found clone matches the original. The
working baked token is already accepted as public by explicit ruling.
One key changed. The MapLibre path (VersaTiles Colorful, keyless) stays
fully built and reversible by flipping provider back.
The Driver, correcting the first attempt: "I don't want a dark basemap, I
didn't ask for one. I asked for a spherical globe with a black background --
the galaxy, like the Mapbox map -- with blue oceans like the Mapbox map, and
the land was not to be black."
The first galaxy tinted dark tiles: black land, recoloured water -- the
night side of Earth. What was asked for is the DAY side seen from space.
So the style entry is now a normal-coloured basemap with no paint overrides
at all (mapbox standard / OpenFreeMap liberty), and the blackness lives
where it belongs: the map CONTAINER behind the sphere, a deep radial space
gradient scoped to [data-projection="globe"] so flat mercator keeps its
neutral ground.
Verified by reading the rendered screenshot before pushing: a real sphere,
blue oceans, normal land, the greyscale choropleth legible on top, black
void behind the globe, and the old caption gone.
The caption "UPG choropleth - click a country to filter - click a pin to
select" is gone, along with its now-dead .rd-maptag rule and a comment that
claimed the bottom-left corner was occupied.
GLOBE: the projection default was already 'globe', but the request was not
arriving. Mapbox GL takes setProjection('globe') -- a bare string. MapLibre
v5 takes setProjection({type:'globe'}) -- an object. Passing a string to
MapLibre throws, an empty catch swallowed it, and the map silently fell back
to flat mercator with no console error. That is why it appeared exactly when
the dashboard moved to provider maplibre.
Now tries the object form first, then the string, so one call serves both
engines. The catch stays so an engine with no projection support degrades
rather than breaking the panel -- but it now catches a real unsupported case
instead of masking a shape mismatch.
GALAXY + MAPLIBRE. New 'galaxy' style in the shared registry -- deep-space
ground, blue oceans -- with both a mapbox and a keyless MapLibre URL, and a
paint override that is applied only if the loaded style actually has the
layer, so a miss is a silent no-op rather than a break. The dashboard now
runs provider 'maplibre' and mapStyle 'galaxy'. Reversible by two config
keys; the Mapbox path is untouched.
BOTH RESIZES RESTORED. My seven-row change had hard-capped the table's
keyhole at 7 x 26px, which read like a default and was not: nothing writes
--rd-table-rows, while the drag handles write --rd-shell-h and --rd-map-h.
So the cap was unreachable by any gesture and the Driver lost both resizes
at once, plus a grey gap where the freed pixels had no consumer.
Fixed by restoring the derivation and moving the default instead: the
keyhole is once again shell - map - bar - foot, and DEFAULTS.shellH drops
720 -> 602 (map 360 + bar 32 + 7 rows x 26 + foot 28) so the table OPENS at
seven rows while staying fully draggable. CSS fallback matched to 602.
Measured before the revert: shell handle 849 -> 731 with rows 7, table
divider map 360 -> 280 with rows 10, gap 1px after each.
Also carries the charts' frozen count column (sticky right:0 with an opaque
background, so a scrolled label cannot slide under the number) and the
ViewSelect library-index entry.
The first tab a new user meets is now the poster/resources pane, not the
map. That pane already explains what a UPG-100 list is at the top and shows
the posters, so it needs no explanation; a choropleth of zones does. Maps
need not be primary — a familiar list or grid is the on-ramp people use
before they transition into the map.
Renames the tab from "Prayer Lists" to "Prayer Resources".
Pane order lives in at[0], not in array order: buildPaneColumn sorts by
at[0] while ViewSelect renders the array verbatim, so both are swapped
together or the tab strip desyncs from the scroll column. The active view
is now derived from that same sort instead of a hardcoded 'map'.
In the wrapper shell the map pane is resolved by PROFILE rather than by
"pane 0 is the map": under the positional rule, reordering mounted the map
element into the poster pane, and "Show on map" scrolled to the wall you
clicked from. The shell's tab strip also resets to a profile's first view
on switch, since a stale 'map' id is valid in nearly every profile.
ViewSelect keeps each tab on one line (nowrap + ellipsis), ported from the
shell's own mobile rule that the original extraction left behind. Measured
at 390px: "Prayer Resources" wrapped inside its button and grew the strip
45 -> 60px; it now holds 45px at every width from 1280 down to 390.
Driver, on the people-groups table under the map: "I would like to add a
horizontal scroll bar so that instead of ABBREVIATING the headers, we can have
the FULL headers in the table", and "I would also like to add a FULL SCREEN
button to the table, so that if you want you can open up the table to be full
screen."
These ship together on purpose. The table now shows seven data rows by default
because we are reclaiming vertical space; a deliberately small table is only an
honest trade if the reader can get a big one on demand. The cap and its release
are one change.
HEADERS ARE NO LONGER ABBREVIATED. text-overflow:ellipsis is gone from .rd-th.
Ellipsis destroys information with no way to recover it -- "Engageme..." is not
a column name, and unlike a truncated VALUE it has no menu behind it. Horizontal
scroll preserves it: the full text always exists and is always reachable. This
is the same mechanism already applied to the chart legends (nowrap +
overflow-x:auto), deliberately reused rather than a second invention.
THE FLOOR IS MEASURED, NOT ESTIMATED. Columns are flex:<w> 1 0, which can never
overflow -- flex-basis 0 plus shrink means every column collapses to whatever
the container offers, so scrollWidth always equalled clientWidth and overflow-x
had nothing to scroll. Each column now carries a min-width derived from its own
header. A first pass guessed 7.1px/char, which put the summed floor at 675px
against an 878px scrollport, so it never bound and no scrollbar could appear at
any width. Measured live by cloning each header out of flex flow: worst case is
9.75px/char (Language 78px/8ch), so 9.9 covers every label, +12px reserves the
sort indicator. Summed floor 742px + 30px checkbox.
That floor sits BELOW the 1440px scrollport on purpose. At 1440 the eight
columns genuinely fit and there is nothing to scroll; forcing a scrollbar there
would invent a defect. It binds at 1280 and below, which is exactly where the
truncation being complained about was happening.
ROW WIDTH IS SHARED, NEVER INTRINSIC. An intermediate pass used width:max-content
to create the overflow. It did -- and sized every row to ITS OWN content, so a
row holding "Lao People's Democratic Republic" laid out wider than the row above
it. That is not a table but a stack of independent flex lines: screenshot-
verified ragged, with Country/Region/Language stepping left and right on
consecutive rows while the header, correctly aligned, disagreed with all of
them. The measurement said so too and was read past: scrollWidth was 969 at the
top of the list and 1051 after scrolling, i.e. the table's width depended on
which rows happened to be rendered. Head, rows and footer now share one explicit
width (summed floor as min-width, width:100%), so nothing is intrinsic and the
column grid holds at every scroll offset.
The sticky header stays pinned on the vertical axis only, which is correct: it
must travel sideways with the body or the labels detach from the columns they
name. The footer is a SIBLING of the scrollport, so it is translated in lockstep
from the scroll handler -- otherwise the population total slides out from under
its own column.
FULL SCREEN is CSS pseudo-fullscreen, not requestFullscreen(). The native API
promotes the element to the browser's top layer, detaching it from the cascade
this table is built on, and needs allow="fullscreen" to work in the iframe embed
this dashboard actually ships in. Pinning the wrapper with position:fixed keeps
the element where it is in the tree, so inherited tokens and ancestor rules keep
applying and only the two rules we mean to override are overridden. Esc exits.
The seven-row cap is released with max-height:none !important, and !important is
the honest tool rather than a specificity war: the first attempt lost 0-2-0 to
the shell's 0-3-0 selector and rendered a 900px box containing a 182px table --
the exact failure its own comment warned about. That shell rule is also being
rewritten concurrently, so matching its selector would break on the next edit.
library/components/map-controls/FullscreenButton.vue was read first and does not
fit: it resolves its target as the SHADOW HOST, so pointed here it would
fullscreen the whole dashboard rather than the table; it renders MapControlButton,
a circular control built to float over a canvas; and it depends on vue-i18n,
which this bundle does not use. Making it fit means adding a target prop and a
chromeless variant to a component with seven map consumers to serve one non-map
caller, so this stays local until a second table wants it.
Verified at 1280 and 1440, real browser, live data: headers complete with
text-overflow clip and no cell clipped; scrollWidth 867 vs clientWidth 718 with
the last column's left edge moving 1045 -> 896; rows recycle (abai-sungai ->
deaf-comorans) with the rendered count bounded at 14; zero ragged columns and
zero head-vs-row drift at every scroll offset; fullscreen 244 -> 900px with the
keyhole 182 -> 838 and 45 rows rendered, restoring exactly on exit.
check:fits-screen 17/17.
A "Composed of" panel now sits under the plugin title, above the live
preview. For a composite it lists one row per directly-nested child:
name, element tag, and a capability badge, each row a link to that
child's own entry. A leaf plugin shows no panel.
Composition is no longer navigation. The rail had carried a collapsible
folder per composite, which re-listed children that already had their
own home — people-groups-grid held six nav nodes, so there was no single
name to call it by when reporting a problem. The rail is back to one
node per plugin in its own capability tier (31 nodes, 31 manifest rows,
none repeated) and the relationship lives on the plugin's own page.
Depth is one. A nested composite shows as a single row and does not
expand its own children; its breakdown is one click away, on its page.
The badge reads the manifest's declared profile type, so retyping a
profile moves its badge with no code change here. A declared child that
is published nowhere is counted as "not published" beside the heading
rather than dropped in silence — big-picture names bp-research-header,
which is an internal part, not a plugin. The list caps its own height:
the layout's header row is auto-sized, so ten uncapped rows would have
taken height from the preview instead of overflowing.
Rows are buttons with descriptive aria-labels, reachable and activatable
by keyboard. No emoji glyph on a row: this box has no emoji font, so
every one rendered as an empty box, and the badge says it better.
Driver: "we are trying to reclaim height."
TABLE = SEVEN DATA ROWS, stated in ROWS rather than pixels. The old formula
derived the keyhole by subtraction (shell - map - bar - foot), so the row
count was whatever fell out -- measured 300px = 11 rows. Now
calc(var(--rd-table-rows,7) * var(--rd-table-row-h,26px)), so the promise is
the declaration: change the row height and seven rows stays seven rows.
His own arithmetic settles an earlier ambiguity (he had said both 77 and 7):
seven is DATA rows, excluding field, footer and table header.
HEADERS 137px -> 125px: topbar 52 -> 44, stats 30 -> 26, both now vars so a
host tunes them in one place instead of three grid-template-rows that must
agree.
CHARTS scroll sideways instead of wrapping. This is the third pass on that
trade and it resolves rather than reverses it: pass 1 was nowrap + ellipsis +
overflow-x HIDDEN, so labels were truncated and unrecoverable -- the defect
he rejected. This is nowrap + overflow-x AUTO, so the full label still exists
and is reachable. Legend cap 176 -> 120.
Measured at 1440x900: grid-template-rows 44/55/26/476/244, keyhole 182px,
exactly 7 data rows, shell 863 -> 849 with the map gaining 116px.
Driver: "the spacing between the rows is too thick, they take up way too much
height -- the padding should be decreased per row."
Row vertical padding 3px -> 1px, min-height 22px -> 18px, section header
padding 8px -> 5px.
Font-size is deliberately untouched at 12px. It is not a lever here: the
Driver previously rejected shrinking it ("super tiny text that cannot be
read"), and the note beside .fr-lbl records that the width for the count came
from the bar and the gutters for the same reason. Only whitespace moved.
min-height 18px still clears a 12px glyph at line-height 1.3, so no descender
is clipped.
Measured live at 1440x900 with four sections open, 69 rows rendered:
row height 24px -> 20px
computed padding 1px/1px, font-size 12px unchanged
rail content height 1966px -> 1424px (542px saved)
Driver ruling: "instead of the red, we need to use the grey scale from grey
to black." The greyscale preset was built earlier at his request as a
swappable alternative; this is him taking the swap.
One config key moves both the choropleth and its legend together, which is
why it was built as config rather than scattered hex. Ramp is
#e8e8e8 / #a9a9a9 / #6f6f6f / #3a3a3a / #060606.
Verified in the shipped bundle: colorRamp "greyscale", the five stops
present, sfc-parse passes.
Worth knowing for the follow-up: the colour research measured the ramp
change as roughly neutral for pin visibility (worst case 1.96 greyscale vs
1.84 red). The real fix for pin contrast is the lightness-conditional stroke
-- dark ring under a light pin, white under a dark one -- which lifts the
worst case to 4.51 and works on either ramp. Not done here; that is its own
change.
REGIONS WAS RENDERING AS A CAP-1. The regions-mfe composite (nested-elements-mfe
over regions-list + region-dashboards, with the shared Select View strip) had
shipped, but the shell entry still named the bare `regions-list` leaf -- the
cap-1 half -- so one flat list rendered and the composite sat unused. The cause
was in the shell, not the regions bundle. Compare the UPG-100 entry, which names
`upg-100-mfe` and is why that tab has tabs.
Measured in the shell after: Select View present, options "Regions List" /
"Region Dashboards".
DUPLICATE SHARE BUTTON. The map carried its own ShareButton while the composite
header already mounts one. Removed the map-level control here rather than in the
shared component, because sharing is per-map policy: Prayer Progress is the map
meant for embedding elsewhere and keeps its own button; a map only ever seen
inside a composite does not need one. Unused import dropped.
DOTS WERE OVERCORRECTED. An earlier pass raised z=1 radius 4 -> 6 to fix "hard to
see". Sound reasoning, wrong amount: it made this the largest-dotted map in the
set (all-people-groups is 2.2 at z=0). Now 5 at z=1, keeping a deliberate step
above the dense map without leaving the family; the halo carries visibility.
Gates: template-literal, sfc-parse, sri all pass.