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.