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 old copy led with the instruction ("Hand away the prayer task in
clusters of fifties and hundreds") and then asserted "Each poster is a
100-list you can pray, print, and put on a wall", which does not read as a
sentence and never says why fifties and hundreds.
It now opens with the hook: how would you feed a crowd of five thousand?
Jesus sat them in groups of fifty and a hundred and the impossible became
shareable. We did the same with the unreached people groups, and each list
prints as a packet and a poster so one person can carry one.
THE NUMBERS ARE MEASURED, NOT ROUNDED TO THE BRIEF. The brief said about
thirty lists of fifty to a hundred; the catalogue says 27 lists covering
2,106 groups, with sizes from 8 to 118 and only 18 of the 27 actually
inside that band. So the copy says 27 and 2,106, and says ABOUT fifty to a
hundred rather than claiming every list is -- a reader who adds up the
rows would otherwise catch us out, and this is the same discipline the
funnel page already applies to K.
Eleven locales, agent-written per locale rather than translated from the
English, with the scripture reference in each locale's own convention
(Luke 9:14 / Lucas / Luc / Лк. / لوقا ٩:١٤ / 路加福音). The three-key split
with its emphasis span is unchanged, so the markup does not move.
Found by the bug-CLASS sweep, which is the point of doing one: the busy
indicator was added to the selection HUD after a report that pressing its
buttons showed no sign of work, and the legend's Export menu -- Poster and
Packet -- had none at all. Same defect, different surface, and I fixed the
reported instance without looking for its siblings.
Both do the same slow work: two dynamic imports, a map-SVG fetch, a funnel
fetch, then a multi-sheet render. onLegendExport now guards re-entry (a
second click would run two renders over one print frame), sets busyAction
so the control says what it is doing, and clears in finally on every exit
path including the early return inside the try.
The gate's busy assertion had two holes that let this through, and both
are fixed. It counted setters by matching the literal strings 'packet' and
'poster', so a ternary assignment was invisible -- a regex that enumerates
the shapes it knows about will always miss the next one, so it now matches
the assignment and excludes the clear. And it only asserted a whole-file
count, which "some handler somewhere sets it" satisfies; it now asserts
PER FUNCTION for all three print paths, so a surface cannot be forgotten
while the file as a whole looks fine.
Mutation-tested: removing the busy handling from onLegendExport now fails
naming that function, and dropping a finally fails naming its own.
Also drops a stale "page 3" from a shipped CSS comment -- page order is
variant-dependent since lightweight started omitting two sheets, so a
fixed page number in the output is wrong even where no reader sees it.
Authored here and landed by studio, per the library boundary. The English is
byte-identical to the call-site fallback; each script takes its own ellipsis and
spacing convention rather than the English one.
The Driver asked for a top-right dropdown with Auto plus fixed overrides,
remembered, and said plainly that the CSS already working must not break.
So Auto writes NOTHING. The grid is repeat(auto-fill, minmax(240px, 1fr)),
which already gives one column on a phone and three to five on a desktop by
measuring its container; Auto leaves the stylesheet alone and only an explicit
override sets grid-template-columns. An Auto that computed a number and wrote
it back would look identical until the container resized, and then be silently
wrong — the same picture, with the responsiveness removed.
An override is honoured even when it is a poor idea: five columns on a 390px
phone is a 62px card. That is deliberate. The reader asked for it, Auto is the
default and one click away, and the control shows which is active. Clamping
someone's explicit choice to what we think is sensible is how a control stops
being trusted.
It is remembered per PROFILE, not per bundle. Two grids on one page are two
different lists, and useDashboardLayout already paid for that lesson when a
shared key made one caller inherit another's saved layout. Blocked storage is
caught and ignored: in private mode the default is the correct outcome, not an
error to report.
It sits in its own row rather than in #filters, which is gated on
useSearch || useFilters — a host turning both off would otherwise lose a control
that has nothing to do with either. It modifies the grid, so it lives on the
grid.
Both strings are authored in all eleven catalogues, in the snake_case this
catalogue uses: a camelCase key here would have resolved to nothing and rendered
its own name, which is the defect I shipped in a legend earlier today.
The console showed 24 Vue warnings, 8 each for "profileModules", "appI18n"
and "Symbol(pinia)" — "already provides property with key … inherited from its parent
element. It will be overwritten with the new value." They come from a composite mounting
child custom elements: Vue's CE layer lets a child app inherit its parent ELEMENT's
provides, and app.provide() warns on any key already reachable.
ALL THREE MUST BE THE CHILD'S OWN, so neither silencing the warning nor honouring it by
not providing would have been right. profileModules is the plugin's own .vue glob — a
child rendering its parent's components is a different and worse bug. pinia is
per-instance so two embeds on one page never bleed store state. And the i18n instance is
the one ProfileLoader drives from THIS element's profile-config, which is the whole of
the permanently-English fix from earlier today. Inheriting any of the three would be the
regression, not the warning.
So the shadow is made DELIBERATE at the line that does it. provideOwn() writes to the
app's own provides when the key is already reachable and uses the public app.provide()
when it is not, so the ordinary path is untouched. pinia needs the other shape: its
symbol is module-private, so it can only be provided by app.use(), and the key cannot be
named — withoutInheritedProvides() hides the inherited chain for the duration of the
install and restores it in a FINALLY, so a throwing install cannot leave an element
unable to see its parent.
The helpers are split into library/elements/provide-own.js with NO framework import,
because defineMapElement.js imports ProfileLoader.vue and is therefore unloadable by the
gate that proves it right. Third time today that split has been necessary; the gate now
exercises the shipped helpers rather than a copy.
check-provide-shadowing asserts BOTH halves — the child ends up with its own value, AND
the parent's other provides still resolve afterwards — against a Vue-shaped fake app that
warns exactly as runtime-core does. Its control is the naive call, which must still warn.
Negative-tested three ways, including moving the restore out of the finally.
check-element-bootstrap now accepts either spelling and was re-checked for the thing it
actually guards: removing the provide entirely still turns it red.
Follow-up to 50ebdf89, which kept an internal tracking id on the grounds
that a reader needs to know where the answer comes from. The ruling is
that in shipped code the id should go too, and it is right: a row number
means nothing to someone reading packet-render.js, and it points at a
place they cannot open.
What the comment has to carry is that the value MAY CHANGE, so a later
reader does not treat it as settled and hardcode it somewhere. "Provisional,
pending a product decision" says that without pointing anywhere.
Three sites, all mine. CenteredSetTarget.vue carries the same reference
and is not mine; it belongs to the carded fleet sweep.
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 Driver wants MapLibre as the default everywhere, on cost and on how it
looks, with Mapbox supported as an explicit choice.
One line decided it. resolveMapProvider read
explicit || (token present ? 'mapbox' : 'maplibre')
so merely HOLDING a Mapbox token selected Mapbox. That conflated two different
things: having a token is a capability, wanting Mapbox is a decision, and only
the decision belongs in this function. It now reads explicit || 'maplibre'.
The token is still honoured for what it is — with provider:'mapbox' it
authenticates the map, and the token argument stays in the signature because
callers pass one they hold outside the config. It simply no longer votes.
The migration surface is exactly one shape: a host that pasted a real token into
a published embed snippet and set no provider. All 36 shipped snippets carry a
tk placeholder and no provider key, so any host who filled one in is in it. An
unfilled snippet was already MapLibre, because hasMapToken rejects YOUR_
placeholders, and the DX Life branches pass no token at all. Such a host keeps
Mapbox by adding "provider":"mapbox"; ?provider= and window.MAP_PROVIDER are
unchanged.
The gate's four token-selects-Mapbox assertions were the old specification, so
they are inverted rather than deleted — the diff should show what changed for
whom. Two are added: the opt-in still works with a token in hand, which is the
path a migrating host takes, and a real token is still SEEN, asserted apart from
the engine because otherwise the engine could be right while authentication
broke silently.
check:map-props-unit was not in gates:pre, so it proved nothing on a normal run.
It is now, and the suite reads 32 of 32 with it named.
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.
DRV1650-SR-POSTER-EXPORT, the "too much Antarctica" half. The big-poster renderer
stitches a grid of tiles, each drawn into an EQUAL-HEIGHT slot of the output image, and
it chose each row's latitude band by dividing the degree span evenly:
latStep = (ne.lat - sw.lat) / rows
Screen height is proportional to ln(tan(pi/4 + lat/2)), not to latitude, so equal DEGREE
bands are not equal SCREEN bands. MEASURED on a -60..75 view in 4 rows: the top band is
37.0% of the true rendered height and was being painted into 25% of the poster; the next
19.7% into 25%; the bottom 25.2% into 25%. Every row is squeezed or stretched, which is
the wrong aspect, and the bands where a degree buys the most pixels are the most
over-represented -- the Antarctica the Driver saw.
Sliced in Mercator y instead, every row is 25.0%, exactly, because that IS the screen
coordinate.
★ THIRD PLACE TODAY FOR THE SAME MISTAKE. A whiteboard chart nearly shipped with a linear
scale into degrees; a measurement harness nearly reported a false threshold by correcting
the wrong axis; and this has been shipping. Treating latitude as a screen coordinate is
the error, the conversion is one line, and it now lives in library/maps/mercator-slice.js
rather than being rediscovered a fourth time.
check-mercator-slice asserts the PROPERTY across five view/row combinations -- every band
an equal share of rendered height, contiguous, covering the span, north-first -- and its
CONTROL is the old code: degree-linear slicing must FAIL the same assertion, which it does
by 12.0 points. Without that control the test would pass on a tree where nothing changed.
A -90..90 view clamps at the Mercator limit instead of going infinite.
NOT FIXED HERE, because it is a product decision and not a defect: the export also asks
for an 8.5x11 PORTRAIT sheet (research-map.vue:1410), so a landscape map is letterboxed
onto a tall page. That is a deliberate default, it is the other half of the wrong vertical aspect, and changing the poster's default shape is a product decision, not a fix.
The agreed ring vocabulary now lives in two catalogues in eleven locales
each -- gospelInternet.json for the screen legend, packet-strings.json for
the printed sheet -- and nothing held them together. They legitimately
want different SHAPES (the screen renders the axes as standalone labels,
the packet composes them into one string per ring), so neither can import
the other; but the WORDS must match.
The failure that makes this worth a gate is quiet: one cell is provisional
on the Driver's row 89, and it appears in rings 1, 4 and 7. Answer it in
one file and not the other and the sheet and the screen state different
things about the same ring, each correct in isolation, with no error
anywhere. Only a reader holding both would notice.
Measuring first showed 36 of 77 cells already differed, in two classes.
Most were grammar -- the packet's culture terms follow a comma
mid-sentence, so they are lowercase -- but some were genuinely different
words: "Same family" against "Same language family", German "verwandte"
against "Nahe", Hindi "वही जन" against "वही जनसमूह". Those are drift, and
the screen's wording wins because it is what readers already see. The
packet now adopts every term verbatim, lowercasing only the first
character of the culture half, which is a no-op in zh/ar/hi and leaves
German common nouns capitalised.
That adoption caught a bug in my own assertion: I had hardcoded ring 7-9
as "Same family" from CenteredSetTarget.vue's header, while the shipping
string is "Same language family". A summary in a comment is a description
of the data, never a second source of it.
The gate also refuses to pass when it compared nothing -- rename a key on
either side and every lookup returns undefined, every pair is skipped, and
a silent check reports success. Mutation-tested both ways: answering row
89 on the screen only, and renaming a packet key.
The figure printed rings without saying what they mean -- honest but thin.
It now names each one with the vocabulary gospel and exp settled, the same
words the screen legend renders, so a printed sheet cannot disagree with
the screen.
COMPOSED FROM band x culture, NOT A FLAT 1..10 TABLE, and that is the
whole design decision. "Same people" in column 1 is provisional on the
Driver's row 89 -- and it appears in rings 1, 4 AND 7. A ten-string table
would need three edits kept in step, which is exactly how two of them end
up saying different things. Composed, it is one string
(ring_culture_same), and if he picks "culture" the ring numbering, the
columns and the colours are all untouched.
Ring 10 stands alone ("Nearby only") because it is not a band-and-culture
reading at all -- it is what is left when no linguistic relation was
found. The gate asserts that separately, and a mutant that composes it
like the other nine fails.
ONLY THE RINGS THAT HOLD SOMEONE. Ten rows of vocabulary beside a figure
is a glossary, not a legend, and a named ring with no dot on it invites
the reader to hunt for something that is not there. ringTally's per-ring
counts drive the list, so it states what this zone actually contains.
Mutation-tested: a wrong band order, listing all ten rings regardless of
count, and composing ring 10 like the others each fail with their own
message. Eight new strings x eleven locales; packet-strings-parity green.
Brief A1 back matter: after the profiles, before the wheel, full detail
only. The lightweight packet is map, triangle, profiles, wheel, points --
these two figures are the part a reader sits with rather than carries.
The layouts are regions' library/components/charts modules, imported
rather than injected: unlike describeGroup's country resolver they have
zero imports and run under plain node, and packet-render already imports
scatterCells from that directory. Verified by importing and CALLING them,
not by reading them. They return data, so the SVG is ours.
Gospel's non-negotiable is carried through and checked rather than
trusted: the bounded set keeps a deliberate gap either side of the
boundary because a dot ON the line is the one thing a bounded set cannot
express, and print has no hover to disambiguate it. Measured on the real
module with 40 groups -- inside max 85.3 of 92 allowed, outside min 119.8
of 112 required. The figure scales through the viewBox, so the gap lives
in coordinates the scaling cannot touch; shrink the figure, never the gap.
UNKNOWN POINTS ARE STATED, NOT PLOTTED. layoutBoundedSet gives them no
x/y, which is the module being right: `reached` undefined means we do not
know which side they belong on, so there is no honest place to draw them.
My first version mapped them through the same dot() helper and crashed on
p.x.toFixed -- a better outcome than silently drawing them at (0,0), which
would have put "we don't know" inside the circle. They go in the tally,
because on a printed page a category the reader cannot see reads as zero.
check-packet-variants' sheet delta goes 2 -> 3, updated in this commit
rather than relaxed into a range. It also now SUPPLIES the sets fixture:
setsPage returns '' without data, so a build() that omitted `sets`
exercised the filter for every page except the newest one, and the gate
sat green through the whole rewrite while asserting a delta that had
become wrong. A fixture missing an input silently narrows what the whole
file covers.
Seven strings x eleven locales; packet-strings-parity green. Gospel's own
ring names live in gospelInternet.json, a plugin catalogue, so there is no
collision with these.
The zh catalogue is Simplified, but 27 values (the Prayer List and Cards buttons, Close, Show on
map, the share steps, the UPG-100 wall intro, three tab names and several popup labels) were
Traditional, so one button row mixed scripts. Converted character by character, with the mainland
wording for two words (列印 → 打印, 檢視 → 视图).
check:i18n-parity was RED: working, packetVariantAria, packetFull and
packetLightweight existed only in en, so all ten other locales were short
four keys with no DEBT entry. Three of the four came in with the Prayer
list caret, so this would have failed there too.
Really translated, not English-filled: check:i18n-never-translated
compares values ACROSS locales and a copy-paste of the English would trip
it. The register follows the neighbouring keys already in each file
(cardDeck, prayerList), and the two packet names stay short because they
are buttons inside an 84px tape, not prose.
zh is SIMPLIFIED, and my first attempt was Traditional (studio caught it).
I had matched the neighbouring cardDeck/prayerList, which are themselves
Traditional -- but they are the exception: a census of the whole zh
catalogue counts 826 Simplified-only strings against 22 Traditional-only,
and 11 of those 22 are in this one file. So the neighbours were the drift,
not the standard. Matching a locale means matching its SCRIPT and
register, not just its language, and the way to establish that is to count
the catalogue rather than read the adjacent line.
Deliberately its own commit rather than an amend of either source commit:
the four keys span two commits, so they belong to neither, and rewriting
one already staged in a landing batch would hand studio a different sha
for work they had picked.
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.
On mobile, selecting countries and pressing Prayer list reported zero UPGs and would not generate a prayer list.
selectedTerritories holds three kinds -- 'zone' (a legend row), 'adm0' (a
country tapped on the map) and 'adm1' (an India state / China province) --
and selectedLists() kept only 'zone'. So a map selection resolved to an
empty list and selectionAsList threw "nothing selected", while the HUD,
which sums t.count straight off the selection, showed a correct non-zero
total. Two sources of truth for one selection is why it read as a nonsense
error rather than an empty state: the app said 87 and the generator said
zero.
The adm0 key already carries the ISO3, which IS country_code on a
people-group row, so a tapped country becomes a country_codes selector the
evaluator already handles. No new selector type, and a mixed selection
unions and de-duplicates for free. The cover label now names the countries
too -- built from lists alone it was empty, and reduce() on an empty array
throws, so the packet would have died on its cover even after the selector
was fixed.
adm1 cannot resolve and that is a data fact, not an oversight: a
people-group row has no province field and the evaluator has no adm1 case.
Folding it into the country union would print a packet for the whole
COUNTRY while the reader had selected one STATE -- a plausible packet that
is quietly wrong. So it is refused BY NAME, which "nothing selected" was
not: that is a lie the reader can see is false with their selection
highlighted on the map.
Also, nothing showed that the action was running: batchBusy only
DISABLED the buttons, and a disabled button is visually indistinguishable
from an unresponsive one, so on a phone you tap, see nothing change, and
tap again. busyAction names which action is running so only that button
relabels, and it is cleared in every finally -- otherwise a failed print
leaves the button reading "Working..." forever.
check-selection-kinds.mjs reads the kinds OUT of resolveTerritory rather
than listing them, because listing them is how this survived: 'adm0'
existed in the resolver and nowhere in the consumer, and a test written
from the same mental list as the consumer would have missed it too.
Mutation-tested: reverting the guard, dropping the selector, and leaving
busyAction uncleared each fail with their own message.
The Driver asked for the arrow on the people-group grid and on the regions list.
Those needed different wiring, and the reason is worth recording because it is
not a preference.
The regions dashboard owns its scroller: .rd-scroll has overflow-y:auto, so the
plugin mounts the button itself and drives its own element. It deliberately does
not touch the page's scroll — this renders inside a shadow root in someone
else's page, and their scroll is not ours to move.
The people-group grid owns no scroller at all. .pgg-root sets no overflow and
.pgg-body has no rule; the grid simply grows and something outside it scrolls.
In this shell that something is .vtl-host, which lives in the layout, outside
every plugin's shadow root, so the grid could not reach it even if it should.
So the arrow for that surface belongs in the layout, beside the circle the
layout already owns, driving the pane it already owns. The grid is untouched.
.vtl-host is per tab, so the arrow follows `active` rather than binding one ref,
or it would scroll whichever pane rendered last.
One component, two consumers, and each drives a scroller it actually owns.
Checked that this adds nothing to the hardcoded-English census: 256 before, 256
after — the three regions entries there are PosterWall prop literals and predate
this.
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-SR-EMBED-URL-RUNTIME-ORIGIN. The Driver copied
`research-map-plus-grid/research-map-plus-grid/index.html` from a page at
http://127.0.0.1:5192/, a link that works on no other site.
resolveShareUrl's tier 1 returns its input VERBATIM, correctly: a host handing over a
whole url has already decided. So a composite that BUILDS a tier-1 url takes on tier 2's
job, and both of mine wrote the same thing:
base ? base + '/' + leaf : leaf
On any host where the tree root cannot be derived from the script url — a dev server, an
ESM build, a tree served from a path that does not match `<name>/<name>.js` — base is ''
and the else branch hands tier 1 a bare relative path, which it then returns unresolved.
★ IT LOOKS RIGHT IN THE ONE PLACE ANYONE CHECKS. A relative share link WORKS WHEN
CLICKED, because the browser resolves it against the page you copied it from. It is the
EMBED that breaks — an <iframe src="…"> on a partner's page resolves against THEIRS — so
the failure is invisible exactly where it is tested and visible only where it ships.
research-map-plus-grid.vue:174 even documents the requirement ("It MUST be absolute,
which is the non-obvious part") four lines above the branch that violates it.
absoluteEmbedUrl() is tier 2's resolution made available to tier-1 builders: the
configured base when known, otherwise resolved against document.baseURI, which yields the
runtime origin. Never relative in a browser. The ladder is unchanged; only its last rung
is.
The gate has both arms, because either alone would rot: the RESOLVER must be absolute
with no base (the failing case, not the passing one), and no plugin may rebuild the
`: leaf` fallback. Negative-tested: removing the origin fallback, and re-introducing the
ternary in a plugin, each turn it red.
"Copy link didn't work" is most likely the same defect rather than a second one —
ShareButton's copy already falls back from navigator.clipboard to execCommand for
insecure contexts, so the copy almost certainly succeeded and produced a useless URL. If
it still fails after this, it is a separate bug and needs a browser to see.
Driver brief §B. gospel-internet's GiTree had NO props and NO emits: it read a board
composable and three module singletons, which is what made it gospel's rather than
anybody's. Everything it reached for is now a prop, and the parts worth testing are no
longer inside an SFC a gate cannot run.
WHAT IS ACTUALLY IN THE COMPONENT: wiring, 160 lines. The drawing is the whiteboard
tiered-points layer, the surface is whiteboard-map.js (moved out of gospel's plugin --
67 lines whose every import already came from library, so it was never theirs), the
hierarchy is language-tree-model.js and the camera is frame-target.js. Four modules,
four headless gates. An SFC cannot be executed by a check, so it should hold as little
as possible.
THREE BEHAVIOURS PINNED BECAUSE THEY ARE EASY TO STATE WRONGLY:
FRAME ON SELECT, NOT ON CHANGE. Clearing does not re-frame; the camera stays where the
reader left it. "It frames when the selection changes" is the obvious phrasing and it is
wrong in exactly the case that annoys people -- being yanked somewhere after deselecting.
A SELECTION THAT ARRIVES BEFORE THE MAP IS REPLAYED. GiTree survived this by ordering:
its host set the node through a composable that waited for data, so the value always
landed after the map. As a PROP the ordering belongs to the host, and a prop whose effect
depends on WHEN you set it is a trap -- a shared deep link would work or not depending on
load timing, which nobody could report. ready replays whatever `selected` is.
HOVER CANNOT MOVE THE CAMERA, structurally rather than by convention: hover sets a filter
on its own layer and there is no camera call on that path to remove later.
AND THE CONSTANTS. gospel's maintainer inherited GiTree and had to read intent out of
structure -- there is no "this took measuring" note anywhere in it. Asked which line they
would flag, they named frame(). So padding 60 (the outermost nodes carry labels that
extend past the point), maxZoom 8 (a two-language family must not fill the screen with
its own neighbourhood gone), duration 900, and the floor zoom that stops framing one node
from zooming the reader OUT are now stated with their reasons and asserted.
Two branch rules with them, both of which a tidy-up would erase: reduced motion is a
DIFFERENT CALL (jumpTo, not easeTo with duration 0 -- the engines do not treat those as
one), and a single point CENTRES rather than fitting a degenerate box, because a
one-language family is ordinary and fitBounds on no extent zooms to nothing.
THE ROLLUP IS OVER THE READER'S SCOPE. "A family shows the worst colour below it" is only
true relative to what is currently on the board: a family that is red overall can be
green inside a filter. Handing the rollup a whole subtree makes the colours quietly
disagree with the board -- no error, no blank, just a wrong fact about a real people
group. Asserted in both directions, and colorFor stays injectable because nothing in the
drawing depends on the rollup being worst-of.
THE CLICK TOGGLE MOVED TO THE HOST, and that is a change rather than a codification:
GiTree cleared the selection when you re-clicked the selected node, and that toggle lived
in the component's own handler. A host that wants it must now write it. In exchange the
chart holds no selection state.
gospel's UI is not wired and their boardEngine.js is untouched on main; they adopt on
their own schedule.
Driver brief §B, the seam under the language tree, so gospel's set charts inherit zoom,
label priority and tiling from the map engine instead of re-implementing them in SVG.
READ FROM GiTree RATHER THAN ASSUMED: six layer groups, exactly ONE hierarchical. The
parent->child links are sourced from linkFC; the points, the selection and hover rings,
the dish behind a cluster and EVERY label layer come from one point collection and know
nothing about trees. gospel re-read that independently and found the same split, which
is the only reason I trust it -- "the seam is generic" is the claim that should never
travel on one reader.
So library/charts/whiteboard/tiered-points.js takes a point FeatureCollection whose
features carry id, label, tier and priority, and gives each TIER its own symbol layer
with its own minzoom. That is the whole of "band names zoomed out, ring names mid, group
names in": the engine is told which labels may exist at which zoom and resolves the
collisions itself. Paint expressions pass through unread -- the layer has no opinion
about what a colour means, which is what keeps it reusable. Selection and hover are
FILTERED layers, so hover costs one setFilter and structurally cannot move a camera:
there is nothing in here that could.
AND THE PROJECTION IS NOT A SCALE, which is the part that would have shipped wrong.
gospel asked for degrees and planned to scale by one number; their charts are CONCENTRIC
RINGS, and Mercator stretches vertically by 1/cos(lat) -- 1.0154x at 10 degrees, 1.1547x
at 30 -- so a linear scale renders rings as OVALS, worst at the outer ring where the
labels a reader compares are. library/maps/whiteboard-projection.js is the inverse
conversion, EXTRACTED from build-language-tree.mjs rather than written, because the
second copy was about to be made by hand. The degree-height it emits shrinks from 0.9996
of the width at the centre to 0.6794 at the edge; that shrink IS the correction the
renderer then cancels.
TWO TESTS, EACH GUARDING A DIFFERENT RISK.
check:whiteboard-projection reproduces the old private implementation VERBATIM as a
reference and compares over 1,734 grid points EXACTLY -- the language tree is a
build-time artifact, so a rounding shift would move 4,000 coordinates and look like
nothing. It then asserts a ring renders round at the inner, middle and outer radii, AND
that a linear scale FAILS that same assertion, because an assertion the defect can pass
is measuring nothing.
check:tiered-points runs gospel's CENTERED SET through the layer: 2,079 points, 3 tiers,
priority by population, and NO EDGES. The absent links layer is the evidence -- if the
whiteboard draws this chart with no line layer anywhere, edges are separable in fact
rather than in my reading. The map is a recording fake, so the assertions are about what
the module DOES; a regex over the source would pass on a module that never called
addLayer.
Negative-tested: making the projection linear, changing its rounding, dropping the label
priority, or giving every tier the same minzoom each turn one of them red.
gospel's UI is NOT wired -- their component is theirs to write, later, against a module
that now exists. Their boardEngine.js stays where it is until the chart lands.
Goal (c), and it turned into a goal (d) fix. Every profile element is
defineCustomElement(ProfileLoader, { configureApp }), and the configureApp has four
steps. The fourth, app.provide('appI18n', i18n), is the one that gets dropped.
ProfileLoader injects that instance (ProfileLoader.vue:176) to drive its locale from
profile-config. Without the provide the watcher holds null and the drive NEVER RUNS --
while setRuntimeLocale() on the next line still fires, so tStatic strings DO follow the
host. The element renders a convincing MIXTURE of the reader's language and English, and
nothing throws, because the t() shim falls back to its key. It reads as "a few strings
not translated yet".
SHIPPING THAT TODAY: country-posters, key-cities, gospel-resources, cities-internet and
my-upg-100-list. regions-plugin censused all 19 entry files independently and found the
same five and no sixth. The measurement is regions-dashboard's, from when they hit it:
a Spanish catalogue present, es set, and every t() string still English.
All sixteen entries now call library/elements/defineMapElement.js, so the five are fixed
by construction rather than by five separate edits. Behaviour is otherwise identical --
same options, same element names, same per-plugin extras. wall-of-people-groups needed
the helper to hand its pinia back, because it takes a store off its own instance and a
helper that hid pinia would have given it a second one: two stores and a silent state
split.
★ AND THE CENSUS THAT RECOMMENDED THIS SAW FOUR OF SIXTEEN. My detector hashed function
BODIES; eleven sites are not `function buildXElement()` at all, they are
`const El = defineCustomElement(...)`. I wrote "every number here is a lower bound" in
the census as a caveat and it was the headline within the hour -- a 4x undercount of the
item it recommended. So this gate greps for the SHAPE, not for a known list: a check
written around the four that matched would have passed the twelve that did not.
check-element-bootstrap has two arms. No entry may build a profile element by hand, and
the helper itself must provide appI18n -- PLANTED as a negative case, so the regression
cannot return by editing library instead of a plugin. It also fails if it finds fewer
than ten entries, because a detection that stops matching reports the same green as a
tree with no problems.
THE PLANT CAUGHT TWO BUGS IN THIS GATE BEFORE IT CAUGHT ANYTHING ELSE. The predicate
matched the helper's DOCSTRING, so it would have passed a helper that only mentions the
provide in prose -- comments are stripped now, because a check that reads comments is
reading the author's intentions rather than the program. And the plant itself used /m
instead of /gm, so it deleted the documentation and left the code, then reported that it
could not detect an absence that was not there.
Thirty-nine keys the panels and the ring legend already name existed in no
catalogue, so every locale rendered the English written at the call site. The
fallback is what kept it readable and therefore silent — a missing key degrades
to correct English rather than to a visible key name, which is the whole reason
nobody noticed.
The English is byte-identical to the fallback it replaces, asserted mechanically
against the components rather than by eye: translating existing wording is a
product decision, changing it is not, and a catalogue entry that quietly
reworded a call site would be the second kind wearing the first's clothes. The
ring legend's English was read out of the component for the same reason, not
copied from the message describing it.
Each script carries its own punctuation rather than English punctuation in
another alphabet: the Devanagari danda, the Chinese full-width colon and stop,
the French space before a colon, the Arabic comma and Arabic-Indic digits.
The vocabulary is the fixed part. Reached and unreached are terms of art with
settled renderings, the two chart names are Hiebert's bounded set and centered
set, and the first culture column says "same people" rather than "same culture"
because the rule behind it is the same ethne in another country — a stronger
claim than the brief's wording, and the label should not overstate or understate
what was measured. That one word is on the Driver's sheet; if he keeps his,
three cells change and nothing else.
Two fixes from measuring what I had only modelled.
1. THE ROW RENDERED 81px AGAINST A 73.25px MODEL. rega measured it: no
box-sizing, so the declared height was the CONTENT box and the 6px padding
plus 1px border were added outside it. Because `top` still advanced by the
modelled height, every row overlapped its successor by the difference. The
stack still fitted the sheet -- it ended 8px inside the 60px bottom margin
instead of on it -- so nothing clipped, nothing ran off the page, and no
headless check could see it. box-sizing:border-box makes the declared
height the rendered height. The ladder still fits: the content box is
66.25px and a name plus two lines at the default 17px needs 61.8px.
2. THE OFFLINE GENERATOR WOULD HAVE PRINTED A ONE-CLAUSE DESCRIPTION. Its
fetch never asked for primary_language or imb_gsec -- measured on the
cached records, 0 of 2107 carried either, while country_code_label was
present on all 2107. So the browser packet printed two full sentences and
the offline packet printed "The Amahuaca live in Peru." from the same data.
Same defect class as the comment already above that field list: it reads as
missing DATA and is missing FIELDS.
The generator now injects the describer too, deliberately WITHOUT a country
resolver: library/utils/countryName.js cannot be imported under node at all
(it imports JSON without an import attribute -- measured), which is the
constraint that made this seam an injection. It is not needed, because
describeGroup falls back to country_code_label and every cached record has
one. Verified end to end: 28 packets, 63 sentences on the first page, zero
fallbacks, country localized correctly.
The gate now budgets against the CONTENT box rather than the declared
height, asserts the DEFAULT 17px step fits and not merely the smallest, and
pins box-sizing in the source -- mutation-tested: removing border-box fails
with its own message.
Brief A1 item 1. The 2x10 card grid becomes 20 full-width rows, each one
picture | generated localized description | three prayer checkboxes.
PER_PAGE stays 20, so this changes the layout SHAPE and not the
pagination -- which is what lets check-packet-render's dropped-groups
assertion keep passing unrelaxed. That assertion refuses a +/-1 tolerance
because slack is exactly where a lost list page hides, so a rewrite
needing it loosened would be the wrong rewrite.
The description column is ~1036px. An entire card in the old grid was
683px, so the sentence alone is 50% wider than a whole card was; that is
the point, since a generated sentence needs width a name and two metadata
lines did not.
The geometry closes with ZERO SLACK -- 20 rows plus 19 gutters fill the
sheet to exactly the bottom margin. That is the finding, not the
reassurance: a description wrapping to a third line overflows by ~16px.
Hence FIT BEFORE CLAMP: step 17 -> 15 -> 14px, and truncate at a WHOLE
WORD only as a last resort, because a mid-word cut reads as a rendering
fault rather than an editorial one -- especially inside a people-group's
name, where a reader cannot tell it from a misspelling.
Deterministic by character count, never a DOM measure, because this module
runs under Node for the offline generator as well as in the browser. The
0.52em advance is deliberately wider than Georgia's true ~0.50 so a locale
wider than English is absorbed by the estimate rather than by an overflow.
Measured against the real catalogue, every shipped locale fits at full
size -- German is longest at 178 chars against a 234 budget -- so the
ladder is insurance, not routine.
describeGroup is INJECTED, not imported: its country resolver reaches a
Vite-only alias and imports JSON without an attribute, which would make
every `node internal/check-*.mjs` unrunnable. usePosterPrints composes it
on the browser path and tolerates its absence, so a packet that cannot
resolve a describer still prints, degrading to the metadata line.
check-profile-table.mjs was mutation-tested, and two of my own assertions
were dead. "The rows close on the bottom margin" is a TAUTOLOGY -- the row
height is derived by dividing the page by PER_PAGE, so it holds for any
row count; a 21-row mutant sailed through it. It is replaced by the real
constraint, that a row stays tall enough for its content (fires at 24
rows). And the word-aware check fed 'word word word', where a mid-word cut
is indistinguishable from a clean one; with distinct words it now names the
broken token.
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.
ListSelectorLegend renders "No list loaded" when it has no tree yet, reading
legend.noList. That key was never authored, so every locale fell through to the
English fallback: eleven languages showing English in the one place the legend
admits it has nothing to show.
Added to all eleven catalogues. The string is a full sentence rather than a
label, so it keeps its terminal punctuation in each script, including the Hindi
danda and the Chinese full-width stop.
Proved in both directions rather than only after: check:i18n-keys-exist FAILS
with exactly one key when the catalogues are stashed away, and PASSES with them,
and check:i18n-parity reports no locale regressed and no translation debt.
My defect, from my own component, found by exp.
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.
PM-tok, PRODUCTION-MATRIX.md:285. `tokenUrl` names a token SERVICE a host points at
instead of pasting a key: the element asks that URL when it was handed no token. Only
ProfileLoader ever read it. A pane composite bakes its children in rather than mounting
them through ProfileLoader, so a composite embedded BARE -- one tag on a partner's page,
which is the entire point of a composite -- dropped a tokenUrl-only config on the floor.
It worked in every place anyone looked, because a ProfileLoader elsewhere on the page
fetched and published the token: the composite was passing an exam somebody else sat.
And the failure wears the wrong face. No token is "No Mapbox access token provided",
0 tiles, no canvas -- identical to a missing `tk`, which IS threaded -- so the natural
reading is that the host mistyped their config rather than that a knob is unread.
The rule lives in library/composables, not in each composite, because it has already
been copied once (ProfileLoader.vue:132) and a third and fourth copy is how one of them
later learns to ask ALWAYS -- which a token service billed per call notices before we do.
The pure decision is split into host-token-url.js with NO framework import, because a
fresh worktree has no node_modules and a module importing vue is unloadable by the gate
that proves it right. Same shape as describeGroup keeping countryName injectable.
check-composite-token-url takes its expectation from ProfileLoader -- the contract a
composite REPLACES -- not from the composites it audits, so it cannot pass by
construction; it exercises the decision rather than grepping for it; and it finds its
subjects by a PROPERTY, a production bundle already forwarding `tk: hostToken`, so a
third composite is covered the day it is written. stack-demo has the same shape and is
excluded by its STAGE, not by name, so promoting it pulls it in automatically.
Negative-tested 5 ways. The fifth found a bug in the gate itself: /tokenUrl/ also matches
`tokenUrlX`, so the check meant to notice the contract being renamed could not fail. It
is \btokenUrl\b now. A guard that cannot fail is the one nobody ever finds by reading.
Driver brief §B, BUILD-LIBRARY-CHART-LANGUAGE-TREE: promote gospel-internet's language
tree so a fork can build one from its OWN rows.
gospel's scripts/build-language-tree.mjs does two jobs in one file. The generic half is
the radial layout, the circle packing and the inverse Web Mercator projection. The other
half reads gospel-internet.json, ebible-index.json, gospel-resource-links.json and FCBH
verse scopes to classify each language g/y/r, and emits a second, purely gospel output.
Moving the file whole would have put eBible and FCBH inside library/, and a fork
building from its own roster has no eBible index -- so the literal move is the one shape
that would NOT deliver the reuse the brief asks for.
So the classification stays with its owner and arrives as `statusOf`, returning an
OPAQUE string. Nothing here knows what 'g' means; a consumer colouring by speaker count
substitutes one function instead of forking a data pipeline.
The status is written on LEAVES ONLY, which is not an omission. Rolling a parent's
colour up from its descendants is a RUNTIME decision the chart makes against the
reader's current scope -- and the scope changes as the reader filters, so baking it
would freeze one answer into the data. gospel confirmed the rollup is policy rather
than geometry: nothing in the drawing depends on a parent being the worst of its
children.
The measured constants keep their reasons: the enclosing circle is the tree's real
extent plus room for one label (sized by the deepest ring, packing wasted a third of the
board); the poster layout drops dialects (2,653 dialect slots squeezed the 858 languages
until ~40% of names could not be printed); and the share of the Mercator square is a
parameter with a measured default, because it is what decides how far a reader can zoom
out below the opening view.
check-language-tree-layout asserts INVARIANTS rather than a golden file -- a snapshot of
4,000 coordinates fails on every legitimate change and says nothing about which property
broke. Family circles do not overlap, every roster language is reachable, a missing one
warns rather than vanishing, the drawing stays inside its extent budget, the poster
layout excludes dialects, the status letter is carried through unexamined, and the same
input builds the same tree twice.
gospel's own script is untouched: they switch to this and drop their copy in the single
owner-land already planned, so nothing of theirs breaks in between.
The live API sends imb_gsec as { value, label }, not a number. describeGroup read it
with Number(), got NaN, matched no GSEC level, and dropped the level sentence — not an
error, not a fallback, simply absent, so the row described a people group nobody has a
reachedness figure for. upg100 hit it and fixed their own normalizer; that fixes their
caller and nobody else's, and every other consumer handing this function a RAW API
record lost the sentence the same silent way.
The unwrap belongs at the READ, because this function's contract is "an API or
normalized row" and both spellings are inside that contract. pick() now flattens
{ value, label } to its value, exactly as normalize.js:22 does.
THE LANGUAGE CLAUSE WAS GOING THE SAME WAY, FOR THE SAME REASON, and fixing only GSEC
would have left it. On a raw record the language NAME is at primary_language.label; the
field itself holds an ISO code. The existing read deliberately refuses that code -- it
would print "and speak abf" -- so a raw record simply had no language and fell to the
country-only sentence. labelOf() takes the display half of the same pair, and ONLY for
a nested object: consulting a flattened primary_language would print the code, which is
the defect the refusal exists to prevent. Same for country_code, whose nested label is
also the English fall-back when no resolver is injected.
A raw record now produces a string identical to the normalized one, which is the
assertion that makes the rest of it hard to regress.
Negative-tested: removing the unwrap, dropping either nested-label read, or letting
labelOf read bare strings (so the code prints) each turn it red; restored, green.
internal/lib/facet-walk.mjs's __srcFeatures (injected into the page by
check:facet-changes-map) and library/maps/pin-selection.js's readSourceFeatures (the
packet and zoomToLegendRow) answer the same question about the same private GeoJSON
field. They are two copies because one must be a string evaluated in a browser and the
other an ES module, so they cannot be one function -- but nothing tested that they
still agreed, and they had already stopped.
THE CROSS-CHECK FOUND A DIVERGENCE ON ITS FIRST RUN, which is the argument for it. A
source may hold a single Feature rather than a FeatureCollection; both engines accept
that. The product's reader handled it and the gate's did not, so the gate answered
"cannot measure" about a payload it could read perfectly well. Nobody reading either
file would have noticed: each is correct on its own terms. __srcFeatures now handles
it and the two agree.
ONE DIFFERENCE IS DELIBERATE AND IS NOW PINNED AS SUCH RATHER THAN WAIVED: on a
url-backed source the gate returns null while the product returns [] and warns. Both
are right for their caller -- a gate whose job is to tell "the map drew nothing" from
"I could not look" needs a third answer, while a selection returning null would break
the Set arithmetic every caller does on it. Asserted in both directions, so neither
side can quietly adopt the other's and call it a fix.
AND NEITHER MAY BE SILENT ABOUT A SHAPE IT DOES NOT KNOW. maplibre has a THIRD _data
shape: updateData() stores { updateable } (maplibre-gl-dev.js:49001). No product code
calls updateData today, so this is a landmine rather than a bug -- and it is armed the
moment someone adopts partial updates for performance, at which point the product
reader would have gone back to returning [] in silence: "the map drew nothing", stated
as fact about a payload nobody recognised, one line below the url warning that exists
to prevent exactly that. Both readers now name the unrecognised shape by its keys, and
a fixture holds it, so adopting updateData() trips a test instead of blanking a map.
Negative-tested: dropping the single-Feature case, silencing either reader, or making
the gate adopt the product's url answer each turn it red; restored, 14/14 green.
check:pin-selection still passes, so the product reader is unchanged where it matters.