DX Life's /pray at 1440 kept the pill at the viewport's bottom until the map's bottom came into view: the page
scrolls a container, not the document, so the document-only test saw a full-screen page and kept the fold.
pageScrolls now also walks out from the pill, across shadow roots, for an ancestor scroller whose content is
taller than it; a map wholly below or above the viewport is treated as on a scrolling page; and a lift is
cleared when the pill goes off screen, so a lift set while the map was half visible does not survive into the
next view. check:pin-hint-floor adds the inner-scroller, shadow-crossing and map-below-the-fold fixtures; its
self-test refuses 9 planted defects.
Comments in library/ and internal/ that quoted a person, attributed a decision to one, used first person,
named fleet agents or carried private host names now state the same technical fact in neutral words:
poster-render (including the comments it writes into generated posters), packet-render, the poster and
research scripts, PosterWall, DataTable, the legends, the layouts and the gates. The network-policy note
states the policy itself. Behaviour is unchanged: a few printed gate messages and reason labels drop an
agent name, the poster-render integrity pin follows its comment-only edit, and series.json changes only in
its _meta and _why notes. The historical poster design snapshots (library/scripts/posters/snapshots/),
which nothing reads, are removed.
On a partner home page the map is a block in a scrolling page. The clamp floored the pill at the viewport
fold, so a map whose bottom was below the fold at load had its pill lifted to mid-map, re-set by the settle
timers and resize observers, and left there after a scroll, then dropped. The fold now counts only on a
page that does not scroll (a full-screen pane or leaf, where a phone's browser bar can cover the bottom);
the legend sheet and the licence credit still lift the pill everywhere. The floor is a pure function
(pinHintFloor.js), and check:pin-hint-floor proves both cases; its self-test refuses 5 planted defects.
The pill takes touch-action:none. With the legend open it sits just above
the sheet's top edge, so a drag meant to resize the sheet often began on the
pill's lower edge and scrolled the pane instead. Taps still fire, so its
dismiss button keeps working.
- Two lines, each its own string, in a new catalogue area only PinHint names (line1 / line2 x 11,
taken from today's one-sentence text at each locale's own separator; that text is untouched).
- 44px tall like the ☰, 12px text, a smaller bob. Capped to the width left between two ☰ footprints
(--map-fab-beside), so it is centred with equal room and never reaches the ☰; with no ☰ it is
simply centred.
- In the ☰'s row: while the layout shows the ☰, the base is the ☰'s own 12px bottom, and the clamp
lifts it to 8px above the legend sheet, the gap the layout keeps for the ☰.
- It no longer reads --map-fab-clearance, which now carries the ☰'s scroll-tracked lift: inside the
map, beside the legend, the pill stays over it by itself, and positioned from that lift it moved
against the legend on every scroll.
A host may move the engine's credit to the TOP of the map (a phone layout with the sheet flush at the
bottom). creditFloor() would then have used a credit near y=10 as the floor, and syncAttribClearance()
would have reserved the whole map height (capped at 120px), so the pill lifted itself for nothing. Now
a credit in the upper half of the pill's layout parent is not counted: no floor, and the 20px default
clearance. Every credit is at the bottom today, so this changes nothing yet.
Every bundle rebuilt (PinHint ships through ProfileLoader), composites after their children, then the
tree and SRI.
- It is dismissed when the reader taps a pin, the gesture it teaches. The signals are
uiStore.selectedPeopleGroup (the maps that select through uiStore.selectPeopleGroup) or a
'dxmaps:pin-selected' event inside the map's root (a map with its own selection). It was left
standing over the detail card that the tap had opened. A pan still does not dismiss it.
- Wide, the pill reached the gallery's round menu button (bottom-left). Its bottom now takes the
larger of the credit clearance and --map-fab-clearance, which the layout publishes only while the
button is rendered.
- Every bundle rebuilt (PinHint ships through ProfileLoader), composites after their children, then
the tree and SRI.
- left:50% alone capped the absolutely placed pill at HALF the map, about 195px at 390, so the
sentence broke into four lines. width:max-content, with max-width 16px short of each edge, lets it
take the width it needs: at most two lines on a phone, never touching the sides.
- z-index 2147482000 -> 12. In the gallery, plugin content shares one stacking context with
VerticalTabsLayout's chrome (the menu button at 15, the drawer at 20, and 1000 while it is open on
a phone), so the pill painted over the open drawer. 12 keeps it above the map's own base layers
(canvas, scroll edges 2, control column 10) and below that chrome.
- The pill's lines are centred.
The pill's clearance above the licence credit is measured in syncAttribClearance,
and that runs again only when the credit RESIZES. On a phone the credit is also
MOVED, lifted above the legend bar, without resizing. The clearance then stays at a
stale value, and the pill covers the credit. MEASURED at 390, on the shared
browser: on DX Life's home page the pill covered 36px of the 37px credit with the
clearance at 43px; on the leaf in ar, 36px.
clampIntoView now takes the credit's measured top as a floor, next to the fold and
the phone legend sheet. It already runs from the settle timers (1.2, 3 and 6 s)
and from the sheet and host observers, so it reads where the credit is, whatever
the clearance says.
Measured on a served tree, Prayer Progress rebuilt with this and the 120px cap,
at 390: the leaf and the gallery tab, in en and ar.
- before (development's tip): overlaps of 7, 36, 7 and 7px;
- after: 0px in all 4, with the pill's bottom 13px above the credit's top.
Library only. Every ProfileLoader consumer needs a rebuild to ship it.
The clearance is the strip from the credit's top to the box's bottom edge, capped
so a mis-measure cannot throw the pill up the map. The cap was 72px. On a 390px
phone MapLibre's credit wraps to two lines, and on the leaf it sits above the
bottom bar, 90px up. With the selector now finding it, the clearance hit the cap
and the pill still overlapped the credit by 6px, in en and ar (was 35px). The cap
becomes 120px: the measured need fits, and a runaway is still bounded.
Library only. Every ProfileLoader consumer needs a rebuild to ship it.
syncAttribClearance() measured the strip it must clear from Mapbox's credit
(.mapboxgl-ctrl-attrib) and our own (.doxa-attribution), never from MapLibre's
(.maplibregl-ctrl-attrib). MapLibre is the default engine. With no match, the
clearance stayed at the 20px fallback. On a phone the MapLibre credit wraps to two
lines, and the "Tap a coloured dot" pill covered it: a licence-required credit
hidden on every map that mounts the hint.
Measured at 390x844 on DX Life's home page (Prayer Progress), on the shared
browser: MapLibre en and ar, pill over the credit by 35px with the clearance at
20px; Mapbox, clearance 72px and 0px overlap.
The selector now also takes .maplibregl-ctrl-bottom-right .maplibregl-ctrl-attrib
and the MapLibre form of our own credit line. The existing measure (the strip from
the credit's top to the box's bottom) and its ResizeObserver then apply unchanged.
Proved on the live DOM, inside the map's shadow root: on MapLibre the old selector
finds nothing and the new one finds the credit; on Mapbox both find it.
Library only. Every ProfileLoader consumer needs a rebuild to ship it.
Every reference to the Driver in library/ is reworded: the engineering reason
stays, the speaker and the quote go, and a decision still open reads
"provisional, pending a product decision". Comments only: each changed file
is identical after stripping comments (JavaScript, template and CSS, including
CSS comments inside the style strings shadow-DOM components inject).
Those style-string comments do ship, so several bundles change by exactly
those comment bytes.
rega printed a full-bleed map leaf on fc93c97e4 and the release worked — 1 page,
canvas 1440x900, map drawn, floor held. Two things printed that have no business
on paper, both found only because someone looked at the sheet.
The control column — zoom in, zoom out, locate, fullscreen, share — printed top
right, over the map it exists to operate. Every one is a pointer affordance
with nothing to do on a page. The engine's own control groups go with it, for
maps that place them outside this toolbar.
The pin hint printed across the bottom: "Tap a coloured dot — someone still
needs prayer". It is an instruction for a finger, addressed to a reader who is
now holding a sheet of paper. Worse than useless there, because it sits under
the map like a caption and says something untrue about it.
The ATTRIBUTION is deliberately left alone. It looks like chrome and it is not:
it is a licence term of the basemap, and a printed map that drops it is a
licence problem, not a tidier page. Only the attribution TOGGLE goes, which is
the button that expands it.
PinHint self-gated on any <canvas>, so the Wall's own renderer got a tap-a-dot coach-mark over its
dialogs; it now matches only MapLibre and Mapbox canvases. The "?" sat bottom-right, over the map
attribution on several plugins and the zoom-out button on one; map plugins use the corners, so it
moves to the top centre.
On upg-100-mfe at phone width the map pane is stacked (position:absolute; inset:0) BEHIND Prayer Resources,
so the IntersectionObserver, which measures geometry and not occlusion, still reported the map as in view
and the pill floated over the posters (the Driver's report; flagged by repo before the README retake).
The contract: a composite marks an inactive view inert (or hidden / aria-hidden="true"), which is also what
assistive technology needs. The pill hides while any ancestor of its map carries one of those, walking up
through shadow roots, and a MutationObserver on that chain brings it back when the reader picks the map.
The five are NOT one shape, and treating them as one would have changed what
readers see. Measured before touching them:
ApproxPinNote, FilterDropdown fall back to a literal map
SemanticTreeLegend, PosterWall fall back to humanizeKey() — 'legend.columns.name' renders 'Name'
PinHint falls back to a one-key map
SemanticTreeLegend, PinHint also capture i18n.te (used 3x and 1x)
PosterWall also captures i18n.locale
So useOptionalT grew an optional `fallback` hook, and useOptionalI18n was added
for the callers that need te and locale. Every site passes the fallback it
already had, so not one rendered string changes. Routing them through the
catalogue instead would have altered what seven bare-host bundles print, and —
because tStatic honours the runtime locale — would have started translating
strings that are English today. That may be an improvement; it is not this
commit's to make. Changing a default is not a migration.
te keeps its existing contract too: FALSE on a bare host, never "true because the
catalogue has it". PinHint and SemanticTreeLegend both record why — te is how
their callers know to use their own defaults.
THE CHECK NOW LIVES ON MAIN, which is the point of this commit as much as the
migration is. dx-shepherd: the 18-assertion version was in _qa-cycle, so it would
never have run again. internal/check-optional-t-equivalence.mjs is registered as
check:optional-t-equivalence — script line, //scripts note and gates:post token,
all inserted by hand rather than by re-serialising package.json. It carries 46
assertions: the helper's contract across all three host states, a branch-for-
branch equivalence against the old hand-written code, and a PER-SITE REGISTER
that every later C4 wave appends a row to. Negative-tested from outside: strip one
site's own fallback and it fails naming that site.
check:library-index went red on the new composable and is green again after
`bun run library-index`. That is the staleness gate working, not a flake.
It gated on a map canvas EXISTING. A composite (pane-wrapper) keeps the map pane mounted while the reader
scrolls to another pane, so on UPG-100's phone view the pill was clamped into view over the poster pane,
where there is no map to tap (Driver, README picture; traced by upg100-plugin and repo).
An IntersectionObserver on the map box now decides: under a fifth of the map in view hides the pill, and
scrolling back shows it again. Only the reader's close button dismisses it for the visit, as before. Without
IntersectionObserver the old behaviour stands.
Their 24px floor sat only behind @media (pointer:coarse), so a desktop mouse still got the small target
(regions' finding; the phone probe runs with touch, so it measured the passing branch). 2.5.8 is a target-
size criterion, not a touch one. .mobile-collapse-caret (22x20 in a 20+4px slot), .stl-hdr-num (a 9px line
box), .rm-link-btn (11px, padding 0) and .pin-hint-x (16px) each get a 24x24 absolutely-positioned ::after
hit area at every width (the f29b8151 pattern). It adds a target without a box, so no row or slot
geometry moves; the coarse query still sizes the box on touch.
PosterWall's .pbtn, .pstock and .tape-h are HELD: their spacing is the Driver's sign-off.
The desktop verdict (2.5.8's spacing exception, an ancestor with overflow:hidden) needs a rendered layout,
so this is a needs-eyes item for rega.
Second of the shared-library batches, in the shepherd's order: ShareButton,
DataTable, LegendTools, and PinHint's dismiss label.
★ THE ONE THAT MATTERS MOST IS THE PARAGRAPH A PARTNER READS. ShareButton's
embed tab explains where to paste the snippet — "WordPress, Squarespace, Wix, or
any site that accepts HTML widgets" — and it was English on every locale of a
product whose whole pitch is that a partner can drop it into their own page. The
two tab labels beside it likewise.
Also wired: the data table's CSV and JSON buttons, its row-detail hint and
dialog label; the legend's Clusters button and the Minimum Spanning Tree option
with its explanation; and the pin hint's Dismiss.
★ SIX SITES NEEDED NO NEW WORDING AT ALL — they already had translations in all
eleven locales and were still typed into the template: four "Close" buttons
(buttons.close) across PosterDialog, DataTable, MapControlMenu and the QA
corner, "Expand legend" (aria.expandLegend) and PosterWall's "people groups".
Those are the cheapest defect in the whole catalogue: the work was done, the
reader just never saw it.
CSV and JSON are keyed but identical in all eleven, like DPI in the poster
batch. A format acronym is not an untranslated string; the key exists so a
language that does translate it can, without anyone editing a component.
★ LIBRARY CHANGE — studio's to land under rule 5B. I have NOT rebuilt anything.
Until it lands, every string falls back to the English it reads today.
Measured with the source audit: shared-library strings with no catalogue entry
go 32 → 21, and the has-a-key-but-hardcoded class goes 6 → 2. Remaining, in
order: QaCorner (10, a developer tool), SemanticTreeLegend (4), PosterPreview,
PosterWall and VerticalTabsLayout (2 each).
Measured all seven production maps at 390x844 with touch emulation. 195 controls
sat below WCAG 2.5.8's AA minimum of 24x24 CSS px, spread across five maps -- but
they were not five owners' bugs. Grouped by class, 195 failures were ten classes,
and 119 of them lived in library/: the same PosterWall, PinHint, LegendMobile,
SemanticTreeLegend and ResearchMapFilterPanel controls repeated down a list and
across four maps. Routing the per-map counts would have filed hundreds of defects
against owners who do not own the geometry.
Raised the shared controls, scoped to (pointer:coarse). 2.5.8 is a touch
ergonomics rule, and these components sit on maps this change does not own: a
finger gets the bigger target, the mouse keeps the density those owners designed.
The poster row is the reason this matters -- its height was tuned by eye against
the card's blue outline, and growing it for a pointer that needs no help would
have spent the artwork clearance to fix nothing.
min-height states the 24 instead of deriving it. Growing the padding first
MEASURED 23.7px and 23.0px -- under by a third of a pixel and by one, because the
height is padding plus a line box the renderer computes from a 10.5px font. The
declared minimum also survives a font swap or a locale with taller glyphs.
The header tape now self-centres like the footer. It hung on top:-11px, half of
its then-fixed 22px height, which silently becomes wrong the moment the height
changes -- as it just did. top:0 with translate(-50%,-50%) sits on the border at
any height, the mechanism the footer already used, and one less number to keep in
sync.
Two exceptions in 2.5.8 are now encoded in the probe rather than remembered.
Inline: 48 .uupg__country-link "failures" are an <a> inside a paragraph, computed
display:inline, sized by the line-height of the text around it -- the spec exempts
them and the probe now skips display:inline, counting them separately. Equivalent:
a .lpb-seg progress segment is 10px tall inside a track that clips overflow, so no
hit-area extender can reach 24px; it resolves to a tree node and calls the same
selectNode a full-width legend row calls.
195 failures on five maps -> 4 on two. UPG-100 alone went 137 -> 0. The poster
tape gate and per-row persistence still pass.
The shepherd reported a P0 I caused: db36397b made library FilterDropdown call
useI18n() unconditionally, which throws "Need to install with `app.use`" on any
host that never installed vue-i18n. people-groups-grid deliberately has none, so
the grid rendered blank standalone, in the studio and in every composite nesting
it -- one of the seven production plugins going to DX. Studio fixed that
instance in 6603dc17.
That fix was NOT on my branch. 6603dc17 was not an ancestor of my HEAD and my
tree still carried the unguarded call, so my next push would have reverted it.
Merged before doing anything else.
PinHint carried the same unguarded call, added by me in 9ba6ad7a. Guarded the
same way: host translations when present, the English literal otherwise, with
te() answering false so callers fall through to their own defaults.
The gate the shepherd asked for is _chk-library-i18n-optional.mjs. It scans
library/components for useI18n() calls that are not behind a guard, and it
separates LIVE from LATENT, because a wall of identical failures gets ignored
while a list of one gets fixed: it computes which bundles install no i18n and
which of those actually import each offender.
Measured: 17 library components still call useI18n() unguarded, 7 bundles
install no i18n (big-picture, doxa-maps-spa, doxa-vision-card, golden-path-check,
people-groups-grid, plugin-studio, stack-demo), and LIVE is currently none --
no bare host renders an unguarded component today. The 17 are debt, not safety.
They are left for their owners rather than rewritten from here, since library/
reaches every bundle.
Two of my own errors on the way, both caught before they reached anyone. I first
reported 14 bare bundles, because I grepped for createI18n when the install is
usually createAppI18n from @map/i18n -- country-posters does install it. And the
gate first reported PeopleGroupDetail as LIVE in people-groups-grid on the
strength of a COMMENT mentioning it: a substring match cannot tell an import from
prose, which is precisely the defect I reported in check:shared-reached a day
earlier and then rebuilt myself. It now strips comments and requires a real
import statement or an element tag.
TWO SHARED-COMPONENT DEFECTS, both routed to me, both a step away from where
the routing pointed.
THE 14 NAMELESS BUTTONS ARE ONE COMPONENT. check:a11y-button-names reported 14
visible buttons with no accessible name across two country-posters leaves. The
routing named countries-map.vue, which contains exactly one <button> and it has
visible text — the gate prints the LEAF, so the file was an inference. Running
the gate and then a probe that prints what a developer can grep for — class,
innerHTML, box size, the custom-element host chain — named the real one:
library/components/legends/SemanticTreeLegend.vue:1228, seven
<button class="stl-caret">, 14x14, a 9x9 chevron and nothing else. The 14 is
those 7 counted twice, because country-posters-mfe composes countries-map;
worth saying, or the next reader hunts a second defect that does not exist.
"(no title either)" in the gate's output is the diagnostic half: it rules out
the title-only control this gate was built for, and it rules out Mapbox's own
controls, which ship aria-labels. These were ours and they were empty —
assistive technology said "button" once per expandable branch.
Fixed at the source, which is why it lives in library/: an aria-label naming
the branch the caret discloses, and aria-expanded, because a name tells a
screen-reader user what the control is for and still not whether the branch is
open. Three keys across eleven locales rather than a hardcoded label. And the
same defect one element over, latent: .stl-x-chip carried only a `title` —
precisely what this gate exists to reject — and hardcoded English besides. It
escaped the sweep because it renders only while a row is selected.
Seven surfaces render that legend, so one fix reaches all of them. The
artifacts are per-bundle though: country-posters is rebuilt here because its
cards are routed to me, and my own bundle with it. The other five stay stale
until their owners rebuild, and I have not touched them.
AND THE PIN HINT HAD NEVER BEEN TRANSLATED. The ask was to audit eleven locales
for a stale "red dot". There is nothing stale, because there is nothing: no
locale file contains "Tap a", "pinHint" or "coloured dot"; PinHint.vue imports
no useI18n; the sentence is a JavaScript string literal. So every map that
renders the hint has been showing an English sentence to readers in Arabic,
Russian and Chinese.
Which is worth noticing about the fix that came before it. The colour half was
found and fixed carefully — the comment above it records the Driver reporting it
five or more times, and why a shared component must not name a colour it cannot
know. All correct. Nobody asked whether the sentence was in the catalogue. Same
shape as the Russian headings next door: wrong only for readers who do not file
bug reports in English, and so invisible to everyone who could.
Eleven locales, every one saying coloured and never red. The pinHintLabel
override still comes first, so a profile that does want to name its colour can.
And the key falls back to the English sentence when a locale lacks it, so a
missing translation degrades to today's behaviour rather than rendering the
literal "messages.pinHint.tapDot" — the failure mode of a naive t() swap.
Flagged and not taken, because they are shared components with owners:
FilterDropdown renders a hardcoded English "Clear selection" and "No options
found" (the latter already exists translated in the regions catalogue, so the
component is ignoring a string that is right there), and PosterWall's export
modal renders its reassurance line untranslated.
MEASURED at 390x844 on doxa-research-map: the hint pill sat at y=700 and the open
legend sheet begins at y=603, so "Tap a coloured dot — someone still needs
prayer" printed straight across the Progress rows. 1,418 Needs Prayer and the
Full Prayer Coverage line were unreadable underneath it, in the phone screenshot
the README showcases.
Nothing was clipped, which is why the existing below-the-fold clamp had nothing
to say: the pill was fully visible and fully in the way. So the sheet becomes a
FLOOR, exactly as the attribution strip already is — this component has always
treated the credit line as something to clear rather than cover, and the sheet is
the same kind of obstacle. If the pill would cross the sheet's top edge, it lifts
by the overlap; only when the two really intersect horizontally, and only ever
upward, so a surface with no sheet is unchanged.
TWO THINGS THE FIRST ATTEMPT GOT WRONG, both found by re-measuring rather than by
reading the diff:
· The sheet does not exist when the pill mounts — LegendMobile renders after the
map's first canvas. A mount-time querySelector found nothing, so no observer
was bound and the clamp never ran again: the pill moved 0px and the "fix" was
a no-op that looked plausible. The observer is now bound lazily by the first
clamp that SEES a sheet, with three settle re-measures after mount to make
sure one of them does.
· The sheet also changes height as the reader drags or collapses it, and each
height is a different floor. The existing host observer watches the MAP, which
a sheet drag does not resize, so the sheet needed its own.
MEASURED AFTER — 390, 360 and 414px wide: no overlap between the pill and any
painting part of the sheet; screenshot read at 390, where Progress now shows 689
/ 136.7M, 1,418 / 69.6M and Full Prayer Coverage in full. Desktop 1280x900
unchanged: pill still at the map's bottom edge, no lift applied. The two other
hint consumers (country-posters-mfe, regions-mfe) show no overlap either.
One probe note worth keeping: .legend-sheet-root spans the whole map with
pointer-events:none and paints nothing, so an overlap check that counts it reports
a collision no reader can see. The obstacle is the sheet, not the box it lives in.
The default label was "Tap a red dot -- someone still needs prayer" and it
shipped on every map: pinHintLabel is an override no profile has ever set,
and pinHint:false is an opt-out no profile has ever used (verified: zero
occurrences of either across plugin-designs/).
So cities-with-internet and gospel-resources, which have no red dots,
invited readers to tap one; and UPG-100, whose dots are a four-colour
reached/unreached scale, named the wrong colour. A hint describing
something the reader cannot see makes a working map look broken.
Fixed at the default rather than per profile, because the defect was a
default asserting more than it could know. A shared component cannot know
what colour a given map's dots are, so it no longer claims one. Profiles
that do want to name a colour still can, via pinHintLabel.
Reported 5+ times by the Driver.
The cap-4 composite shipped the coach-mark sliced through the middle of its sentence.
Measured, same component and viewport: the leaf renders the pill at bottom 864 of 900,
the composite at 919 — clipped by 17px, because the composite adds a ~53px header above
its nested child's stage and findMapBox() finds no positioned container to re-parent into.
The component's header promises that case degrades to "stay put — a degraded anchor, never
a crash". A graceful degradation that degrades to invisible is a failure, not a fallback,
so the clamp measures the rendered rect and only ever lifts a pill that is actually clipped.
Two wrong attempts are recorded in the code because the reasons matter. Setting
style.transform would have destroyed the wrapper's translateX(-50%) centring, and the
pill's computed transform also carries the bob animation, so a computed reading is never
just my offset. Adjusting the bottom offset composes with the attribution clearance
instead of fighting it. Clamping only at nextTick also missed: the composite's layout has
not settled then, so the clamp now runs from syncAttribClearance, which already re-fires
from a ResizeObserver, plus a second observer on the pill's own layout parent.
Verified on six surfaces: the composite lifts 30px to bottom 888, every other surface
reports lift 0 and is byte-identical in behaviour, and all six keep centreDelta 0.
- Compile-time gate __DOXA_QA__ (default off): the QA corner and every string
it carries are dead-code-eliminated from public bundles; platform builds
opt in with DOXA_QA=1. Shell QA becomes ?qa=1 opt-in. Audit: zero platform
strings across all bundles + shell.
- One Mapbox wordmark per page (first live map claims it; released on destroy).
- Coach-mark points at the need: 'Tap a red dot — someone still needs prayer.'
- Pray-click glow: locally-prayed groups render as Has Prayer for that visitor
(localStorage, 7-day TTL) — lasting glow comes from the real signup via the API.
- Staging forge gains 'copy embed': snippet built at click time from
location.href, so it carries the FULL URL of whatever host serves the tree.
library/components/hints/PinHint.vue — a tasteful pill ('Tap a glowing dot — meet
a people group') that teaches the one gesture behind every conversion: tap a pin
-> detail card -> Pray (utm-attributed). Self-gates on a real map canvas in the
shadow root (lists/tables never see it), shows once per session, dismisses on
first map touch / x / 14s, honors prefers-reduced-motion, dark-aware. Default ON;
opt-out profile-config pinHint:false; label override pinHintLabel. Wired in
ProfileLoader; visually verified live.