The all-regions map drew its pins at their zoom-2 size below zoom 2, which is where a phone's globe
sits, so they read far too large. They now shrink below zoom 2 (unchanged from zoom 2 up, so desktop
keeps them), and an invisible hit twin with the old radii takes the click and the cursor, so a tap
selects exactly what it did before.
The poster dialog's default is 24x36 in at 300 dpi, a 78-megapixel render: minutes on a desktop, and
beyond a phone's canvas limit, so "Export poster" appeared to do nothing on a phone. On a phone it now
opens on Letter at 150 dpi, and sizes or resolutions over the phone canvas limit are disabled with the
reason shown, in every locale. Desktop is unchanged.
The map's attribution control is a round (i) as well, and two identical glyphs on one map read as the
same thing. Both copies of the glyph change: MapInfoButton (the map control and the dashboard header
button) and MapInfo's floating fallback button.
The panel is role=tabpanel, labelled by the selected tab; each tab names it with aria-controls. Only the
selected tab is in the tab order, and Left/Right switch tabs with focus following.
MapInfo gains a `strategyKey` prop: a catalogue key prefix whose .bottom is the bottom line and .p1, .p2 … the
points, read in order until the first missing one (the catalogue decides how many). The popup shows the intro
(the lead), then:
- a map with examples AND a strategy gets two tabs, Examples first, then Strategy (44px targets, reset to
Examples on every open);
- a map with only a strategy goes straight to it, under a Strategy heading;
- a map with neither is unchanged.
The takeaway is not shown beside a strategy, which supersedes it. Labels come from mapInfo.tabExamples and
mapInfo.tabStrategy. Prayer Progress's own info (for embeds) names its strategy key too.
The shared region/country map lifted its bottom corners by the gallery's round-button clearance whenever
that button rendered. That suits a map that fills the screen, but the Regions maps sit in the page flow,
so on a phone the credit floated 80-100 px up the map. The lift is removed; the full-height maps keep
their own clearance in the mobile legend.
Two phone legend fixes in LegendMobile.
The rule that moves the engine's bottom corners to the top lives in CSS injected
once into the host's shadow root and never removed. Cities with Internet mounts
the sheet only for its phone people-group card, so after "back to legend" its
credit stayed at the top, under the map's own legend (measured at 390: 1 of 11
points visible). The move is now scoped to a class on the host that is set while
at least one LegendMobile is mounted, counted so the desktop legend's hidden twin
cannot clear it early.
A drag on the sheet's handle fell through to whatever scrolls behind the map:
on UPG-100's Map view a downward drag on the collapsed sheet scrolled its pane
column 655px back to the list. The handle (the drag strip, the title rows, and
the whole sheet while collapsed) now has touch-action:none, and a collapsed sheet
holds the gesture as a backstop. A tap still reaches the caret.
At two posters per row on a phone (a ~162px card) the print row wraps to
three lines, and because it is a sticker pinned over the banner's bottom
edge that grows downward, it hung about 45px below the card's white box.
Each card is now fitted to its own row: a ResizeObserver on every .pfs
writes --pfs-room on its card when the row hangs past the white box (the
part below the banner, plus 8px of daylight, that the 26px bottom padding
does not cover). A row that fits writes 0, so every card that already
contained its row is unchanged.
check:poster-card-fits asserts it: a print row may not end below its own
card (below-card N). Rebuilt: my-upg-100-list, country-posters,
regions-dashboard; the tree and SRI regenerated.
Every other caller of the in-place print moves onto the phone path the poster wall already uses:
printImageBlob (the map's poster/download dialog and research-map) prints the page itself on a phone,
and the gospel tree, the prayer-walk planner and the wall of people groups call printDocument. A caller
without its own progress dialog gets a shared Print bar when the tap has expired, labelled through the
caller's own translator. Desktop keeps the in-place frame unchanged. Source only; the bundles are rebuilt
when it lands.
At the top of a phone map the credit shares the edge with the map's control
column, and at 10px it took two lines. It is now 8.5px, capped 44px short of
the column (35px wide at 390), which fits the whole MapLibre licence text on one
line (294px against about 302px of room, measured at 390). It still wraps rather
than clips, so a longer credit shows whole on two lines instead of losing terms.
Every bottom placement of the engine's credit failed a reader on a phone: under
the open sheet it was covered, lifted above the sheet it floated mid-map, and
in a strip below the sheet it pushed the legend off the map's bottom edge. On a
phone both engines' bottom corners now move to the top corner on the start
side, opposite the map's own control column (left; right in RTL), and the sheet
sits flush at the map's bottom, starting beside the round menu button
(--map-fab-beside) so the button covers none of it. The move is !important so a
plugin's own lift of its bottom corners cannot push the credit off the top.
Desktop is unchanged.
On a phone there is no room for "The dot marks where this people is found, not
a town or a boundary" beside the legend, so the caption chip under the sheet is
gone: the sentence shows in no resting state (collapsed, or open with nothing
selected). A tapped dot's detail view keeps its note inside the sheet, where it
explains shared dots, and the tree's own copy stays off. The credit strip under
the sheet is the bottom controls alone again. Desktop is unchanged.
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.
On a partner page or a standalone preset page the ⓘ control rendered nothing, because only the
gallery answered it and the ⓘ's text lived in the gallery's settings. Now the map carries its info
(plugin-designs/doxa-simple-map/src/mapInfo.js) and a host only adds placement:
- MapInfoButton gains an opt-in `standalone` prop. When no host answers and the map carries info, it
asks the map for a popup of its own (the 'standalone' event). If a real host appears later, the
map's popup is dropped and the host takes over. Never inside the gallery (its panes carry
data-doxa-gallery): there the gallery alone decides, its own info:false included.
- MapInfo gains `popupOnly` (with trigger 'control'): no floating button, since the map's control is
the only button; it answers that control from inside the map, points at it once per page load,
and opens the same popup (the bottom sheet on phones). It announces itself as local, so the
control can tell it from a real host.
- Prayer Progress mounts that popup beside its control column, only when asked.
Per preset: the text describes the prayer map, so a map that shows the Prayer tab has info (the full
profile and the prayer preset); the engagement-only and adoption-only presets have none and show no
ⓘ, as before. A host's profile-config info:false removes the ⓘ. Every other MapInfoButton consumer
is unchanged (standalone is off by default).
doxa-simple-map grows by 20,590 bytes raw (5,699 gzipped), for compiling MapInfo. Rebuilt
doxa-simple-map and doxa-maps-spa, then tree + 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.
Every poster, prayer list and card deck printed from a hidden iframe (openPrintFrame), and phone browsers
do not honour a print() inside a subframe: no print dialog ever opened on a phone. printDocument() keeps
that iframe path unchanged on desktop; on a phone or tablet it mounts the document into the page under a
print-only stylesheet that hides everything else, and calls the page's own window.print(). When preparing
the document outlasted the tap's activation, the wall's progress dialog shows a Print button so the print
comes from a fresh tap. The poster wall (Regions, UPG-100, Country Posters) is the first user.
- 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.
On phones (up to 767px) the "What can this map do?" panel opened as a full-screen page: a few
lines of text at the top of an empty white screen, reading as a new page rather than a panel.
It is now a bottom sheet:
- it sizes to its content, up to 70% of the small viewport (svh, so a collapsing URL bar does not
resize it), and scrolls inside, with its scroll kept to itself;
- the close button is 44px and stays in view in the sheet's header;
- the dimmed map above it stays visible, and a tap there closes the panel, as Escape does.
Desktop is unchanged. Rebuilt doxa-maps-spa (the only bundle that compiles MapInfo.vue), then
tree + SRI.
Every map whose ⓘ is its own control (MapInfo trigger 'control') now gets the same pointer:
it shows the first time that control is on screen in a page load, and goes for the rest of the
load once the reader opens the popup or leaves the tab. A full reload shows it again. This is
page-load memory, never storage, so the popup offers no checkbox in control mode: the "Keep
tooltip turned on" box and its string (all 11 locales) are removed, along with the pointerKeep
prop that selected this model per tab. Floating-mode maps keep their stored model and their
"don't point again" box, unchanged.
Leaving the tab is read from the pane (hidden or inert), on the control's intersection change
and the existing 1.5 s measurement tick.
Rebuilt doxa-simple-map (it ships the mapInfo catalogue) and doxa-maps-spa, then tree + SRI.
Prayer Progress draws its control column only once the style and the people-group data are in,
which on a slow phone can take longer than any fixed wait, and then the gallery's floating ⓘ
appeared and was swapped for the control. Now the map announces at mount that a control is on its
way ('dxmaps:mapinfo-pending'), and MapInfo waits for the map instead of a clock.
- A known failure (the map reports an error) or an unmount before the map is ready withdraws the
announcement, and the floating ⓘ shows at once.
- While a control is pending, the 30 s wait becomes a 90 s backstop (time on screen), so an
unreported failure still gets a floating ⓘ.
- A map that never announces anything keeps the 30 s wait, and pages with no MapInfo are unchanged.
Rebuilt doxa-simple-map and doxa-maps-spa, then tree + SRI.
The dashboard header gains an info button ("What can this map do for you?") to
the left of Share. Both are rounded squares at the vertical tabs' radius
(--vtl-radius), matching the app's own roundedness; map controls stay round.
Built as a reusable, parameterized header-button form so other dashboards adopt
it by prop, not by copy:
- MapControlButton: new shape="rounded-square" (border-radius: var(--vtl-radius,8px)).
- ShareButton / MapInfoButton: a shape prop forwarded to MapControlButton;
ShareButton also squares its rotating ring so a round ring never wraps a square.
Defaults stay 'round' — every existing consumer is unchanged in behaviour.
- research-dashboard DashboardTopBar mounts the ⓘ and Share as one header-button
group, sized to the bar with the 44px tap target kept for both. DOM order
[ⓘ, Share] puts ⓘ at the logical start: left in English, right of Share in
Arabic (the flex row reverses under rtl).
- doxa-maps-spa dashboard-plus-grid tab -> trigger:'control', so the tab's
MapInfo is driven by the header ⓘ and the gallery's floating ⓘ is suppressed:
exactly one ⓘ, fail-open. No new strings (reuses mapInfo.open, 11 locales).
The control column goes back to the library's round, spaced buttons at every width; the square
grouped card read too compact, on phones most of all. The ⓘ stays the top control.
In control mode, MapInfo no longer shows its floating button before the map's control has
announced itself. The map draws its column only once it is ready (style and people-group data),
so the floating ⓘ appeared for a moment and was then replaced by the control. The wait is bounded
at 30 s of time on screen, counted only while the pane is visible and not inert: measured column
arrival times run from 7 to 19 s on a local serve, so a shorter wait expired inside a normal load
and flashed late. If no control arrives, the floating button shows, so there is still always a way
to open the popup. Once a control has announced itself, measurement alone decides, as before.
MapInfo's plain <script> block (the page-load pointer memory) now sits above <script setup>, so
check:setup-order no longer reads it as used before its definition; its explanation moves into
<script setup> so the library index keeps the component's own description. Compiled output is
unchanged by the move.
Rebuilt doxa-simple-map and doxa-maps-spa (the only bundle that compiles MapInfo.vue), then
tree + SRI.
- pointerKeep maps (Prayer Progress only) point ONCE PER PAGE LOAD: the pointer shows the first time
the map is on screen in a load. Opening the popup hides it for the rest of that load, and the next
full load shows it again. The memory is a module-scope Set (pointerDoneThisLoad), because the gallery
remounts a heavy tab's MapInfo on every switch back. It is marked when the pointer is on screen, not
on mount. Nothing about it is persisted any more (no seen write in show() or hide()).
- The keep box (still stored, per map) keeps the pointer after the popup closes and on every
appearance. A page-load pointer ignores the every-map off switch, which is not offered beside it.
- The label is "Keep tooltip turned on" (en), with de retuned to match; the other nine already say
"keep ... on".
- Rebuilt doxa-simple-map and doxa-maps-spa, then tree and fix:sri.
In the detail view a thumb on the content resized the sheet whenever the
content could not scroll that way, so reading the list moved the sheet. Only
the handle (the drag strip and the title row) starts a resize now; below it
the content scrolls, in every mode.
The sheet's list gets overscroll-behavior: contain (the list only, not the
pane), so a fling that reaches its end stops there instead of scrolling the
page behind the map.
The open state was a fixed 30% of the map: a three-row legend sat in a mostly
empty sheet, and a long tree scrolled inside one. At rest in the open state the
sheet now takes its content's height, capped at 45% of the map. A drag still
sets an explicit height; the collapsed and fully open states are unchanged. The
detail view's mini image follows the open sheet's real top edge.
The start state was a race. Every map's own mount code means collapsed on a
phone, but uiStore.init() measures the breakpoint in a ResizeObserver whose
first callback runs after mount, so Simple Research read the 'desktop' default
and started open on every load at 390, while Prayer Progress started
collapsed. The sheet now applies the collapsed start state itself, once, the
first time it sees the phone breakpoint, unless a people group is selected.
The open sheet covered the licence credit and the Mapbox wordmark (measured at
390 on Simple Research: the top element at both was the sheet's content). The
bottom corners now stay at the map's bottom edge and the sheet sits above a
strip measured from the real boxes of both engines' bottom controls.
The approximate-location caption sits in that strip, under the sheet. It shows
once whether the sheet is open or collapsed (it was 0 when collapsed), and the
pin-hint pill, which lifts itself clear of the sheet's top, never lands on it.
- Prayer Progress's column becomes the engine's square grouped card (controls="square"). Its first
control is MapInfoButton, which opens the gallery's MapInfo for the tab. The gallery's tabInfo
'prayer-map' entry now uses trigger 'control', so the floating button hides while that control is
on screen, and shows again (fail-open) whenever it is not.
- MapInfo, control mode: the first-visit pointer now points at the control. Its place is measured
against the pane (which scrolls) and goes on the control's physical inner side, so an Arabic page,
whose column mirrors to the left, gets the pointer on the right with the arrow reversed. It shows at
every width: the under-1200px rule was measured for the floating pointer crossing each tab's top
strip, and does not apply here.
- MapInfo, pointerKeep (prayer-map only): the popup offers "Keep tooltip activated" instead of the
every-map "don't point again" box. It is off by default, and opening the popup retires the pointer.
Checked, the pointer keeps showing on every load. It is stored per map.
- mapInfo.keepPointer in all 11 locales.
- Rebuilt doxa-simple-map and doxa-maps-spa, then tree and fix:sri.
A v-show-hidden button is still a child, so it took the square toolbar's
:last-child corners (MapToolbar controls="square") from the last button a
reader can see, on every page where no host answers (a standalone leaf, a
partner page). Found by exp reading the bridge.
The control now asks from the toolbar's own element (MapToolbar provides it
as mapToolbar.el), or from its placeholder's parent, and renders its button
only once a host has answered (v-if). The rendered button then announces
itself, so the host measures and focuses the real element.
The "what can this map do for you?" button can now live IN a map's control
column instead of floating over it. This is the bridge only: nothing changes
until a map adds the control and its gallery tab sets trigger: 'control'.
- map-controls/MapInfoButton.vue (new): a MapControlButton with the info
icon and the localized mapInfo.open label. Clicking it sends a bubbling,
composed 'dxmaps:mapinfo-open' event out of the plugin's shadow root to
the gallery pane. On mount it asks 'dxmaps:mapinfo-control' and shows
ONLY if a MapInfo on the same pane answers (ack), so a standalone leaf or
a partner page never shows a button that opens nothing. A MapInfo that
mounts later broadcasts 'dxmaps:mapinfo-host' and the button asks again.
- info/MapInfo.vue: new prop trigger, 'floating' (default, unchanged) |
'control'. In control mode it listens on its own pane: it acts only on
events whose composedPath contains the pane, and stops them there. Its own
floating button and pointer hide ONLY while the announced control is
MEASURED usable: connected, a non-zero on-screen rect, and in no
inert/aria-hidden subtree. It is re-measured by IntersectionObserver and
ResizeObserver plus a 1.5 s fallback timer, never inferred from an unmount
event. Escape and close return focus to the control when it is on screen,
else to the floating button. aria-expanded is kept on the control.
Proved on a UPG-100 pilot served locally (gallery page, 1440, MapLibre and
Mapbox, en and ar), exactly one info button on screen at every step:
wall 1 floating; Map 1 control (it wins its hit test and sits inside the
toolbar column); opened from the control, Escape closes it with focus back
on the control; toolbar hidden 1 floating, shown again 1 control; tab away
and back 1; back to the wall 1 floating. 0 page errors. That pilot's wiring
is not in this commit.
Reported from a phone: on the Regions tab, "2 posters per row" for the country
posters did not work -- a 390px phone showed ONE per row.
THE CAUSE is a deliberate clamp in PosterWall. The per-row setting is 2, but the
grid is Math.min(perRow, fits) with fits = floor((viewport - 36) / 210), and at
390 that is floor(354 / 210) = 1. So the setting said two and the phone silently
showed one.
★ WHY THE CLAMP EXISTED, AND WHY IT IS SAFE TO RELAX NOW. It was added when a
two-up card (~162-170px) CLIPPED its action labels -- 'Prayer List' and 'Poster
Map' both read 'Pra...', and Romanian's 'Pachet de cartonase' was measured at 54
of 81 controls clipped at ro/390. That was then fixed separately: the labels WRAP
(.pfs > .pbtn, "a label too long for its box wraps"). With wrapping in place the
clamp no longer protects anything; it only costs the phone a column.
★ IT IS OPT-IN, NOT A GLOBAL CHANGE. PosterWall is shared by twelve files,
several of them frozen experimental maps. A new minCardPx prop defaults to 0,
which means the existing 210px clamp, so every wall that does not set it lays out
byte-for-byte as before. Only regions-list sets 150, which gives two per row at
360, 390 and 414, still one at 320 (which genuinely cannot hold two), and no
change on desktop (already capped at the per-row setting of 2).
MEASURED on the regions-list leaf page, this branch served locally, against
FINAL on :4611 -- by hit test and by eye:
FINAL @390 en 1 per row, 367px cards
AFTER @390 en 2 per row, 169px cards, 0/123 labels clipped
AFTER @390 ro 2 per row, 0/123 labels clipped <- the old clipping corner
AFTER @390 ar 2 per row, RTL, 0/84 labels clipped
AFTER @1440 en 2 per row, unchanged
0 horizontal overflow and 0 console errors in every cell. The print buttons now
stack three high on a phone card instead of sharing a line.
KNOWN, COSMETIC, NOT CHANGED: at two-up the card's "53 UPG · Show on map" pill
wraps "Show on / map" onto a second line. Readable, not clipped.
LegendMobile already shows "The dot marks where this people is found, not a town
or a boundary." once, floating above the sheet, and placed so that it takes no room
from the options inside. The SemanticTreeLegend it renders inside the sheet printed
the same sentence again, so with the sheet open a phone reader saw it twice.
Measured on Prayer Progress at 390 on the shared browser, with the sheet opened
through its own toggle (pp-caption.mjs):
- before, on development as served by DX Life: 2 visible copies on / and on the
Deep Research tab, the floating note plus the tree's note inside the sheet;
- after, Prayer Progress rebuilt locally with this change: 1 visible copy, the
floating note, in en and ar;
- desktop, 1440: still 1 copy, the desktop legend's own tree, unchanged.
The sheet's tree now gets :pin-note="false". The detail view keeps its own note,
which carries the shared-point wording.
Library only. Every bundle that renders LegendMobile needs a rebuild to ship it.
PinHint's first-visit pill sits at z-index 2147482000, so with MapInfo's scrim at 1000 the pill floated
bright and clickable over the dimmed backdrop while the popup was open (gospel; measured on the live
build: the pill's centre hit the pill, not the scrim, on Cities). The scrim goes to 2147483000; the
dialog is its child, inside the scrim's stacking context, so it stays on top of the scrim. The root fix
is the top layer (<dialog>.showModal()), carded.
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.
The "What can this map do?" pointer is anchored beside the ⓘ and extends ~213px
INWARD, across whatever the tab puts in its top strip. Worst overlap per width,
measured on Deep Research on the shared browser, on 99c22caf7:
390 Regions "Region Dashboards" 1416px^2 UPG-100 1428px^2
768 Cities legend 6560 Gospel legend 6560 + input 6240 Regions 4200
900 Cities legend 2432 Gospel legend 2496 + input 2176 Regions 3696
1024 Cities CLEAN Gospel CLEAN Regions 2100
1200 CLEAN CLEAN CLEAN
1440 CLEAN CLEAN CLEAN
★ IT IS NOT A PHONE PROBLEM, which is how it was first reported and how I first
fixed it. It persists to 1024 and only clears near 1200, so the max-width:600px
version I wrote first would have left 768, 900 and 1024 broken on four tabs.
Two things found that: gospel counted PANELS (.gm-legend/.gm-sheet) as well as
controls -- a control-only probe misses a legend entirely -- and upg100 measured
the same shape on their own tab (768 over "Prayer Resources" and "Map", 1024 over
"Map", clear at 1200+), in en and ar.
★ ARABIC IS MEASURED, not argued. My first attempt used ?lang=ar, which does not
switch the locale -- the page stayed dir=ltr and I reported that run as invalid
rather than as an ar pass. Via DX Life's /ar/ route (dir=rtl confirmed) the
pointer is hidden and clean at 390, 768 and 1024, and at 1440 it mirrors to the
start side (x=104) and is clean there too.
★ TWO OTHER SHAPES WERE MEASURED FIRST AND BOTH WERE WORSE. Stacking it below the
button traded one collision for five: Gospel's "Resources found" chip (2010px^2)
plus the zoom +/- of Prayer Progress, Big Picture and Simple Research. Hiding
only its text shrank it to 30px but still left 675px^2 on "Region Dashboards" and
900px^2 on Gospel's search input.
⚠ NOT A PLACEMENT CHANGE, and an earlier draft of this got it wrong. It also
pinned the button's inline side at narrow widths; studio's pos.narrow values have
since landed and the buttons now sit at x=8, 12, 294 and 346 across the seven
tabs at 390. A pinned inset-inline-* here wins by specificity and would have
silently flattened every one of them. The button is placement's business; this
rule is only about the hint. 0 added lines declare --map-info-side; 0 touch
.mi-btn.
Its own block rather than the existing 600px one, which also full-screens the
dialog and has nothing to do with this.
VERIFIED AFTER THE CHANGE, en and ar: pointer hidden and 0 overlaps at 390, 768
and 1024 on Cities, Gospel, Regions and UPG-100; present and clean at 1200 and
1440.
NOT FIXED HERE: at 1440 Big Picture's pointer lies over the Religion filter by
1050px^2. Stacking it below the button there does not clear it -- it relocates it
to "Adopted" 561 + "1%" 425. The only single rule that clears that too is hiding
the pointer at every width, which deletes the affordance where it does fit. So
BP-1440 wants a per-tab placement value or a known-minor ruling; it is
pointer-events:none, so the filter still opens.
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.
The button and its pointer were placed with a physical right:12px, so on an
Arabic page, where a tab bar starts at the right, they covered the first
(active) tab. They now use inset-inline-end / inset-inline-start (LTR is
unchanged), and under dir=rtl the pointer's arrow glyph and its nudge are
flipped so it still points and moves toward the button. --map-info-top and
--map-info-side remain the per-tab overrides.
The shadow card behind a packet's first page sits at z-index:-1; without a
stacking context of its own it was painted under the dialog's white
background and never seen. isolation:isolate on the stack keeps it just
behind the page.
MapInfo (library/components/info) answers "what can this map do for
you?" with real output the map makes, pre-generated so it opens at once.
The examples are an ordered list of kinds rather than a fixed set,
because maps make different things: an image (a poster), pages (a packet
that shows its first page and expands to every page), a deck (cards that
expand from a stack to a grid) and a table (a real excerpt of the
spreadsheet a map exports, with translatable headers). An example whose
image is missing drops out quietly: no broken-image icon, no empty frame.
The pointer at the button goes for good once that map's popup has been
opened and closed; a checkbox in the popup turns it off on every map.
Both are kept in localStorage behind a guard, so private browsing cannot
break the page. On a phone the popup takes the whole screen; Escape
closes it and focus returns to the button. The pointer's motion stops
under prefers-reduced-motion.
All of its text is one new catalogue area, 'mapInfo', in all eleven
locales: the component's own words, and each map's lead, takeaway and
note as mapInfo.tab.<tabId>.<field>. One area because a bundle ships only
the areas its source names, and the gallery that mounts a popup per map
names none of the maps' own areas. This commit carries the UPG-100
map's two sentences; the other maps' follow.
RegionSectionCard passed THREE arguments to useShadowStyles(css, label): the CSS,
then 'regions-list', then 'region-section-card'. The signature takes two, so
'regions-list' was the key and its own name was silently ignored.
useShadowStyles keys its <style> tag by label and, on a collision, OVERWRITES
the existing tag's text instead of adding one (useShadowStyles.js:60-61). This
card is a CHILD of regions-list, so the two share one shadow root: the cards
render after the fetch, mount second, find the list's tag under the shared key
and replace every .rl-* rule with the card's CSS.
Measured in the running page: .rl-root became position:static and .rl-scroll
overflow-y:visible, the shadow root held 3 style tags where a working tree holds
4, and 'rl-root{' appeared nowhere in it. The list therefore had no scroller of
its own, a wheel moved the pane column instead, and every region below the fold
was unreachable -- at 1440 Middle East, North America and Oceania; at 390
everything except Africa.
It was latent until the label became real: before the cyrillicHeading fix, a
dangling comma concatenated 'regions-list' INTO regions-list's own CSS, so the
list keyed by a content hash and the two never met. Fixing that rule turned a
silent collision into a visible break, which is why the live site is unaffected
and every sha after it is not.
Verified in the artifact, not the source: ,"regions-list") appears once in the
rebuilt bundle where it appeared twice, ,"region-section-card") now appears once,
and the .rl-* rules are still present.
BoundedSet.vue, CenteredSetTarget.vue and VisionFunnel.vue came back through the
handoff merges, byte-identical to what c5c01dead deleted on purpose on 09-28
("Delete 7 unreachable library components"). That commit also added
check:no-dead-library precisely so they could not return. Verified before
removing: 0 files import charts/BoundedSet.vue, charts/CenteredSetTarget.vue or
charts/VisionFunnel.vue, and packetCharts.js references none of them, so nothing
is left dangling. check:no-dead-library passes with them gone.
A first count said BoundedSet had 6 importers. It has none: those files import
layoutBoundedSet from charts/bounded-set.js, a different module, and the grep
matched the substring.
packetCharts.js: the doc block describing scatterCells sat above topCells' own
block, two functions away from what it documents. Moved to sit directly above
scatterCells. No code change.
The handoff merges brought in three references that name the Driver in shipped
text: two comments in CenteredSetTarget.vue and one _why in the people-groups-grid
instances. Reworded to state the engineering reason without the speaker -- an
open decision now reads as pending a product decision, which is what the rule
asks for. The instances file still parses as JSON.
Clearing the clip is not the same as being clickable. The attribution and the
pin hint are painted over the canvas and take pointer events, so a popup that
ends fully inside the frame can still have its buttons underneath them.
Measured at 390 on a 366x732 map: the pan cleared the clip exactly (cut 0) and
a real click on Full Profile still did nothing, because at y~701 the top element
at that point was div.maplibregl-ctrl-attrib-inner; on the sibling pin it was
span.pin-hint-pill. Both pins panned, both looked fixed, neither was.
Neither the popup nor the control is at fault. Attribution is a licence term of
the basemap, so raising the popup over it is not available to us. The usable
frame is simply shorter than the container, and the fit test has to say so.
panPlanFor now takes a bottomInset and fits against containerH - bottomInset.
On the measured case the plan changes from -77 (pan up, popup below the pin,
buttons under the attribution) to +119 (pan down, popup above the pin, clear of
the band) -- the planner flips direction because with the band reserved, fitting
above became the smaller move. That is the existing "pick the smaller move" rule
doing its job on a corrected frame, not a new rule.
The inset is MEASURED from the live controls rather than assumed. A constant
would be wrong on the next control we add, at a width where the attribution
wraps to two lines, or when the gallery's clearance transform lifts the band.
Only controls that intercept pointer events are measured: the corner container
is pointer-events:none and spans nearly the full map width at 390, so measuring
the container would over-reserve the whole strip.
Unchanged where it was already right: a pin whose anchor fits still returns 0
(rega measured pan 0 on those in both arms), a popup taller than the usable
frame still refuses rather than jumping the camera, and bottomInset 0 is
byte-identical to the previous behaviour.
7 probe cases, with the post-pan fit asserted from the geometry rather than by
re-calling the planner -- a self-consistent oracle cannot see an error that
shifts both sides.
With no resolver configured, the kit chip bar joined a multi-value filter with ', ', so "Africa,
Asia" read as ALL to most people and carried English commas in every locale. The selection bus
matches several values on one facet with includes() (useSelectionBus isFilterActive), so adding a
value WIDENS the result: they are alternatives. The chip now uses formatList(value, { type:
'disjunction' }): "Africa or Asia", "Africa أو Asia", "Africa或Asia". gospel traced the matcher
and made the call as the only consumer's owner (gospel-internet's GiCombos). The English on this
chip changes from a comma to "or".
check:legend-mirrors lifts this exact expression and drives it with no resolver. It now lifts
formatList from library/i18n the same way (the shipped grammar, not a copy) and expects
"Islam or Hinduism". Poisoned with the old join it fails; restored it passes. formatList's doc now
says to choose the type by what the list MEANS, and never 'unit' for names.
check:approx-pin-note server-renders ApproxPinNote in Node, and it crashed: with no locale argument
and no runtime locale, formatList fell through to detectLocale(), which reads `document`. The page
lookup is now a guarded last resort. ApproxPinNote also passes its sentence's own locale (the
host's vue-i18n when installed, else '' so the runtime/page chain applies), so a host with its own
i18n can never get an Arabic sentence with a list in another language.
The gate now expects the new English ("Koraga and Maila", the list grammar this change exists for)
and adds the Arabic case: two names joined with و, never an English comma inside Arabic prose.
Poisoned by putting join(', ') back, both of those cases fail; restored, it passes.
ApproxPinNote built pins.sharedPoint's {names} with join(', '), so the Arabic sentence carried English commas between the group names: the case gospel measured. It now uses formatList, which joins by the locale's own grammar (ar 'A وB وC', zh 'A、B和C', en 'A, B, and C').
DataTable's sheet heading fell back to a hardcoded English 'Detail', one word and so outside check:hardcoded-english's reach. It now reads aria.rowDetail ('Row detail'), which this component already uses for the dialog's own label: translated in all 11 locales, and it brings no new catalogue area into any bundle (a dashboard.* key would have added the whole dashboard area to every bundle that shows a table).
rega's poster-path timings: A4 300 dpi ~42 s, 24x36 in at 150 dpi 90-135 s,
24x36 in at 300 dpi ~4 min. Through all of it PosterDialog showed an
internal phase name ("hidden-map"), a bar measuring only the current phase
(so it restarted and sat still), and English. A failed render, including a
canvas too large for the device, went only to the console: the status block
vanished and the reader was told nothing.
- The bar follows the composable's overall, monotonic percent; beside it,
the time elapsed (ticking) and, while stitching, "tile n of total".
- An honest expectation for the chosen size, from its megapixels: "up to a
minute" from 5 MP (A4 300 dpi, 8.7 MP), "a few minutes" from 15 MP (24x36
in 150 dpi, 19.4 MP, and up). A4 at 150 dpi (2.2 MP, 11 s) shows none.
- A failure is shown in the dialog: a specific message when the device ran
out of memory (an oversized canvas or encode), a general one otherwise,
nothing when the reader cancelled (AbortError).
- The Render button's labels and the "(optional)" placeholder go through
T() too. 9 poster.* keys in 11 locales, using the dialog's own terms.
RegionCountryMap's note on the projection override said who ruled it out, in capitals the reference
baseline's pattern never matched; it now says what was decided and why the globe stays. The
mobile-gating header and a people-groups-grid census entry carried internal card ids that named their
source; the ids are shortened to what they describe.
RegionCountryMap.vue and check-mobile-gating.mjs change in comments only: script, template and styles
are identical before and after, compared without comments and without renaming. The census card field
is informational; nothing matches on it.
Defence in depth for a bug already fixed at its source. <doxa-country-map> was
passing center: null when its host set none; Vue applies a prop default only for
undefined, so that null replaced this component's [20, 10] default and reached
the Map constructor, where the engine's LngLat.convert threw before a style or a
tile was requested. Twelve measured cases, every one a dead map reported as "a
map-token or connection problem".
The element now sends undefined and no longer produces that null. These guards
are for the NEXT caller: a component in a library cannot assume every host
resolves an absent value the way one of them does.
The asymmetry is why it happened at all. `bounds` two lines below already had
`|| undefined` at the constructor, so a null bounds was harmless; `center` had
no guard, so a null centre was fatal. Both now behave the same way.
flyTo gets the same treatment, because it reaches the engine's LngLat conversion
by another path and would throw identically on a re-frame. It falls back to the
map's current centre, so a caller that supplied none gets the zoom re-framed
without the camera sliding sideways.
The popup is built with no explicit anchor, so the engine puts it above the pin
when there is room and below when there is not. Measured on the drop-in: a 452px
popup on a pin in the lower half of a 780px map fits NEITHER way, so it opens
below and the map's overflow:hidden cuts off the Pray and Full Profile buttons.
A real click at their centres then lands on the page behind, though a scripted
click still navigates -- so the buttons look present and are not reachable.
Moving the PIN is what makes an anchor fit, so the map pans rather than the
popup changing. Its design is untouched, which is what keeps this a clone; it is
what Leaflet does by default. The alternative, a shorter popup, would have been
a visible departure and a product decision.
The pan is the smallest one that works, and most pins need none: on a 780px map
only a narrow band moves at all, by 74px at the worst position rather than a
jump. It is animated over 200ms.
It resolves at every width this component renders at. The container is
max(780, 16/9 of width) at >=768px and twice the width below that, so even at
320px wide there is 640px of container against a 452px popup -- 164px of
headroom, and the popup would have to grow by that much before any width could
overflow. So no inner scroll and no residual case.
It returns 0 rather than panning when the popup is TALLER than its container: a
host can give the map a short slot, and there a pan would only move the clipping
around. The failure stays what it is today instead of becoming a clipped popup
and a camera that jumped. Unmeasurable inputs -- a map mid-load, a zero-height
container, an unprojectable point -- also return 0 rather than throwing, and the
pan itself is wrapped, because a pan is a nicety and must never break a click.
The offset lives in one constant now. The pan arithmetic has to use the same
number the popup was built with, and two literals drift.
Proven against the shipping function, lifted out of this file by source rather
than restated: 16 checks, including every pin position at 320, 390, 768 and
1440px wide -- none left clipped. The fit check is stated independently of the
function, because an earlier version asked the function whether it had
succeeded, and that hid a dropped offset: the pan was 12px short and its own
test 12px lax, so the two agreed while the button still clipped. Mutation-tested
four ways -- dropped offset, zeroed offset constant, flipped sign, removed
squeeze guard -- each caught.
Both consumers rebuilt: country-map +623 B, regions-dashboard +672 B.
Intl compact forms are longer than the old '12.3M': with one decimal
everywhere, German reached '123,5 Mio.' (10 chars) on the region rows,
past both the 60px mobile and 70px desktop Population column. One
decimal now only below 10 million; whole units above. Worst case over
all 11 locales: 8 Latin chars (de/ro/ru '123 Mio.').
The legend's region sections, its Deaf row and its one-country zones, the
wall's card names, the print progress and error messages, the map's status
badge and the Population column all rendered English in every locale. None
of them was visible to check:hardcoded-english: they were single words, .js
modules, Error() arguments, library components or number formatting.
- Legend: region rows through one REGION_KEY table shared with the wall
(poster.region_*, new region_deaf); zones through zoneTitle (tier A by
country, the Deaf row by its bloc name); node ids stay English-derived.
- Wall: card labels through zoneTitle, so the Show-on-map name, alt text,
tooltip, HUD title and printed title agree with the legend and packet.
- Errors a reader sees are keyedError(key, English, params);
errorText(e, t) shows them translated, and an unkeyed error still shows
its own message. Developer detail for a failed data load goes to the
console; the badge says upgMap.mapDataFailed.
- Population: Intl compact notation in the app's locale, same precision.
- Country Posters: the unsupported card's reason is
messages.countryPosterTooMany, not the data file's English note.
- Strings only the UPG-100 map shows move to a new upgMap area, so other
bundles do not carry them (country-posters 1,490,907 bytes).
- check:hardcoded-english judges keyedError's English as a fallback
(quiet when the key resolves everywhere, a finding when it does not);
check:label-key-coverage follows REGION_KEY to legend-tree.js and covers
the legend's table and its regionFor producer. Both self-tests two-sided.
A larger-screens profile whose root is position:absolute; inset:0 would
lay out against ProfileLoader's wrapper and paint over the in-flow bar,
hiding the notice and its dismiss button. While the bar shows, the
profile's root is held in flow as the column's second item. No flagged
profile has that shape today; the next one will not need a special case.
The bar's padding is logical, so in Arabic the wider side stays on the
text rather than moving to the dismiss button.
A map says how it behaves on a narrow screen once, in its <profile>.meta.json:
"mobile": "desktop-only" (blocked on a phone) or "larger-screens" (opens,
under a dismissible notice). Absent means phone-ready. Until now the rule
lived only in the Research Gallery, as two hard-coded id sets, so a map
opened standalone on its own leaf page on a phone got neither the block nor
the notice.
- profile-meta.mjs knows the vocabulary; generate-tree and the dev manifest
carry the flag into each profile's manifest entry, and generate-tree into
the leaf page's BASE_CONFIG (with the map's name for the desktop-only
sentence).
- ProfileLoader mounts library/components/LargerScreenNotice.vue on a screen
under 640px: for desktop-only the profile is never mounted and a centred
message says why; for larger-screens a slim in-flow bar sits above the map
(it cannot cover the map's controls), dismissible per profile for the
session, in the element's own locale and direction. Inside the Gallery it
stays quiet: every Gallery pane now carries data-doxa-gallery.
- One text: the Gallery's two sentences moved into the library catalogue as
messages.largerScreenNotice / desktopOnlyNotice (+ noticeDismiss), all 11
locales moved as they were, and the Gallery reads them from there.
- The Gallery derives its rules from the manifest's flags. On a phone it
mounts nothing until the manifest has landed: its baked first tab (Big
Picture) would otherwise mount there before the flags say it is
desktop-only.
- dashboard-plus-grid is desktop-only and gospel-internet/board is
larger-screens, the two sets the Gallery held; nothing changes for either.
- check:mobile-gating now asserts the vocabulary, that each flagged leaf page
carries its flag, the Gallery's derivation and its closed-before-manifest
rule, that Big Picture stays desktop-only, the 11 locales, and the
notice's mounting. Its self-test plants one failure per rule.
A country page frames by a fixed center and zoom, and a fixed zoom shows less on
a narrow screen. Measured on the drop-in: /regions/niger at 390 wide put the
camera over southern Niger and northern Nigeria with the Agadez outline cut off
at the top and 0 of the page's 2 pins in view, while the same page at 1440
showed both.
Inherited rather than introduced: their CountryDetail passes only center and
zoom to CountryMap and never a box, so the live site frames the same way.
chooserZoom cannot save it either -- its first line returns props.zoom unchanged
unless colorMode is 'region', and a country page is 'prayer', so the
width-and-height-aware solver below it never runs.
The fit is CONDITIONAL, which is the part that keeps this a clone. fitTheirWay
ends in easeTo({center, zoom}), so it moves the centre as well as the zoom;
fitting unconditionally would re-frame every page that is already correct. So
the configured camera is applied and kept, and the pins' box is fitted only when
a pin actually falls outside it. Asked on moveend, because getBounds reads the
current camera and flyTo is animated, so asking sooner would test the camera
being left behind.
The cap is a ceiling: maxZoom = props.zoom lets the fit zoom OUT, which is what
the narrow case needs, while forbidding it from zooming in past the camera the
page asked for. Capping it the other way would have made this a no-op on the one
case it exists for.
The pins' box is the honest input available here. The country outline comes from
the countries tile source, so its extent is only as good as the tiles that
happen to be loaded; reading a box off it would measure the viewport, not the
country. A box supplied by the page would be better still, since pins can
cluster in one corner.
Probe in _qa-cycle lifts pinsBox and fitIfPinsAreOutOfView out of this file by
source and tests both sides: a well-framed page takes zero fits, and the narrow
case fits the pins' box capped at props.zoom. Mutation-tested both ways --
removing the already-framed early return, and capping with minZoom instead of
maxZoom, each make it fail.
Ten agents read today's branch (five finders, one skeptic per area who had to confirm each finding
against the current code); 21 distinct defects survived. All are fixed here.
Generator and its proof
- generateCountrySheet fed only ADM0, so a country beside India or China printed without their
provinces (Nepal: 251 KB against Node's 921 KB). It loads the full boundary set, as the driver does.
- check:poster-browser-equiv rebuilt its own feed, so it could not see that. It now calls what the
browser calls (makeZoneSheet, generateCountrySheet — Nepal), deletes the target before the Node run,
runs Node with a clean environment, and FAILS (not "not measured") when the Node generator breaks.
Proven two-sided: it fails with the ADM1 fix reverted.
- The facts table's country list sorted in the reader's browser locale; pinned to en-US (Node's
default, so Node's output still hashes to the baseline).
- zone-sheet-print.js imports the library relatively, so bun, Node and the bundler resolve it alike.
Print seams and wiring
- The wall's Poster Map button used its own stored-file fetch and never the generated sheet, so every
country poster 404'd on the published tree; it now prints the generated sheet first.
- Country Posters wall: card stocks passed (its Card Deck could never print), iso3-names passed (zh/ar
page 1 kept English names), the stored fallback reads previews/ (where the tree serves them).
- Card-stock labels render at render time, so a locale change relabels them.
- The records provider caches by language AND list, and does not keep a failed membership load.
- The UPG-100 legend's Card Deck returns quietly on a parent row instead of an evaluator error; the
selection fetch uses PRINT_FIELDS.
Packet
- The centered-set caption counts what is DRAWN (a ring too full to separate reports its remainder).
- Each ring's number lane is sized to its own radius (ring 1 put a dot under its badge).
- Per-box line keeps a decimal below 10 people ("≈ 0" for small lists); worksheet rows name the country;
the localized poster kicker is fitted against the poster's real 19px, not 22.
Plurals and gates
- regions.countryCount / regionCount were authored in English zero|one|other order in de, es, fr, hi, it
and pt, so CLDR picked "27 Land", "1 países". Rewritten in each locale's own order; check:plural-forms
now fails a string in English order (proven on the old German string).
- check:optional-t-equivalence (gates:post) crashed on the new plural fallback's imports; it injects the
real cldrChoiceIndex and asserts the plural path (ru 21/3/5, en 0/1/7).
- check:packets-all and check-packet-render counted people as cards only, so every lightweight packet
read as "a packet of nobody"; worksheet rows count, and the browser gate checks the grid's top fill
(the renamed .lc-frame) instead of the retired scatter. check:packet-variants now reads the grid itself
— 5,200 boxes, Jesus' 1,716 from the top, the % shading from the top — proven: it fails on a renderer
that shades the last boxes.
gates:pre 68/68, check:optional-t-equivalence PASS, both bundles build.
library/packet/poster-render.js and posterSheets.js arrive in this landing
carrying the generator's comments as they stood before the sweep: two lines
named the Driver and one said "he changed the SHAPE". They are reworded as
the sweep reworded them in generate.mjs, and two pronouns the sweep left
behind (SearchFilterHeader, check-filter-width-stable) now say what was
asked instead of who asked. The baseline drops the two generate.mjs lines
that moved out.
The Driver's print standard: every export is generated when it is asked for, and only previews are
stored. The zone and country map sheets — the poster, and the map-only sheet on a packet's page 1 —
existed only if library/scripts/posters/generate.mjs (Node) had written them ahead of time; no country
had a print-resolution sheet at all.
- library/packet/poster-render.js is generate.mjs's drawing layer (2,645 lines) as a pure factory,
createPosterGenerator({ env, SERIES_DEF, ISO3_NAME, adm0, adm1Ind, adm1Chn, nePlaces, series }). Moved
VERBATIM (template literals carry their whitespace into the output), `process.env` read through a
local shadow; its inputs are its free variables, found by a scope-aware parse, not a grep (a grep
missed adm0, and the proof caught it). generate.mjs is now its Node driver.
- PROVEN BYTE-IDENTICAL: all 118 sheets of a pinned offline matrix — the 28 zones in 3x4, 3x3, letter
and packet, and a country sample at both sheet sizes — hash the same before and after.
- data/zone-sheet.js is which group pins in which zone (first claim wins), now ONE function both the
Node driver and the browser call, so a group cannot pin in one zone on paper and another on screen.
- library/packet/posterSheets.js feeds the factory in the browser: the served full-resolution
boundaries, the shared series and places tables, a generator per call (caching by format pinned the
first caller's data on every later sheet). generateCountrySheet does a country end to end;
data/zone-sheet-print.js a UPG-100 zone. ISO3_NAME moves to library/data (territory-counts re-exports).
- usePosterPrints gains opts.sheet: the poster print is the generated letter sheet as a VECTOR page,
and the packet's page 1 the generated map-only sheet (item.mapId honoured); stored images remain the
fallback when generation fails. PosterWall forwards it; the Country Posters wall, the UPG-100 wall and
the UPG-100 map pass it. The UPG-100 wall and map drop their own record fetches for the shared
provider, and the map's legend and selection HUD offer the card deck (production).
- check:poster-browser-equiv: the browser feed's sheet is byte-identical to the Node generator's for an
India zone (packet) and a country — proven two-sided (it fails when the feed drops the city dots).
check:selection-kinds reads the handler's branches instead of a fixed poster/packet pair, anchored on
`if (` (proven: it fails with the cards branch deleted). library/packet/README.md: the standard.
gates:pre 68/68; both bundles build.
RegionCountryMap builds its map popup by concatenation and escaped every value
in it -- the name, the religion, the description, the image, both button labels
-- except the two URLs:
<a class="cm-popup__btn" href="${profileUrl}">
Both come from joinBase(props.researchUrl, p.slug), and joinBase says plainly
that it only normalises slashes. So two untrusted values reached an HTML
attribute raw: the slug, which is API data, and the base URL, which is an
attribute set by whoever embeds the element. With a slug of
`x" onerror=alert(1) data-x="` the rendered tag is
<a href="https://doxa.life/research/x" onerror=alert(1) data-x="">
out of the attribute and into new ones. esc() was already imported in that file.
The same omission in library/packet/packet-render.js, found by sweeping for the
class rather than assuming it was one site: the people-group card writes
src="${src}" where src is photos.get(slug), and usePosterPrints builds that Map
directly from the API's image_url. esc(g.name) sits two lines below it.
Neither could be caught by review or by template analysis -- both are inside
template literals -- and the popup had never once rendered, because the Regions
map was grey. So the gate: no href= or src= in built HTML takes a raw
interpolation. Data URLs, blob URLs, encodeURIComponent and safeUrl() are
recognised as safe, each verified at its call site rather than assumed from the
name; anything else needs an href-escaped-ok: reason in the four lines above.
Two such reasons are recorded, for the two sites the check cannot see the safety
of: useMapAttribution's hrefs are module constants selected by engine type, and
export-public builds its rel entirely from encodeURIComponent one line earlier.
The sweep also cleared seven sites it first flagged -- three blob: URLs from
createObjectURL, two data: URLs from readAsDataURL, safeUrl(), and the
encodeURIComponent pair -- so the check now recognises those shapes instead of
reporting them.
Mutation-tested: reverting either fix alone turns the gate red. 6 self-tests
carry the founding bug and the safe shapes. gates:pre 65/66, the one red being
check:poster-action-print waiting on gospel's GiTree fix.
- The legend's packet submenu emits 'packet:full' | 'packet:lightweight', and the country map compared
against a bare 'packet', so its legend Packet export did nothing at all. It splits the kind first, as
the UPG-100 map's handler does (the research map carries the same three lines; reported to its owner).
- The selection's rows come from the print standard's records provider by slug (full print fields,
the reader's language); the rows mapped by hand put the country's NAME in country_code and carried no
language, so every profile description ran short. They remain as the fallback. The live map's raster
goes in through the mapImage seam, so page 1 is the map full page.
- The legend gains an OPT-IN Card Deck export (exportCards; provide('legendExportCards') for the mobile
sheet), so a map that does not handle it never shows a dead item.
- library/print/experimentalExports.js is the ONE switch for rolling the standard's exports out on
maps that are not production print surfaces (production: the UPG 100 list, the Regions dashboard's
country posters, the Country Posters wall). This map's card deck sits behind it; off — per page, per
visit (?exports=off), per site or everywhere — restores the menu it had before.
- The records provider returns coordinates (a generated map's pins), exposes the unselected set
(recordsFor.all) and sorts in the reader's collation.
The legend gives each tab a small "i" button explaining what that tier
means — what an Affinity Block is, how a people group differs from a people
group in a country. SemanticTreeLegend already read that text from
legend.tabInfo.<id> behind a te() guard, and not one of the ten keys the
research map needs had ever been written. The tab chips were translated in
all eleven locales and the explanations beside them were not, so the reader
least able to guess what "PGIC" means was the one reader who got it in
English.
The keys are written for all eleven locales, following the terminology each
catalogue already uses for these same tiers — People Group as Volksgruppe,
Peuple, Pueblo, 民族, Народ, जनसमूह — with WAGF and UPG left untransliterated
as the corpus leaves them. The reader-facing text drops the source field
names and row counts that the English carried; those are notes for whoever
maintains the tables, so they stay in the source as a comment.
With the keys in place the English in the tab tables can never render, and
a second copy of a string drifts from the catalogue without anyone noticing,
so it is removed. `info: true` stays because the legend gates the "i" button
on that field being set — dropping it would have removed the affordance
instead of translating it — and tabInfo now treats only a string as a
fallback, so the flag cannot reach the popover as the word "true".
Verified every changed tab renders a real label and a real explanation in
all eleven locales. check:i18n-parity, check:i18n-never-translated and
check:i18n-treeshake pass; hardcoded-english 205 -> 190.
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.
PosterWall carried its own copy of the poster fetch, so the fallback-page
guard added to usePosterPrints did not reach it: a missing .webp answered
200 text/html by a host was still handed to the print dialog as the image.
It now uses fetchPosterRaster and fetchPosterSource. A thrown .webp fetch
is still a miss that falls through to the source, and a missing source
still reads "poster not found (<status>)".
The five stock labels were English literals in every locale ("Card 2.5 × 3.5 in" in Romanian), in a
file whose own header promises every string comes from the catalogue. The DECIDE card asked whether
paper names translate at all; this takes the middle that answers it for both readings: the NOUN
translates (Card, Postcard, landscape — wall.stockCard / stockPostcard / stockLandscape in all eleven
languages) and the size does not, since "2.5 × 3.5 in" and "A6" are what the paper box says in any
language. A6 has no noun and prints as named; English prints exactly as before.
rega measured 13 controls under the 24px minimum in the country map's legend at 390px: the tier tabs
at 22px tall, their info buttons at 18x22, and the row carets. The tabs take a 24px minimum height and
the info buttons a 24px square. The carets grow to 24x24 while a -5px margin returns the extra space,
so the tree's indentation (which shares the caret width) does not move. One library component, so
every map with this legend gets it.
The i18n + RTL audit of Country Posters (the hardcoded-English gate only reads production plugins,
so it had never read this one; its scanner was run on the plugin directly and every hit triaged for
reachability, since the country map mounts one tab):
- 9 strings a reader sees were English in every locale: the search placeholder, the packet title's
"— 50 of N", the map-failed message, the wall's heading, tag, intro, soon message and item nouns,
and the view switcher's legend and label. All read from the catalogue now, reusing the existing
keys where they exist (tabs.selectView/views, the UPG-100 wall's tag and emphasis).
- The wall's section headings are the WAGF region labels in the data, English everywhere; they now
resolve through the Regions dashboard's own WAGF table, which gains its two missing regions.
- The three WAGF tier tooltips had no keys in any locale (legend.tabInfo.region/block/country).
- 0 physical-CSS findings.
PosterWall counted "N countries" as a number plus a 2-form noun pair and defaulted the plural to
noun + "s" — wrong for Russian and Arabic whatever strings it was given. It takes itemCountKey, a
plural catalogue key rendered with the count; useOptionalT forwards a plural count to vue-i18n (the
CLDR map picks ru 4 forms, ar 6, zh 1), and a host without i18n picks the English form by
vue-i18n's own rule. The people-group counts on the section heads and placeholder cards use
wall.peopleGroupCount the same way ("people groups" was hardcoded English on the cards).
The UPG-100 wall: its Americas and Europe section headings had no key (single words, invisible to
the gate), and it counts its lists with wall.listCount.
Checked: plural-forms, i18n-parity, keys-exist, never-translated, and i18n-treeshake against both
bundles built (26 bundles carry every area they ask for); gates:pre 66/66.
A host that draws its map live (the regions country map) has no posters/<id>.html to lift, so its
packets printed page 1 with the missing-map notice. usePosterPrints gains opts.mapImage(item): an
image or data: URL of the host's own map, placed by the renderer (mapFromImage) exactly as a poster
sheet is, the whole page inside the white margin. Order for page 1: an explicit mapSvg, then the
generated map, then the stored sheet. PosterWall forwards it as a prop. Measured two-sided through
the real printPacket: with mapImage the image is page 1 and no sheet is lifted; without it the
stored sheet is.
The composable's header now documents the whole contract — every option, which are optional, and
the row shape — so a map adopting it reads one comment instead of the wall that uses it.
The list's own print relabelled the map sheet (mapLabels.js); the wall's print, through
usePosterPrints, lifted the sheet with no relabeller, so a zh packet printed from the wall kept
INDIA, CHINA … on page 1. The generator's ISO3 -> name table is plugin data the library cannot
import, so the host now supplies it: PosterWall iso3Names -> usePosterPrints iso3Names, and the
UPG-100 wall passes its territory-counts table lazily. A host that passes nothing prints exactly as
before. Measured two-sided through the real printPacket on a tracked sheet: without the table the
page carries CHINA and no 中国; with it, 中国 and no CHINA.
The rule: never quote or cite the Driver in shipped code. Every one of these
comments had a real engineering reason attached, and the reason is what a future
reader needs — so the reason stays and the attribution goes.
COUNT IS THE CHECK: 31 before, 0 after, across 8 files.
I was pointed at 18 in region-dashboards. That was one file. Sweeping my own area
as instructed found 31 in eight files: region-dashboards 7, RegionCountryMap 13,
regions-list 5, UupgsList 2, plus regions-mfe.vue, regions-mfe.meta.json,
index.js and check-regions-uses-library.mjs with one each. Reporting the wider
number rather than the one I was given.
HOW EACH WAS REWRITTEN, by kind:
· a REPORTED SYMPTOM keeps the symptom, loses the reporter — "the pins were
reported too small at rest", "the list page was reported slow, with the
posters suspected", "instead of flying to the region, which is exactly the
reported symptom".
· a REQUIREMENT becomes a requirement — the region key is "a map that shows the
regions and countries AND lets a reader select one" rather than a quotation;
the missing fullscreen and zoom controls are "part of the cloned surface"
rather than someone's complaint about a full clone.
· an OPEN DECISION becomes provisional. Two of these: the stat-card divergence
from RegionDetail.vue:176 is now "carried back as a product question", and
the numeral-grouping parity reads "PROVISIONAL, pending a product decision"
instead of waiting on a named person's ruling.
· a CARD ID goes with the name: BUILD-REGIONS-MAP-RESTORE-EDGE-SCROLL-STRIPS
and the round-2 test numbering are internal handles that mean nothing to a
reader of a published bundle.
· a SCOPE RULING is stated as scope: "improve the map's LOOK and add the
all-regions map, which must be intuitive" — the constraint survives, the
quotation does not.
Nothing lost that a reader needs: every measurement, pixel value, file:line
citation into DX-life's source and engine-specific caveat is untouched. What went
is a name, four dates and two internal ids.
SAME PROOF AS THE CITATION COMMIT, because a prose sweep across eight files is
exactly where a stray edit hides:
· the only non-comment changes are an HTML comment in a template and the "note"
field of regions-mfe.meta.json — and `note` is read by nothing: generate-tree
never touches it, and the string does not appear in the built manifest.
· meta.json still parses.
· rebuilding regions-dashboard and country-map reproduces the committed bytes
EXACTLY, and a full `bun run tree` leaves public/ clean — so NO bundle needs
rebuilding for this.
· gates:pre 66/66.
gospel's citation rule, applied to mine: a comment citing a PROBE or MEASUREMENT
(_probe-*, _chk-*) states the FINDING and drops the path. A reader cannot open
_qa-cycle from a published bundle, and the path outlives the file — so the number
has to be in the comment or it is lost.
FOUR CITATIONS, all of the probe kind, so all get the same treatment. Every one
already carried its finding; only the dead path is removed:
RegionCountryMap:36 three engineFor implementations "decide identically in
every engine combination" — now says WHICH combinations
were checked (mapbox-only, maplibre-only, both globals
present) rather than naming the script that checked them.
RegionCountryMap:462 the globe-fill measurement keeps its numbers (zoom 1.229
-> 390.61px = 50.1% of the frame, half the frame dead
space) and loses the probe path.
RegionCountryMap:552 the fit-at-construction comparison keeps both readings
(bounds-at-construction zoom 3.210 / mercator at t=0 vs
fitBounds zoom 2.777 / globe) and the engine version.
region-dashboards:522 "4 of 10 app properties OK", with the five failures that
were one missing thing already spelled out below it.
I did NOT only fix the one line I was pointed at: I swept my own plugin files,
the six library files I own or moved this session, and the seven gates I wrote.
Four citations total, all in these two files; the gates and the other library
files carry none.
COMMENTS ONLY, and proved rather than asserted: the diff filtered to non-comment
lines is empty; check:ported-globals-identical still reports 4/4 byte-identical
emitted CSS; and rebuilding regions-dashboard and country-map reproduces the
committed bytes exactly — comments are stripped at build, so NO bundle needs
rebuilding for this and public/ stays untouched. gates:pre 66/66.
scripts/strip-code.mjs:5 is deliberately left alone: per the same ruling it
explains why a committed gate cannot import from the scratch area, which is a
fact about the boundary rather than a citation across it.
Five physical CSS declarations in the chrome every production map renders, so
under Arabic each of them lands on the wrong side for every map at once:
SemanticTreeLegend's active tab chip drew its border on the left, the share
menu's step list padded from the left, its tab spacing and its auto-spacer both
pushed from the right, and the geocoder's search icon sat at left:7px inside an
input that mirrors. All five become logical properties, which in a left-to-right
reading resolve to exactly what they resolved to before: four insertions against
four deletions in the components, one property each, and the English view cannot
move.
The reason they survived is the more useful half. check-legend-logical-insets
existed and passed, but it read only library/components/legends -- the share menu
is one directory away, and a reader in Arabic does not know which folder a control
came from. So the scan now covers map-controls as well.
Widening it was not enough. It still passed, because its CSS extractor matched a
template literal with the pattern `([^`]*\{[^`]*\}[^`]*)`, which describes a
literal containing ONE brace pair. ShareButton keeps its styles in a 600-line
useShadowStyles literal with hundreds, so the scan captured seven fragments
totalling 7,003 characters of a 629-line file and never saw .share-tab at all. It
was reporting PASS on a directory it was barely reading, which is worse than
having no check: a green gate is read as evidence. Template literals are now
taken whole and kept only when they look like CSS -- 2 blocks, 8,462 characters,
and the rule is finally in scope.
The self-test poisons BOTH directories. Poisoning only a legend would have proved
the check works on the half it always covered, and passed unchanged the day the
wider scan silently narrowed again. Its map-controls arm puts back the exact
declaration this commit removes, and its failure message says what a failure
means.
Seven comments in the library and the app pointed at probes, checks, a report or research notes that
live outside the repository, where a reader of the code cannot follow them. Each now states what was
found and drops the path: the engine-predicate equivalence, the chooser's globe-fill measurement, the
fit-at-construction probe, the worldview clause, the web-boundaries sizes (1,866 KB to 383 KB, every
feature and part kept). The QA corner and the packet generator lose a dead pointer. The app's nav
comment states the three sections and the third dashboard directly instead of quoting a transcript.
upg100 asked for two parameters on the library chart so the packet's printed
calendar and the on-screen one are the same thing configured differently, rather
than two implementations that drift. Both are opt-in; every default is unchanged,
and the component has no other caller today, so nothing moves.
fill: 'scatter' | 'top' — WHERE the shaded cells sit.
The two answer different questions, which is why this is a prop and not a fix.
'scatter' says "these weeks fall somewhere in a life and we do not know where",
which is the honest answer for weeks lived. 'top' makes the grid a MAGNITUDE a
reader can measure against its own frame: 32 cells scattered across 5,200 are
invisible confetti, while 32 stacked from the corner are an obvious sliver. A
share page wants 'top'; a life page wants 'scatter'.
highlightRows: Number — rows marked as a band from row 1, for the printed
calendar's 33-year highlight. ROWS, not cells, because the band is read along
the year axis and a cell count would drift with the column width. Derived as
rows x columns and CLAMPED to the grid, so a value larger than the calendar
marks the whole calendar instead of overflowing into a class nothing renders.
Verified: 33 x 52 = 1716 cells, 0 -> 0, 999 rows -> 5200 not 51948.
The band is an OUTLINE, not a fill, and that is deliberate: the band and the
shaded cells have to be readable at the same time, and two fills in one 32x32
print cell are indistinguishable. A shaded cell inside the band still reads as
shaded, because --on wins the border.
topCells() is added beside scatterCells in packetCharts, not inside the
component, so the packet's Node renderer and the Vue chart share ONE
implementation — which is the point of the request. It takes the same (total, n)
signature as scatterCells so a caller can swap one for the other without
reshaping anything, is deterministic with no seed (there is nothing to
randomise), and clamps: topCells(10, 99) is 10 cells, not an error.
gates:pre 66/66.
Two reported mobile bugs (DRV1650 #3 and #4), both settled by rega's
measurements at 390x844 on dx-life /research rather than by reading source.
★ #3 — THERE WAS NO BACK-TO-TOP AT ALL, and I had reported otherwise. I said
VerticalTabsLayout already rendered one targeting the active .vtl-host. rega:
ZERO .vtl-host elements in the live build, button.btt ABSENT from the DOM. The
chain: VTL renders .vtl-host only for `mountedElementRows`, which filters rows
carrying an `element` key — and this SPA passes :groups whose rows carry none,
mounting its own .dms-pane panes imperatively. So activeHostEl is null, and the
layout's button correctly renders nothing. That button is for layouts hosting
their own element rows; it is inert for the composition the gallery uses. I had
read the source and not the configuration.
The SPA now renders its own, bound to the ACTIVE .dms-pane — the scroller rega
measured (clientHeight 712, scrollHeight 12,373, overflow-y:auto) while the
document does not scroll at all. The gallery owns both the circle and the
scroller, so no shadow-root walking is needed. Placement reads --map-fab-beside,
published by the layout only while its circle renders, so the arrow sits BESIDE
the circle and falls back to the plain inset where there is none.
BackToTopButton already refuses to appear when its target does not scroll.
The label goes in all ELEVEN of the SPA's locales. Its translate() falls back to
the KEY, so a missing entry would have rendered the literal string "backToTop"
to every reader — en/ar/zh/ru/hi verified, and the Arabic and Chinese strings
are present in the built bundle.
★ #4 — THE REGIONS ATTRIBUTION SITS UNDER THE CIRCLE, and the geometry was not
what I assumed. rega: at 390 the attribution is details.maplibregl-ctrl-attrib in
the BOTTOM-RIGHT container, expanded to 347x44 at (22,791) — nearly the full map
width — so its LEFT END runs under the 44px fab at (12,788), 1,394 px² overlap.
The bottom-left container is EMPTY (0x0), so a bottom-left-only fix would have
changed nothing.
Both bottom containers are lifted off the published --map-fab-clearance under a
767px query, so a standalone leaf or a <doxa-country-map> with no gallery around
it reads the 0px fallback and nothing moves. The existing z-index:3 cannot help:
the circle lives in the layout, outside this shadow root, so the two are
different stacking contexts. Attribution is a LICENCE TERM of the basemap, which
is why this is a fix and not a polish item.
REBASED ONTO 0428f279c BY REPLAYING SOURCE, not by resolving a conflict: the
rebase collided only in generated bundles, where a conflict is never resolvable
by hand. Three files applied clean; RegionCountryMap.vue had MOVED to
library/components/maps in that same round (my own country-map work), so its rule
was re-applied at the new path by hand — a straight patch would have failed with
"No such file or directory", which is exactly what studio would have hit.
country-map, regions-dashboard and doxa-maps-spa rebuilt; both fixes verified in
the shipped minified bundles, including the new country-map bundle that now
inherits the lift.
NOT PROVEN HERE: the headless DOM proof needs a browser (rega's seat). What I
verified is that the button, its scoped CSS and its ar/zh labels are in the
SHIPPED doxa-maps-spa.js, and the fab lift in both map bundles.
The Experimental maps' explainer lived in a "?" floating at the top centre of the app, which read
as a game piece rather than part of the map. The map already has a "?" control. This lets that
control answer from the HOST: a click dispatches doxa:help, bubbling and composed, so it crosses
every shadow root between the button and the page, and the app answers with the map's text.
library/composables/useHostedHelp.js is the one mechanism, for the HelpButton map control and for
the maps whose "?" belongs in their own bar. It also decides WHERE the button shows: only under a
host that answers (an ancestor, across shadow roots, carrying data-doxa-help), so the same bundle
embedded on a partner page shows no dead "?". HelpButton keeps its open-help emit, so
doxa-simple-map's own help popup is unchanged; hosted is opt-in.
Rings that zoom apart, as a flag, so either answer to row 130 is one line at
the call site. DEFAULT IS 'fit' — this changes nothing today.
'fit' keeps every pin at least minSep apart at the chart's own scale and reports
what will not fit. That is the right behaviour for a PRINTED sheet, where there
is no zoom and a dot a reader cannot resolve is a dot that is not there.
'fill-all' places all of them. Separation is in map units, so it grows with zoom:
measured on the shipped table, the worst ring is ring 1 (1,190 groups in the
smallest circle) separating to 8px on screen at z=4.45, and ring 8 (1,932) at
z=3.50. The GROUP label tier already starts at 5.2, so every pin is clickable
before the names appear. At the start zoom the band reads as a dense arc of
colour rather than countable dots, which is the intended appearance — and the
reason a caller in this mode must caption the true total rather than a count
that changes with zoom.
The row count had to follow the POPULATION here, not the pin size. Keeping the
'fit' row count would have packed 1,932 pins into the two rows a band allows at
full size, and they would have sat almost on top of each other along the arc —
the single-line defect again, one level down. Ring 8 uses 14 rows in fill-all
against 2 in fit.
Real table: fit places 1,851 of 7,296, fill-all places 7,296 of 7,296, both with
zero pins outside their band. Six new assertions, and the row-growth arm is
poison-tested: making fill-all reuse the fit row count fails it at "2 rows vs 2".
Found by running the SHIPPED table through the layout instead of the uniform
sample the module was built against. The real near-neighbours data is heavily
skewed — ring 8 carries 1,932 groups, ring 5 carries 100 — and against it
bandFill placed 962 of 7,296. It dropped 87% of the map, and every drop was
faithfully reported, which is exactly why no assertion caught it: all 16 asked
"is the overflow reported?" and none asked "is the CAPACITY right?".
The capacity model counted one circle of pins at the band's innermost radius. A
band has depth, so it holds several concentric rows, each with its own
circumference. Counting them is a ~2x correction at ringGap 24 and more at
larger gaps; on the real table it takes placement from 962 to 1,851 on the gate's
geometry and every under-full ring now places everything it is given.
Placement had to follow: a hashed radius with one angular step was fine while a
ring held a dozen pins and wrong once it held hundreds, because two pins could
take nearly the same angle at nearly the same radius while the capacity sum
insisted there was room. Pins are now dealt round-robin into rows, each row
evenly spaced across the usable arc, staggered a quarter slot so adjacent rows
do not line up radially and read as spokes.
Two bugs found on the way, both by the gate failing honestly:
- the stagger first added half a step to every odd row, which pushed that row's
last pin past the end of the arc and into the label's keep-out — 7 overlapping.
Staggering WITHIN the slot keeps every pin inside its own slot.
- the shrink arm tested at 200 groups, which only crowded a ring while capacity
was undercounted. It now tests at 2,000, the honest pressure for a 7,296-row
table, and still reaches the pin floor and reports.
The new capacity arm asserts a RATIO against the single-row count, not a magic
number: its first version hardcoded ">400" from the component's ringGap of 22
while the gate builds radii at 24 — the code was right and my threshold was
guessed. Poison-tested: forcing rows back to 1 gives 1.05x and fails both arms.
layoutCenteredSet gives every group on ring N the same radius, so a crowded ring
draws as one line of dots on a circle. The target needs each ring to be
a BAND, groups spread evenly through it, never overlapping each other or the
ring's label.
library/components/charts/ringBands.js is that geometry, with no Vue, no engine
and no DOM, so the Wall poster can consume the same module rather than growing a
second copy of it. bandFill() places; curvedRingLabels() returns each label's arc
AND the keep-out that bandFill refuses to place inside — computed from one source
so "pins never overlap the labels" is true by construction rather than usually.
Determinism is a requirement, not a nicety: every position is a pure function of
the item's own id, so two printings of one packet compare sheet to sheet and a
dot that moves means the DATA moved. There is deliberately no seed. Input order
does not affect any position either, so a caller that filters or re-sorts does
not reshuffle the chart.
An over-full ring SHRINKS its pins to a floor before anything is dropped — a
visible pin beats an unplaced one — and only then reports the remainder with the
ring and the counts.
check:ring-bands, 16 assertions, every arm with a negative control. One arm is
there because this gate passed the bug it now catches: uncapped, a long label on
ring 1 wanted 96% of that small circle, so capacity was zero and all twelve
groups on the innermost ring were reported unplaced while every arm stayed green
— "reported, not silently dropped" is not the same promise as "the ring draws".
Labels now truncate at a share of their own circumference and report how many
characters fit. Poison-tested: restoring the old share reproduces "empty rings: 1".
Seven shadow-root components each carry their own copy of DX Life's
base/_globals.scss rules, because page CSS cannot reach into a shadow
root. Measured first: where the copies overlap they are byte-identical
(the Cyrillic stack 3x, strong{font-weight:500} 2x, scrollbar-gutter 2x),
so this is deduplication, not a fix.
The module exports FUNCTIONS, not strings, and the measurement forced
that: the six copies of the Cyrillic rule share one declaration but have
six different subjects (.rd-goal-card__title, .rd-h2, .rd-title,
.region-card__title, .rl-title x2). A fixed rule string would have to
pick one class and silently stop styling the other five. The caller owns
its selector; the module owns the declaration.
cyrillicHeading takes a selector LIST, not a selector. GoalCard styles
one heading class; RegionSectionCard groups two into a single rule
sharing one declaration block. A one-selector signature emits two rules
instead of one -- the same CSS to a browser, but not the same
stylesheet, and the bar here is unchanged, not equivalent.
Migrated GoalCard and RegionSectionCard. regions-dashboard's two files
port the same rules and stay with their owner; the harness records them
so that migration can run against this same baseline.
The img border-radius rule (_globals.scss:80) is documented as
deliberately NOT ported, because an invisible omission is one that gets
"fixed" later.
PROOF: internal/check-ported-globals-identical.mjs executes each
component's setup, captures the string handed to useShadowStyles and
hashes it. The emitted CSS is byte-identical across the refactor
(987d94b1cd66110d, 897d948b17b21efe, both captured from 45e31f102).
The harness's first version hashed the SOURCE of the call and was
wrong: this refactor replaces a literal with a function call, so the
source changes by construction -- a text-hash gate fails a perfect
refactor and cannot tell that from a broken one. It measured the
artifact when the property under test was the output. It failed exactly
that way on first run.
Shown capable of failing, four ways: dropping letter-spacing, a wrong
font name, 500->600, and splitting the grouped rule into two. All four
go red; restoring goes green. Components that cannot be evaluated are
reported NOT MEASURED (exit 75), never as a pass.
The caret (C11) and the reduced-motion gate were written in parallel, and only together did the gap
show: the new caret animates its arrow (transform 0.18s) and its colours with no reduced-motion rule.
The rule sits in the useShadowStyles string, where a shadow root can see it. The arrow still flips
to the right direction; it only stops turning on the way there.
Seven components with no importer, removed. The interesting part is not the
deletion, it is that an import census was NOT sufficient to authorise it, and
two other agents proved that rather than me.
WHAT AN IMPORT CENSUS MISSED. internal/check-gospel-set-charts.mjs iterated
['BoundedSet.vue', 'CenteredSetTarget.vue'] as literal STRINGS and read their
props off disk. No import, so no census and no build would have warned; deleting
them would have turned a gate red on main with nothing to explain it. gospel
found that in their own file after I asked, and retargeted the arm at the
components that actually render (GiBoundedSetMap / GiCenteredSetMap, 2d2e853a7).
Their retarget also fixed a quieter defect: the seed guarantee had been asserted
against two files NOTHING renders, so it would have kept passing while the
shipping component grew a seed prop.
I did not take either agent's word for it. Verified gospel's retarget on their
sha and ran their gate green before deleting; verified upg100's structural claim
that packet-render.js cannot mount a component at all — 0 resolveComponent, 0
defineAsyncComponent, 0 dynamic import(), with the same probe returning a hit
elsewhere in library so an empty result is a real absence.
DELETED: charts/LanguageTreeChart.vue, components/charts/VisionFunnel.vue,
components/charts/BoundedSet.vue, components/charts/CenteredSetTarget.vue,
components/legends/ListSelectorLegend.vue,
components/dashboards/DashboardBreadcrumb.vue, composables/useMapSelection.js.
NOT DELETED, and worth stating because the names collide: the lowercase
boundedSet.js / centeredSet.js HELPERS are load-bearing — packet-render.js and
GiBoundedSetMap import them directly, and check-gospel-set-charts still does too.
Also left alone: colors/resource.js (a colour STRATEGY resolved by MODE NAME
through _registry.js, 'resource' appearing in mapStore.js and GiResources.vue)
and layouts/app-wrapper-material/nav.js (115 mentions). Both look dead to an
import census and would break a working map.
check:no-dead-library scans for a filename as a STRING across internal/,
library/ and plugin-designs/, and:
· DISTINGUISHES A READ FROM A COMMENT (gospel's ask). check-packet-variants.mjs
discusses CenteredSetTarget.vue in prose while asserting against JSON. A
comment hit is reported, never fatal — a gate that cannot tell the two apart
cries wolf once and is then ignored. It currently reports 4 such mentions.
· IS TWO-SIDED (upg100's ask). It also asserts a KNOWN-PRESENT dynamic
reference is found — LegendDesktop lazy-loads LegendMobile through import().
Mutation-tested: blinding the scanner makes it FAIL on that control instead
of passing with "no references found", which is what a one-sided version
would have done.
· ignores a longer identifier that merely contains the name, so GiBoundedSet.vue
is not read as a reference to BoundedSet.vue.
Both arms mutation-tested and every mutated file restored byte-identical.
Registered in all three places and verified by re-reading the parsed
package.json — the suite insert silently hit the scripts KEY instead of the suite
string on the first attempt, which verifying caught and trusting would not have.
17 of 25 library components that ship motion carried no reduced-motion rule, so
they animated regardless of the reader's OS setting.
The reason it cannot be fixed once, centrally: a @media (prefers-reduced-motion)
rule in the host document does not cross a shadow boundary, and these render
inside one. Measured while surveying: NO generated leaf page ships such a rule
at all — 0 occurrences across the whole built tree — so there was not even a
page rule to inherit. Each component owns its own.
CSS-ONLY AND ADDITIVE. Nothing but new @media blocks; no existing rule changed.
Verified by diff: 0 real removals across 17 files. The two lines git reports as
removed are a trailing comma moving as a new string was chained onto an existing
concatenation, and both original rules are still present — checked by grepping
for them, not by reading the diff.
WHERE MOTION CARRIES MEANING, it is kept rather than hidden:
· SemanticTreeLegend's spinner MEANS "loading". Stopped dead it is a static
ring that says nothing, so it is slowed to a step crawl (2.4s, steps(8)) —
visibly working, no smooth spin.
· LegendProgressBar's only motion is a hover filter, so it simply stops; the
bar's FILL is geometry, not animation, and still reports its value.
The rule goes where each component's styles actually go, and that varies more
than expected: 13 append into a useShadowStyles template literal, 3 into
string-concatenated sheets, and VerticalTabsLayout into its VTL_SHADOW_CSS
constant AND its duplicate <style> block, because its own header says the two
copies must stay identical.
check:reduced-motion asserts both halves, each mutation-tested to fail before I
trusted a pass: strip the rule from an animating component and it fails; move a
rule from the injected sheet into a <style> block and it fails too, because on a
useShadowStyles component that rule would never reach the shadow root. Both
mutated files restored byte-identical.
Two of my own errors caught by tooling rather than by review, and worth naming:
my first patch asserted useShadowStyles existed in every target and RAISED on
VerticalTabsLayout instead of silently skipping it — the assert-the-anchor habit
from the last registration failure working as intended. Then I appended CSS
after the label argument on two concat-built sheets, corrupting the injection
key; check:shadow-css-parses named both files and the fix was to insert before
the label. A gate I did not write caught what I got wrong.
Registered in all three places and verified by re-reading the parsed
package.json.
Three copies of the same control: SemanticTreeLegend's .stl-collapse-btn, and
two in LegendDesktop — the data-view caret and the detail-mode button, whose own
comment says it "Matches SemanticTreeLegend's .stl-collapse-btn look". It
matched the look. A control is not its look, and the copies lost both of the
parts that do not render:
· aria-expanded. A disclosure control that never reports its state leaves a
screen-reader user with a button called "toggle legend" and no way to learn
whether the legend is open. This is the same defect I fixed in LegendMobile
last week, in a component sitting in the same folder as the fix.
· the RTL arrow. STL carries .stl-collapse-btn:dir(rtl) svg{rotate(0deg)}
because a panel that slides off the other edge needs its caret to point the
other way. LegendDesktop has no :dir(rtl) rule at all, so in Arabic its
caret points AT the panel it would open.
The label also now names the ACTION rather than the control: collapseLegend /
expandLegend instead of a fixed "toggle legend". All three keys were already
translated in all eleven locales, so this adds no translation debt.
THE ARROW AND THE ARIA COME FROM ONE PROP. They drifted apart because they were
two independent pieces of markup that happened to agree; `open` now drives both,
so a future copy cannot get one right and the other wrong.
STATE STAYS WITH THE CALLER. Two consumers drive collapse from uiStore's
tri-state cascade (fullyOpen -> open -> collapsed) and one from a local boolean.
A shared control that quietly adopted either would have broken the other, so
this one owns no state and no persistence — it renders what it is told and emits
the intent.
TWO THINGS DELIBERATELY UNCHANGED. The detail-mode control was an INVISIBLE
28x28 hit target — transparent fill, no border, transparent colour — over the
panel's corner; making it suddenly visible is a look change nobody asked for, so
the slot keeps the position, the size and the invisibility, and only the
semantics improve. And LegendDesktop's four light/dark theme rules stay in
LegendDesktop: they are its themes, not the control's. They outrank the shared
base on specificity (0,3,0 and 0,2,0 against 0,1,0), so which sheet is injected
first does not decide the colour.
Caught while writing it: the first draft styled the caret with <style scoped>,
which defineCustomElement silently DROPS for any child loaded through
import.meta.glob — it would have rendered unstyled in every shadow root while
looking right in the source. That is what useShadowStyles exists for, and what
every sibling legend component already does.
DX-life's country pages render nothing where a map belongs. The map they need
has been in this repo since the regions clone landed — it was just unreachable
without loading the whole regions dashboard, a bundle that fetches the people
group list, builds four stat cards, a region chooser and a table, none of which
a country page wants. So this is not a new map. It is the same component, given
its own element and its own bundle.
<doxa-country-map profile-config='{"countryCodes":["TCD"],"colorByPrayer":true}'>
RegionCountryMap and useRegionsI18n move to library/ because they now have a
second consumer, which is the only thing that earns a place there. Six import
sites updated in this commit; nothing else moved.
THE CONTRACT arrives through profile-config, the channel every element here
uses, so a generated page and a hand-written host page are configured the same
way: countryCodes, center, zoom, bounds, colorByPrayer, researchUrl,
prayBaseUrl. Two translations happen at the edge rather than by widening the
component — colorByPrayer is a boolean here and colorMode a string inside, and
countryCodes accepts an array OR "TCD,NER,MLI", because an HTML attribute is a
string and JSON-inside-an-attribute is the shape people get wrong.
THE ELEMENT FETCHES ITS OWN PINS, and that is what makes prayBaseUrl part of the
contract rather than decoration. An element handed a country code, a base URL
and no way to fetch would draw a country outline with nothing inside it — the
blank page it exists to fix, in a new disguise. Same endpoint, same fields, same
mapping as the dashboard; it asks for a country instead of the world.
THE FIELD LIST TRAVELS WITH THE MAPPING, in peopleGroupPoints.js. The obvious
version of this commit asks for PEOPLE_GROUP_LIST_FIELDS — the list the stats
need — and every pin still draws, every popup comes up empty, and nothing logs
anything. The popup half is what makes it a map you can read, so the two are
one export and neither can be requested without the other. The dashboard's own
request is unchanged: the concatenation produces the identical string.
countryUrl is additive, not in the confirmed contract. The component's country
link needs a base and DX-life's country pages live under /regions/; a host that
sets nothing gets that documented default.
Seven components with no importer, removed. The interesting part is not the
deletion, it is that an import census was NOT sufficient to authorise it, and
two other agents proved that rather than me.
WHAT AN IMPORT CENSUS MISSED. internal/check-gospel-set-charts.mjs iterated
['BoundedSet.vue', 'CenteredSetTarget.vue'] as literal STRINGS and read their
props off disk. No import, so no census and no build would have warned; deleting
them would have turned a gate red on main with nothing to explain it. gospel
found that in their own file after I asked, and retargeted the arm at the
components that actually render (GiBoundedSetMap / GiCenteredSetMap, 2d2e853a7).
Their retarget also fixed a quieter defect: the seed guarantee had been asserted
against two files NOTHING renders, so it would have kept passing while the
shipping component grew a seed prop.
I did not take either agent's word for it. Verified gospel's retarget on their
sha and ran their gate green before deleting; verified upg100's structural claim
that packet-render.js cannot mount a component at all — 0 resolveComponent, 0
defineAsyncComponent, 0 dynamic import(), with the same probe returning a hit
elsewhere in library so an empty result is a real absence.
DELETED: charts/LanguageTreeChart.vue, components/charts/VisionFunnel.vue,
components/charts/BoundedSet.vue, components/charts/CenteredSetTarget.vue,
components/legends/ListSelectorLegend.vue,
components/dashboards/DashboardBreadcrumb.vue, composables/useMapSelection.js.
NOT DELETED, and worth stating because the names collide: the lowercase
boundedSet.js / centeredSet.js HELPERS are load-bearing — packet-render.js and
GiBoundedSetMap import them directly, and check-gospel-set-charts still does too.
Also left alone: colors/resource.js (a colour STRATEGY resolved by MODE NAME
through _registry.js, 'resource' appearing in mapStore.js and GiResources.vue)
and layouts/app-wrapper-material/nav.js (115 mentions). Both look dead to an
import census and would break a working map.
check:no-dead-library scans for a filename as a STRING across internal/,
library/ and plugin-designs/, and:
· DISTINGUISHES A READ FROM A COMMENT (gospel's ask). check-packet-variants.mjs
discusses CenteredSetTarget.vue in prose while asserting against JSON. A
comment hit is reported, never fatal — a gate that cannot tell the two apart
cries wolf once and is then ignored. It currently reports 4 such mentions.
· IS TWO-SIDED (upg100's ask). It also asserts a KNOWN-PRESENT dynamic
reference is found — LegendDesktop lazy-loads LegendMobile through import().
Mutation-tested: blinding the scanner makes it FAIL on that control instead
of passing with "no references found", which is what a one-sided version
would have done.
· ignores a longer identifier that merely contains the name, so GiBoundedSet.vue
is not read as a reference to BoundedSet.vue.
Both arms mutation-tested and every mutated file restored byte-identical.
Registered in all three places and verified by re-reading the parsed
package.json — the suite insert silently hit the scripts KEY instead of the suite
string on the first attempt, which verifying caught and trusting would not have.
Diffing what the lightweight packet ACTUALLY prints on each path against
its card (map, triangle, profiles, wheel, points) found the wall path
missing two pages and half of a third -- while the legend path matched.
Same class as the sets page: the renderer could draw every page, and
only one of the two hosts supplied the arguments.
Three gaps, three suppliers:
The map. printPacketAs passed no mapSvg and there was no default, so a
wall-printed packet opened on a title-only cover. printPacket now lifts
the bundle's own poster (posters/<id>.html, the same file printPoster
already reads) when the host passes nothing -- via a liftPosterSvg
helper shaped for unit-testing against a real generated poster, no
fetch. Verified on laos: the svg is found and lifted. And PosterWall now
actually passes assetsBase into its print opts; without that one line
the lift's guard would skip silently and the wall would STILL print a
mapless packet -- the seam-without-supplier failure one layer down,
caught in the same pass.
The triangle. No funnel was supplied and none defaulted. The default
states ONLY the steps: listCount and unassigned stay absent, so
funnelPage omits the share line and the tip rather than printing a
denominator that is wrong under a non-default list. Proved two-sided:
steps-only renders the triangle with no share claims; adding listCount
renders both.
The profiles. The wall's fetch carried primary_language but not
imb_gsec, so every wall-printed description ran at half length -- the
third fetch site with the same missing-field defect, found the same way.
setsPage landed as a renderer capability with no data supplier: it
returns '' without data -- correctly -- which meant the FULL-DETAIL
packet had never once printed its distinguishing back page in real use.
The full-vs-lightweight split existed in code while the full packet was
missing the page that makes it full.
packetSets.js is the supplier: a pure decode+join with zero imports, so
the identical function runs under Node (the generator reads the table
from disk) and under Vite (the browser fetches it) and the join cannot
fork between the two. The table stays with the caller -- the same seam
as strings, christian and describe.
The join is measured, not assumed: 2,091 of 2,107 groups match by slug,
rings distribute 1-10 with real shape, and the 16 unmatched are inked as
UNKNOWN on the bounded figure rather than guessed onto a side --
an un-inked category reads as zero on paper.
Wired at every path that prints a packet: the offline generator (all 28
zones verified carrying the page, e.g. laos: 0 inside, 94 outside, 1
unknown; 94 placed with "25 same dialect, same people"), the legend
Export menu, the poster wall, and the selection packet -- the last three
through one opts.sets loader fetching the SAME served asset-pool copy
the generator reads, so an embedded bundle prints the same page as the
dev tree.
The decode is a third reading of near-neighbors.json's cols/dict format
(nearRows.js documents being a copy of upg-neighbor's, pending promotion
to library/). Flagged in the header rather than hidden: when the loader
is promoted, the decode here collapses into it and only the join
remains.
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.
Every toggle in this file carried aria-label="aria.toggleLegend" and no
aria-expanded, so a screen-reader user was told the control is a toggle and
never told whether the legend was open or closed. SemanticTreeLegend has had
this right all along; this is the copy that drifted, and it drifted on whether
the control is usable without sight rather than on how it looks.
gospel found it while measuring four implementations of the same control. I
verified it here before changing anything rather than taking the report: zero
occurrences of aria-expanded against four toggle labels.
One computed drives all four buttons, so they cannot disagree with each other
about a state they all describe — which is the same failure, one level down,
as four files disagreeing about the control.
This is the accessibility half of the C11 collapse-caret promotion and does not
wait for it: the promotion is a refactor, this is a defect.
Round 2 item 6. The control was already built in PosterWall -- state,
persistence, grid binding, markup -- and gated behind an opt-in prop that
only the regions dashboard passed. So most of this is enabling it rather
than writing it, which is what "reuse the pattern, don't re-derive" asked
for. I checked that before building anything, because the last time I
added a feature without checking which surface already had it, it landed
somewhere nobody looks.
Auto is genuinely new: the choices were 2 to 5, always forcing a count,
and the brief asks for Auto meaning today's CSS with overrides on top.
gridStyle already returned {} for a non-positive value -- the comment at
that line even says so -- so Auto needed no new layout branch. It needed
to become REACHABLE: a 0 in the choice list, a label, a read path and a
write path. It extends the earlier min-2/max-5/default-2 ruling rather
than contradicting it, since those bound the counts and Auto is the option
that declines to pick one.
Round-tripping the storage values, rather than reading the code, caught a
bug I had just written: getItem returns null when nothing is stored, and
Number(null) is 0 -- which is exactly Auto now that 0 means something. So
every first-time reader would have silently got Auto instead of the
default, and merely ADDING an option would have changed the layout for
everyone who never touched the control. A missing key now returns null and
falls back to the default; a stored 0 reads as Auto.
Auto is also persisted, which needed its own clause: the range check
rejects 0, so without it the one value that is not a count would be the
one value that never survived a reload. A preference that forgets only
some of its values is worse than one that forgets all of them, because the
reader cannot tell it is broken.
The Prayer-list dropdown went into PosterWall's per-card print row. The
profile people open -- upg-100-list -- does not render PosterWall at all;
its print path is the legend's Export menu, which still offered two items
and printed immediately. So the feature was built, landed and served, and
invisible on the only screen it was asked for.
Two surfaces legitimately offer printing and both carry a button labelled
Prayer list, so the label was not enough to tell them apart. The grep that
would have caught it -- does this profile mount that component -- takes
five seconds and I never ran it.
The legend's packet item is now a disclosure: it is named UPG 100, it
explains what a UPG 100 list is, and it reveals Full detail and
Lightweight. The explainer matters because this menu item is the only
place a reader meets the idea, so it wraps rather than truncates.
The submenu opens BELOW its trigger by construction, not by positioning:
the menu is a flex column, so a block after the button flows underneath
it. A panel that grows its parent cannot cover its parent -- which is
exactly what the card-deck row could do, hanging out of its card at
top:100% with nowhere to go on a short viewport.
emitExport widens its existing string to packet:full / packet:lightweight
rather than adding a second event; poster and a bare packet still work, so
an older consumer keeps working and one event means nobody can handle only
half the menu. printPrayerPacket takes the variant and forwards it to
printPacket, which already accepts it.
UPG 100 is allowed to be identical in all eleven locales, recorded in
check:i18n-never-translated as a brand name rather than as debt: it is not
waiting on a ruling, it IS the ruling, and the explainer beside it carries
the meaning per locale. Inventing eleven names for one product would be
the failure that gate exists to prevent.
On mobile the size options sat on top of the words they belong to, so the
Card deck label disappeared behind its own menu.
The row hangs at top:100% and is then pulled back up by half the print
row's height. The reasoning was sound and the arithmetic is right at
exactly one line: the footer sticker half-overhangs the banner edge, so
half a row is the correct clearance. It stops being true the moment the
row wraps -- on mobile it takes two lines, and half of a doubled height is
no longer half a row, it is the labels.
A FRACTION OF A VARIABLE HEIGHT IS THE BUG. The sticker's overhang is a
constant, so the offset is now the full row height minus that constant,
which is correct at any number of lines. Measured at one, two and three
lines: the old rule overlapped by 10, 20 and 30px, covering labels from
two lines on; the new one overlaps by a constant 11px -- the sticker
alone -- every time.
Same shape as the box-sizing error on the packet's table rows: a value
that happens to be right at one size and is silently wrong at every other,
which is why it only showed up on a phone.
The follow-up dx-shepherd queued: wire retryFetch into the library fetches with no
failure handling, and skip any that handle failure deliberately. Four qualified, one did
not, and the distinction is the work.
WIRED, because each already handles an HTTP error and NOT a transport one:
usePosterPrints the poster HTML — checks .ok, so a 404 is deliberate; a network throw
went uncaught and the poster simply failed
useSharedMapToken the token service — its .catch degrades to '' by design, but the
result is MEMOISED one-fetch-per-URL-per-page, so a single blip meant
no token for the whole page and a tokenless Mapbox map
useCsvExport the bulk list — .ok checked, transport throw blanks the export
PeopleGroupDetail the detail API — .ok checked, transport throw blanks the panel
In every one the deliberate degradation is unchanged; it now simply survives a blip
first. retryFetch returns a non-ok RESPONSE untouched, so no .ok path moves.
NOT WIRED, and named in the gate rather than silently skipped: usePosterPrints' probe for
an OPTIONAL pre-rendered .webp. A miss is the EXPECTED path there — it falls through to
generating the poster — so retrying would delay the fallback three times over for an
asset that is legitimately absent. That is failure handling that means something, which
is exactly what the instruction said to leave alone.
The gate now covers all eight sites and carries the exemption as a COUNT for that one
file, so a second bare fetch appearing there still fails. Negative-tested: reverting any
wired site to bare fetch turns it red.
Per the 17:25Z rule: no quotes, no citations and no transcript references
in strings, commits or code. Studio had to strip these from two of my
commit messages during L64, which is a sign I should be doing it at the
source rather than leaving it to whoever lands the work.
Nine sites across five files, all of them lines I added. The engineering
content is unchanged -- what goes is the attribution, not the reasoning.
"A disabled button is visually indistinguishable from an unresponsive one"
is the durable fact; who observed it is provenance a later reader cannot
verify and that ties shipped code to a private transcript.
Where a decision is genuinely still open I keep the tracking reference
("still open, tracked as row 89") but not the speaker, because a reader
hitting that comment needs to know the value may change and where the
answer will come from.
Scope is deliberately my own unlanded additions. packet-render.js,
PosterWall.vue and upg-100-list.vue carry many more such comments from
before the rule; those are not mine and a fleet-wide sweep is a separate
decision. Flagged rather than silently rewritten.
All seven gates green after the edit, including the two whose own header
text changed.
The previous commit did not parse, and I pushed it: check:sfc-parse exited 1 and
I read the exit code after the push instead of before it. The gate names this
exact cause in its own failure text — a backtick inside the shadow-CSS template
literal ends the literal, so everything after it is read as code. I had written
the word dark in backticks inside a CSS comment. The gate was right, it was
specific, and it was already telling me.
The second defect had no symptom a gate could show. The note is a direct child
of .legend-sheet-root, and .legend-sheet-root>* re-enables pointer events for
every direct child at the same specificity as the note's own rule and AFTER it,
so that rule won. The note would have swallowed a map drag across the full width
just above the sheet — the precise thing its comment claimed it must never do.
Stated now after the rule that would otherwise override it.
Both are the same shape: I wrote a claim in a comment and did not check the
claim was true of the code beneath it.
The Driver scrolls further than he should to reach the legend options on a
phone, and the note under the legend is why — but not by pushing them down. It
was the last child of .legend-content, a natural-height sibling of a flex:1
options panel, so every pixel it took was a pixel the options' own scroller
lost. The tree got a shorter viewport and the same rows moved further away.
Moving it WITHIN the sheet would have recovered nothing, which is worth stating
because it was the obvious reading of the instruction: the sheet's open height
is fixed at 30%, so anything inside it costs the options the same wherever it
sits. Out here the note costs them nothing and they keep the whole sheet.
It is positioned off --legend-sheet-h, which a ResizeObserver already writes for
the engine's own bottom controls, so it tracks the sheet as it opens, drags and
collapses instead of assuming a height. It stays pointer-events:none with the
sheet root: it is text, nothing in it is tappable, and it must never swallow a
map drag.
The note is kept, not removed — he accepted the text and objected only to it
breaking the legend. Its scrim follows `dark` for the same reason the note
itself takes that prop: a white halo behind light text is a smear rather than
legibility.
NEEDS EYES at 390x844: whether it reads over an arbitrary basemap, which is the
one thing moving it out of a white panel puts at risk.
On mobile the Card deck options tape slid under the cards below it.
.pstocks hangs at top:100%, i.e. below its own card and over the next one.
Every .card is position:relative with NO z-index, so they all sit at the
same stacking level and later cards paint over earlier ones in document
order. The tape's own z-index:2 only orders it WITHIN its card, which is
why it looked correct on the last card of a row and vanished under the
next card everywhere else. On a phone the grid is one column, so the very
next card is directly beneath -- hence "really bad on mobile" rather than
merely wrong.
Raising the CARD while one of its tapes is open lifts the whole stacking
context above its siblings. z-index 5 sits above any card and below the
sticky section header at 6, so a revealed tape never covers the header the
reader scrolls with -- a menu that hides its own navigation would be a
worse bug than this one. The tape also gains a stronger shadow so it reads
as sitting ON the card below rather than tangled with it; without an
opaque, shadowed surface the next card's text shows through, which is the
other half of what "underlapping" looks like.
Covers both tapes -- the card sizes and the new packet variants -- since
they share the disclosure.
A back-to-top on mobile, next to the hamburger circle, for the people-group grid and the regions list. Both scroll their own container,
so the container is a prop and this component owns only the affordance.
It does not know where the circle is, and must not guess. .vtl-fab is 44px at a
12px inset today, and it lives in the layout outside every plugin's shadow root.
Hardcoding 64px here would put the two on top of each other the day that changes,
silently, on a phone. So the layout publishes --map-fab-beside alongside the
clearance it already published, and this reads it — falling back to the plain
inset when there is no circle, which is also the standalone case where nothing
sets it. The circle's geometry is stated once, by the component that owns it.
It also refuses to appear when it cannot work: visible tests that the container
actually scrolls, not only that scrollTop is large. An arrow that does nothing
when pressed is worse than no arrow, because the reader spends a tap finding out.
And it honours prefers-reduced-motion, since a long list jumping is exactly the
motion that rule exists for.
The failure mode here is a var NAME drifting between publisher and consumer:
nothing throws, the fallback applies, and the button quietly sits under the
circle again. Checked rather than assumed — every published name is consumed and
every consumed name is published.
NEEDS EYES: I cannot see 44px or a hover state. The logic is small; whether it
lands beside the circle rather than over it is a render question.
The Driver scrolls a legend to the end on a phone and the last rows sit under
the hamburger circle. The circle is .vtl-fab: 44px, pinned bottom-left, rendered
only when narrow and headerless with the drawer shut.
It wins on paint for a reason no z-index can fix. The FAB lives in the LAYOUT,
outside every plugin's shadow root, so it and the legend sheet are in different
stacking contexts; the sheet is z-index 1000 and still loses. Raising the sheet
would not have worked, which is why this reserves space instead.
The layout now publishes --map-fab-clearance, and only while the button is
actually on screen; custom properties inherit through the flattened tree, so it
reaches inside each plugin's shadow root. The mobile legend's scroll area adds
that to its bottom padding, defaulting to zero. No circle, no reserved space,
and a plugin used without this layout is unchanged. The padding is additive, so
nothing that works today moves.
The button's v-if and the clearance are now ONE computed rather than two copies
of the same condition. Two copies is the bug waiting to happen: drift one way
and the legend ends under the circle again, drift the other and there is a strip
of dead whitespace with no circle to justify it. Neither has a symptom any gate
can see, so disagreement is made impossible rather than tested for.
Measured, not guessed: the earlier reading of this card found the top-left map
control at top:8px and concluded the geometry did not match the report. It did
not — that is a second, real collision in two plugins, and a different bug.
DRV1650-SHARE-MENU-MOBILE-OFFSCREEN. On mobile the share/embed menu opens off-screen to
the right on Cities with Internet and Gospel Resources, while Simple Research and Prayer
Progress are correct.
THE SPLIT IS NOT A PLUGIN DIFFERENCE. MapControlMenu resolved the area it places inside
with closest('.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]') -- three
hardcoded plugin class names. doxa-simple-map and research-map are on that list. Everyone
else resolved `|| null`, and place() answers a null area by returning an EMPTY STYLE,
which leaves .mcm{position:absolute} at its STATIC position: below-and-right of a button
already at the top right. Off the page.
So the two maps that worked worked because they were named in a string in another
component, and the bug was decided by a literal rather than by geometry. It is also the
SAME failure the 2026-09-22 dashboard fix documents twenty lines below -- not "a wrong
side was chosen" but "nothing chose anything" -- returning through a different door.
MEASURED: cities-internet, gospel-resources, key-cities and upg-neighbor carry no
map-area class and no data-map-area, so FOUR plugins were unanchored. The Driver reported
two. A fix aimed at the two he named would have left the other two, and a gate written
around them would have passed.
The named area is still preferred, because it is a real map's walls and a menu must not
outgrow them. When a plugin declares none, the anchor falls back to the toolbar's own
offsetParent -- the containing block this menu is absolutely positioned against, so
placing against it is correct by definition rather than by registration. A plugin that
adopts a class later simply gets the better anchor; nothing has to be registered anywhere
for a menu to be on screen.
check-map-menu-anchor asserts the PROPERTY rather than the list: that the resolution
cannot produce null from a real toolbar, that place() still guards a genuinely absent
one, and that a declared map area is still preferred over the fallback. Negative-tested:
restoring the allowlist-only resolution turns it red.
NEEDS EYES: this is rendered geometry and I work headless. What I can prove is that the
anchor is no longer decided by a name; whether the menu now sits correctly at 390px on
all four is rega's to measure.
DRV1650-SHARE-MENU-MOBILE-OFFSCREEN. On mobile the share/embed menu opens off-screen to
the right on Cities with Internet and Gospel Resources, while Simple Research and Prayer
Progress are correct.
THE SPLIT IS NOT A PLUGIN DIFFERENCE. MapControlMenu resolved the area it places inside
with closest('.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]') -- three
hardcoded plugin class names. doxa-simple-map and research-map are on that list. Everyone
else resolved `|| null`, and place() answers a null area by returning an EMPTY STYLE,
which leaves .mcm{position:absolute} at its STATIC position: below-and-right of a button
already at the top right. Off the page.
So the two maps that worked worked because they were named in a string in another
component, and the bug was decided by a literal rather than by geometry. It is also the
SAME failure the 2026-09-22 dashboard fix documents twenty lines below -- not "a wrong
side was chosen" but "nothing chose anything" -- returning through a different door.
MEASURED: cities-internet, gospel-resources, key-cities and upg-neighbor carry no
map-area class and no data-map-area, so FOUR plugins were unanchored. The Driver reported
two. A fix aimed at the two he named would have left the other two, and a gate written
around them would have passed.
The named area is still preferred, because it is a real map's walls and a menu must not
outgrow them. When a plugin declares none, the anchor falls back to the toolbar's own
offsetParent -- the containing block this menu is absolutely positioned against, so
placing against it is correct by definition rather than by registration. A plugin that
adopts a class later simply gets the better anchor; nothing has to be registered anywhere
for a menu to be on screen.
check-map-menu-anchor asserts the PROPERTY rather than the list: that the resolution
cannot produce null from a real toolbar, that place() still guards a genuinely absent
one, and that a declared map area is still preferred over the fallback. Negative-tested:
restoring the allowlist-only resolution turns it red.
NEEDS EYES: this is rendered geometry and I work headless. What I can prove is that the
anchor is no longer decided by a name; whether the menu now sits correctly at 390px on
all four is rega's to measure.
The ten ring names settled between gospel and exp, derived from exp's ringFor()
rather than hand-numbered so the numbering cannot drift from the code that
produces it. The screen legend already renders these words, so a printed sheet
using them cannot disagree with the screen.
Written as vocabulary in the header a caller reads, and deliberately NOT as a
prop default. One cell is still open on the Driver's row 89 — "people" versus
the brief's "culture" in column 1 — and if he keeps his, exactly three cells
change, 1, 4 and 7, and nothing else: not the numbering, not the columns, not
the colours.
A default would bake a provisional word into every consumer that forgot to pass
one, and would make this component the thing that decided a Driver question.
Same reason ShareCalendar refuses to name its own metric.
Measured on the published file: those labels run 7 to 206 characters and the
long ones are the zone's member list, not its name. Tibet's is a ten-part list
of provinces. Only two of the fourteen carry no member list at all.
Used as a heading on a printed sheet that sets a 206-character title, and paper
has no truncation-with-tooltip to rescue it. The name a reader wants is the
humanised slug, uniform by construction and about 34 characters at worst, with
the label carried separately as the definition it actually is.
This component asserts nothing about the words in labels.rings, so the whole
hazard lives at the call site — which is why the warning belongs in the file the
caller reads rather than in a message they will not see. gospel found it.
gospel's boundedSet and centeredSet move to library/components/charts, and every
importer follows in the same commit: gospel-internet's two components and the
gate. One implementation, no window in which someone edits the wrong copy.
gospel chose this over copying, for the reason the shared scatter already showed.
Two print-safe components come with them, adapted from gospel's rather than
written fresh: theirs fetch and hold board state, these take joined groups and
every label as props, so the packet and the board draw the same figure from the
same code. gospel's already inked their tallies and used no hover, so the
adaptation is the data path and the labels, not the drawing.
The labels had to change, and not for style: gospel's read T('gospelInternet.…'),
and a gospelInternet-namespaced key cannot resolve for another area's consumer,
so the packet would have rendered the English fallback in every locale. Labels
are props now.
Three invariants gospel gave me that the comments did not carry are pinned here:
No seed prop. Position is hashed from the group's own id, so a group lands in the
same place on every render, forever — two printings of one packet are identical
and a reader comparing sheets sees real movement rather than reshuffling. I had
specced a seed; it would have silently rescrambled every dot while the data was
unchanged, which on paper cannot be corrected. Asserted through the parser, and
adding one back turns it red.
Every ring is listed, empty ones included. Under a filter several of the nine go
to zero, and a decoder that omits its empty rows stops being a decoder: a reader
cannot tell "none here" from "this ring does not exist".
And an arrow is a claim about a relationship, so a group with no recorded
neighbour gets none rather than a default one toward the centre.
The unknown-is-counted rule is now in the module header where the next reader
meets it first, at gospel's request: a printed page has no hover, so a category
the reader cannot see is one they will assume is zero.
By the regions owner. An anchor week past the grid, negative or fractional passed an isFinite
guard and matched no cell, which on paper reads the same as the deliberate no-anchor default.
anchorPlacement() returns a reason, LifeCalendar prints it, and check:packet-charts covers six
edge cases while pinning that no anchor asked for stays silent.
The Driver asked for the SAME caret the cards already have on the Prayer
List, with two entries -- Full detail and Lightweight -- and said plainly
not to destroy the print options that exist. So nothing is merged, nothing
is removed, and the Poster is untouched.
There was no dropdown COMPONENT to reuse: the cards' caret is an inline
disclosure, one ref plus a sibling tape of plain buttons, deliberately not
a <select> because a native dropdown collides with other dropdowns on the
host page. So the PATTERN is generalized rather than copied. deckOpenFor
now holds a compound `${id}:${which}` key, which keeps "opening one closes
the others" free -- copy/paste would have given two open-states, and two
tapes could then sit open at once and overlap in the grid gap.
While wiring it, a real defect surfaced: THE WALL'S PRAYER LIST BUTTON HAS
NEVER PRINTED A PACKET. downloadPacket calls printList, a columnar table of
name/country/population/religion. The packet -- map, life calendar,
%-evangelical grid, triangle, profiles, wheel, points -- is printPacket,
which every other surface calls and this wall never did. That is a direct
cause of the packet "not following the design that was asked for" here: no
change to the renderer could reach a button that was not calling it.
printList stays exported for any surface that still wants the list.
The two packets are a FILTER over the existing page array, not a second
code path, so they cannot drift apart in layout, i18n, RTL or print scale.
The filter is keyed and never indexed -- an index filter reads identically
today and drops the wrong page the first time the order changes, and that
order is the Driver's. listPages is deliberately outside the filter: a
variant may drop a FIXED page, never one whose count depends on the data,
because that drops people silently. An unknown variant falls back to full.
internal/check-packet-variants.mjs pins all of it, and was mutation-tested
rather than trusted: dropping list pages, reverting to printList, adding a
second open-state ref, and typoing a variant id each fail with their own
message.
The ten ring names settled between gospel and exp, derived from exp's ringFor()
rather than hand-numbered so the numbering cannot drift from the code that
produces it. The screen legend already renders these words, so a printed sheet
using them cannot disagree with the screen.
Written as vocabulary in the header a caller reads, and deliberately NOT as a
prop default. One cell is still open on the Driver's row 89 — "people" versus
the brief's "culture" in column 1 — and if he keeps his, exactly three cells
change, 1, 4 and 7, and nothing else: not the numbering, not the columns, not
the colours.
A default would bake a provisional word into every consumer that forgot to pass
one, and would make this component the thing that decided a Driver question.
Same reason ShareCalendar refuses to name its own metric.
Measured on the published file: those labels run 7 to 206 characters and the
long ones are the zone's member list, not its name. Tibet's is a ten-part list
of provinces. Only two of the fourteen carry no member list at all.
Used as a heading on a printed sheet that sets a 206-character title, and paper
has no truncation-with-tooltip to rescue it. The name a reader wants is the
humanised slug, uniform by construction and about 34 characters at worst, with
the label carried separately as the definition it actually is.
This component asserts nothing about the words in labels.rings, so the whole
hazard lives at the call site — which is why the warning belongs in the file the
caller reads rather than in a message they will not see. gospel found it.
The funnel computed each bracket with Math.min and Math.max over the step
indices an overlay touches. On an empty list those return Infinity and
-Infinity, so the bracket rendered at y=Infinity with height -Infinity: an
invalid rect that SVG discards without a word. The overlay vanished and took its
groups with it — no error, no gap, just a chart missing a band nobody knew to
look for.
An overlay is unplaceable when its `within` is missing, empty, or names a step
that does not exist, and all three are easy to write. `within` is the unusual
half of the API, so an overlay written as { id, label, groups } looks complete
and is silently undrawable, and a typo in a step id deletes a band rather than
failing.
bracketSpan now returns null for all three, the component lists them in ink
instead of dropping them, and the arithmetic moved out of the template where it
could be asserted. An overlay with no known relationship to the steps must not
be drawn, because a drawn bracket asserts a span.
Found by reading the template rather than by a test, which is worth recording:
these components are in L61 and had never been rendered once. The negative
control pins the cause — Math.min of an empty span really is Infinity — so the
assertions rest on the measured behaviour and not on my account of it.
gospel's boundedSet and centeredSet move to library/components/charts, and every
importer follows in the same commit: gospel-internet's two components and the
gate. One implementation, no window in which someone edits the wrong copy.
gospel chose this over copying, for the reason the shared scatter already showed.
Two print-safe components come with them, adapted from gospel's rather than
written fresh: theirs fetch and hold board state, these take joined groups and
every label as props, so the packet and the board draw the same figure from the
same code. gospel's already inked their tallies and used no hover, so the
adaptation is the data path and the labels, not the drawing.
The labels had to change, and not for style: gospel's read T('gospelInternet.…'),
and a gospelInternet-namespaced key cannot resolve for another area's consumer,
so the packet would have rendered the English fallback in every locale. Labels
are props now.
Three invariants gospel gave me that the comments did not carry are pinned here:
No seed prop. Position is hashed from the group's own id, so a group lands in the
same place on every render, forever — two printings of one packet are identical
and a reader comparing sheets sees real movement rather than reshuffling. I had
specced a seed; it would have silently rescrambled every dot while the data was
unchanged, which on paper cannot be corrected. Asserted through the parser, and
adding one back turns it red.
Every ring is listed, empty ones included. Under a filter several of the nine go
to zero, and a decoder that omits its empty rows stops being a decoder: a reader
cannot tell "none here" from "this ring does not exist".
And an arrow is a claim about a relationship, so a group with no recorded
neighbour gets none rather than a default one toward the centre.
The unknown-is-counted rule is now in the module header where the next reader
meets it first, at gospel's request: a printed page has no hover, so a category
the reader cannot see is one they will assume is zero.
Rebuilt on fe54c303 as one commit, because L61 already carries the components
themselves and these are follow-ups to code that has landed. My working branch
is based on the older main, so its gates ran 22 checks against a tree that no
longer exists; this is the same content measured against what is actually there.
Three defects, two of them the same class found by sweeping for it:
An overlay bracket was positioned with Math.min and Math.max over the steps it
touches. On an empty list those are Infinity and -Infinity, so it rendered at
y=Infinity with height -Infinity, an invalid rect SVG discards without a word.
The overlay vanished with its groups. It fires whenever `within` is missing,
empty, or names a step that does not exist, and all three are easy to write.
The anchor week matched cells with only an isFinite guard, so week 6,000 on a
5,200-cell grid, a negative week and a fractional one drew nothing. On paper a
silently missing anchor is indistinguishable from a deliberately absent one, and
absent is what the default means here, so the quiet failure said the opposite of
what the caller asked for.
And legend.noList existed in no catalogue at all, so eleven locales rendered
English in the one place the legend admits it has nothing to show.
Both placement functions now return a reason instead of a broken number, the
components print it, and the arithmetic moved out of the templates where it can
be asserted. The deliberate silence is preserved and pinned: no anchor asked for
reports nothing, because that is the default rather than a fault.
Swept the other two templates for the class after the funnel's Infinity bracket,
rather than waiting for a second report. LifeCalendar matched its anchor with
only an isFinite guard, so week 6,000 on a 5,200-cell grid, a negative week and
a fractional one all matched no cell and drew nothing.
On a printed page a silently missing anchor is indistinguishable from a
deliberately absent one — and absent is exactly what this component's default
means, since the packet supplies no age on purpose because shading a stranger's
life is a guess presented as fact. So the quiet failure told the reader the
opposite of what it looked like: the caller asked for a mark and the page says
we chose not to make one.
anchorPlacement returns the index or a reason, the component prints the reason,
and the arithmetic moved out of the template where it could be asserted. The
deliberate case stays silent: no anchor asked for reports nothing, because that
is the default and not a fault. Six edge cases and that distinction are pinned.
The funnel computed each bracket with Math.min and Math.max over the step
indices an overlay touches. On an empty list those return Infinity and
-Infinity, so the bracket rendered at y=Infinity with height -Infinity: an
invalid rect that SVG discards without a word. The overlay vanished and took its
groups with it — no error, no gap, just a chart missing a band nobody knew to
look for.
An overlay is unplaceable when its `within` is missing, empty, or names a step
that does not exist, and all three are easy to write. `within` is the unusual
half of the API, so an overlay written as { id, label, groups } looks complete
and is silently undrawable, and a typo in a step id deletes a band rather than
failing.
bracketSpan now returns null for all three, the component lists them in ink
instead of dropping them, and the arithmetic moved out of the template where it
could be asserted. An overlay with no known relationship to the steps must not
be drawn, because a drawn bracket asserts a span.
Found by reading the template rather than by a test, which is worth recording:
these components are in L61 and had never been rendered once. The negative
control pins the cause — Math.min of an empty span really is Infinity — so the
assertions rest on the measured behaviour and not on my account of it.
VisionFunnel, LifeCalendar and ShareCalendar, all props-driven, no hardcoded
list, print first: fixed px on the packet's 816x1056 canvas, nothing revealed by
hovering, and the packet's own ink rather than a new palette, so a reader cannot
tell which page came from the component and which from the renderer.
The funnel draws steps as stacked trapezoids and overlays as brackets BESIDE
them, never inside. Being inside a step is precisely the claim an overlay cannot
make: 362 of DOXA's 2,107 groups are not frontier at all. It asks
overlayPlacement whether an overlay is a slice rather than deciding, and prints
each overlay's parts as text, because a bracket nobody can hover over explains
nothing. Given steps that do not nest, or that mix publishers, it refuses and
prints why instead of drawing a plausible picture.
It is also not mirrored under RTL, which is worth saying because the obvious
reading of "mirror it" is wrong here: the triangle is vertically directional, so
a horizontal flip reverses nothing meaningful and inverts any text inside it.
What mirrors is the label and bracket side, so those use logical properties.
Two decisions now live in prop declarations, and a default is how either dies
quietly. ShareCalendar refuses to name its own metric: the shipped figure is the
evangelical share while the brief says "% Christian", and one default would
reintroduce the confusion this project already retracted a P0 over, with nothing
looking wrong. LifeCalendar's anchor defaults to null because the packet prints
for a reader we do not know and shading their life is a guess presented as fact.
Both are asserted through the SFC and JS parsers rather than a grep, since a
regex cannot tokenise JavaScript, and a sibling prop that does carry a default
is the negative control proving the arm is not vacuous. Adding
default: '% Christian' turns it red, which I checked rather than assumed.
packet-render's lifeGrid already held a carefully reasoned shading: deterministic
so a packet reprints identically, and scattered because a contiguous run of 32
fills most of row 1 of 52 and prints as a solid bar across the header, which is
the ordinary chart the life grid exists to avoid. The message is rarity, and the
eye has to hunt for the 32 among 5,200.
That logic is now in packetCharts.js and packet-render imports it, so the
printed packet and the reusable chart cannot drift into two answers. The move is
behaviour-preserving: I ran the extracted function against the pre-refactor code
over 49 seed and count combinations with zero differences. That probe was
one-off, so the durable half is a golden fingerprint here — the exact cells for
one seed. If the algorithm is ever tidied, those change and every packet already
printed stops being reproducible.
The assertion that matters most is the one that did not exist when the bar
shipped. The existing check asserts the shaded COUNT is between 0 and 5,200,
which is equally true of a bar and of a scatter: a count cannot distinguish a
distribution, so no amount of that gate passing was ever evidence. rega's eyes
caught it, and nothing else could have. Now the spread is asserted — 32 cells
landing on 27 different rows — with the contiguous block as the negative
control, so the arm can fail in exactly the shape it exists to reject.
The rounding flag upg100 asked to keep reachable stays on the return value, so
the packet can print the honest caption when a share is too small to fill one
cell rather than inking one and asserting "some".
These numbers print. A packet is handed to a reader on paper: no hover, no
tooltip, no correction, no redeploy. So the arithmetic lives in its own module
and is asserted against the shipped data rather than a fixture.
DOXA IS NOT A SLICE. Of its 2,107 groups, 2,082 are unreached and 1,745 are
frontier, so 25 are not unreached at all and 362 are not frontier. That is one
group in six. A chart drawing DOXA as a fourth band nested inside frontier
misstates 17.2% of the list with a clean shape and no error of any kind, which
is why steps and overlays are different props and an overlay reports whether it
is a slice rather than being assumed to be one. upg100 asked for the numbers to
reach the caption as text too, because a printed band nobody can hover over
explains nothing.
Three bugs I wrote, each caught by an assertion rather than by review:
The publisher warning sat in the wrong function. I put "Joshua Project counts
16,426 where IMB counts 12,379" into the nesting error, and the test proved the
claim false: 16,426 on top of 12,379 is perfectly monotonic. The two are not the
same kind of count, and no comparison of magnitudes can see that, so it needed a
separate check reading what the numbers are OF. An error message naming a cause
its own code cannot detect sends the next reader to look for the wrong thing.
A percent read as a fraction filled 3,189 cells of 5,200 instead of 32. My
heuristic was "above 1 means percent", and pctEvangelical is 0.6133, which is a
valid fraction and a valid percent with nothing in the value to separate them.
The refusal I added catches a percent of 1 or more and provably cannot catch
ours, and every share this packet prints is below 1% because the subject is
unreached peoples. So the guard is not the protection, the name is:
fractionFromPercent states the unit in the source line. Both halves are
asserted, including the half that does not work, because a guard believed to
cover more than it does is the worse failure.
And the rounding flags were untested until the test used values that could
reach them. 0.6133% of 5,200 is 31.9 cells; the reader sees 32 and cannot see
31.9, so the exact value goes in the caption and a share too small to fill one
cell is flagged rather than drawn as one.
gospel found a latent ambiguity in the emit contract while wiring, and checked
it instead of building past it. selectedId emits one bare string with no kind,
and region ids and zone ids share a namespace. Re-measured here rather than
taken on report: 33 zone ids, 7 region ids, and the intersection is exactly
europe, whose region is itself. So the string "europe" cannot tell a consumer
whether the Europe region row was clicked, meaning every European zone, or the
Europe zone leaf, meaning that one.
It is not live today, and only for a reason that is a coincidence rather than a
design: region europe contains exactly one zone, so both readings give the same
set. It goes live the day a second European zone is filed, and then it fails the
quiet way this whole file exists to catch — a smaller result, no error, no empty
state.
So this is an alarm, not a fix. The consumer builds the tree, so only the
consumer knows which rows are regions, and gospel is namespacing theirs. Two
arms: the colliding set is pinned and says re-derive if it changes, and each
colliding id's region must still contain exactly one zone. The second fails with
the instruction rather than the symptom, naming the zones and telling whoever
hits it to namespace their region rows.
Both falsified before trusting them: filing a second European zone fires the
ambiguity arm with both zones named, and adding a second colliding id fires the
drift arm with both ids listed. Data restored, clean diff.
The same note is on selectedId, because the next consumer to build a tree from
this zone data inherits the collision, and the doc as written invited it.
VisionFunnel, LifeCalendar and ShareCalendar, all props-driven, no hardcoded
list, print first: fixed px on the packet's 816x1056 canvas, nothing revealed by
hovering, and the packet's own ink rather than a new palette, so a reader cannot
tell which page came from the component and which from the renderer.
The funnel draws steps as stacked trapezoids and overlays as brackets BESIDE
them, never inside. Being inside a step is precisely the claim an overlay cannot
make: 362 of DOXA's 2,107 groups are not frontier at all. It asks
overlayPlacement whether an overlay is a slice rather than deciding, and prints
each overlay's parts as text, because a bracket nobody can hover over explains
nothing. Given steps that do not nest, or that mix publishers, it refuses and
prints why instead of drawing a plausible picture.
It is also not mirrored under RTL, which is worth saying because the obvious
reading of "mirror it" is wrong here: the triangle is vertically directional, so
a horizontal flip reverses nothing meaningful and inverts any text inside it.
What mirrors is the label and bracket side, so those use logical properties.
Two decisions now live in prop declarations, and a default is how either dies
quietly. ShareCalendar refuses to name its own metric: the shipped figure is the
evangelical share while the brief says "% Christian", and one default would
reintroduce the confusion this project already retracted a P0 over, with nothing
looking wrong. LifeCalendar's anchor defaults to null because the packet prints
for a reader we do not know and shading their life is a guess presented as fact.
Both are asserted through the SFC and JS parsers rather than a grep, since a
regex cannot tokenise JavaScript, and a sibling prop that does carry a default
is the negative control proving the arm is not vacuous. Adding
default: '% Christian' turns it red, which I checked rather than assumed.
packet-render's lifeGrid already held a carefully reasoned shading: deterministic
so a packet reprints identically, and scattered because a contiguous run of 32
fills most of row 1 of 52 and prints as a solid bar across the header, which is
the ordinary chart the life grid exists to avoid. The message is rarity, and the
eye has to hunt for the 32 among 5,200.
That logic is now in packetCharts.js and packet-render imports it, so the
printed packet and the reusable chart cannot drift into two answers. The move is
behaviour-preserving: I ran the extracted function against the pre-refactor code
over 49 seed and count combinations with zero differences. That probe was
one-off, so the durable half is a golden fingerprint here — the exact cells for
one seed. If the algorithm is ever tidied, those change and every packet already
printed stops being reproducible.
The assertion that matters most is the one that did not exist when the bar
shipped. The existing check asserts the shaded COUNT is between 0 and 5,200,
which is equally true of a bar and of a scatter: a count cannot distinguish a
distribution, so no amount of that gate passing was ever evidence. rega's eyes
caught it, and nothing else could have. Now the spread is asserted — 32 cells
landing on 27 different rows — with the contiguous block as the negative
control, so the arm can fail in exactly the shape it exists to reject.
The rounding flag upg100 asked to keep reachable stays on the return value, so
the packet can print the honest caption when a share is too small to fill one
cell rather than inking one and asserting "some".