rega's heap snapshots (leak-722ac7789): Simple Research and All People Groups
mounted twice went 54.2 MB to 65.1 MB, with live Vue apps 6 to 8. Every unmounted
application was still there, one copy per mount, and the retainers were all the
same shape -- a window key set during setup and never cleared. The closure keeps
its component, the component keeps its shadow root, and a whole app survives its
own unmount. An element property dies with the element; a window key does not.
The named one was window.__doxaLogoOwner -> Map -> div.rm-map-canvas -> ShadowRoot
-> app, released only when the map's 'remove' event fires. That listener cannot be
the guarantee the global is cleared, because it only runs if remove() is reached;
it is a consequence of the guarantee, not the guarantee itself. useMapInstance now
drops the claim inside destroy(), before removing, so it goes even if remove()
throws part-way.
As for why remove() was never reached: research-map's unmount hook ran ten
teardown steps and called destroy() last, and two of those steps were unguarded
calls into other composables. A single throw anywhere above stranded the map, and
with it the app. I could not reproduce which step threw without a browser, so the
fix does not depend on knowing: each step is isolated and destroy() runs from a
finally, where nothing above it can skip it. Ordering teardown so the most
important step is last, behind nine chances to fail, was the real defect.
The globals themselves are released by the components that set them -- four verify
hooks in research-map, three in the dashboard map panel, the vocabulary hook in
the grid, which had no unmount hook at all. Released as SETS: __rdashPinAt was the
one the snapshot named, but __rdashMap holds the map and retains strictly more, so
clearing only the reported one would have looked fixed and leaked nearly as much.
The deletes are written one per line instead of looped over an array of names, so
that the check can read them. A release a checker cannot see is worth no more than
no release, the day someone removes three of the four.
The check flags only values that actually retain. A warn-once boolean latch and a
page-wide cache keyed off itself are deliberate and must not be reported, or it
becomes noise and gets switched off. It is NOT in gates:pre: 19 sites remain in
other owners' plugins, and this is a fleet pattern rather than one plugin's bug.
useLegendData returns { items, columns, title, activeFilter, setFilter,
totalCount, totalPopulation, progress, fmtPop, fmtCount }. There is no `rows`.
research-map read legend.rows in three places, and because JavaScript answers
undefined rather than complaining, each one failed in a different costume:
- provide('legendRows', legend.rows) provided undefined, and nothing in the repo
injects that key -- the comment above it described a contract no component
implements
- openPoster's fallback, legend?.rows?.value || [], could only ever be [], so on
any tab where the flat legend is empty the poster had no legend rows
- the packet's page-1 slot was rows: legend?.rows?.value || [] with NO second
source, so that poster's legend was empty unconditionally
and useMapPoster skips the legend slot entirely when the row list is empty. So the
missing legend on the exported poster had two independent causes: the renderer
painting it over the map, fixed separately, and this -- an empty array arriving
through a fallback that could never be anything else.
Both poster paths now share one builder, so they cannot drift apart again, and it
flattens children because a prayer legend is a parent plus two child tiers and
printing only the parents describes bands the map does not draw.
Two further calls went the same way: legend.refresh?.() and
legend.build?.(normalizedPeopleGroups), against methods the composable has never
defined. The optional call swallowed both, so two lines that read like steps did
nothing. `items` is a computed and re-derives on its own, so they are gone rather
than repointed.
exp-shepherd found the same misspelling in countries-map, where it threw instead
(NE-17). The check therefore guards the class rather than these lines: it reads
the returned key set from the composable and reports any consumer reading outside
it. It is NOT in gates:pre yet, because it still reports countries-map, which
belongs to another owner.
Writing it turned up two traps worth keeping. Stripping block comments with one
global regex DELETED REAL CODE -- a slash-star inside an ordinary string opens a
comment nobody wrote and the replace runs to the next real terminator -- and the
check confidently reported "no consumers at all" while both sat in the files. And
matching property reads by name flagged every t('legend.colLabel.religion'): an
i18n key spelled like a property access is not a read.
The projection toggle and the zoom toolbar share the top-right corner. The toolbar
was pinned at a literal top:34px, a number that was correct when it was chosen:
the toggle then measured 74x23 and ended at y=31, which this panel's own comment
records as a live measurement. A later accessibility fix gave the toggle
min-height:24px for WCAG 2.5.8. Nothing in this shadow root sets box-sizing
globally -- it appears exactly once, on the legend -- so 24 was a CONTENT height
and the rendered box became 24 plus 8 padding plus 2 border = 34px. Sitting at
top:8 it now ended at y=42 and ran 8px into the toolbar, which has z-index 10
against the toggle's 3 and so drew straight over it.
Nobody did anything careless. A correct fix to one control broke another because
the agreement between them was a magic number living in the other rule, where the
person changing the first had no reason to look.
So the toggle gets box-sizing:border-box, which makes 24 mean twenty-four and
keeps it a legitimate 24px touch target, and the toolbar's offset is now
calc(--rd-proj-top + --rd-proj-h + --rd-ctl-gap). The relationship is stated once,
on the element that owns it, and both the desktop rule and its mobile restatement
read it. Changing the toggle's size now moves the toolbar by construction.
The check computes the toggle's rendered height from its own declarations the way
a browser does, honouring box-sizing, rather than asserting either number --
restating 34 here would have rebuilt the same trap one file further away. Run
against the code as it stood before this commit it reports the overlap and its
exact size. It also requires the toolbar to DERIVE its offset, because a literal
that is correct today is the precise shape of this bug.
Two faults rega measured in the /embed-test re-shoot at 1440, both on main.
THE MAP SIZED TO THE VIEWPORT, NOT ITS HOST. This element had no :host rule at
all, so it defaulted to display:inline with no height of its own and .gm's
absolute inset:0 resolved against whatever ancestor happened to be positioned:
a 1198x900 canvas inside a 1198x560 embed card, with ~340px including the
attribution drawn below the card and clipped. The attribution is a licence
obligation, so clipping it is not cosmetic. Now :host{height:100%} so a
height-bounded slot decides, with min-height:420px as a floor so an auto-height
host still gets a usable map rather than a zero-height one.
NOTHING SAID IT WAS WORKING. First data paint takes 7-30s under SwiftShader —
161 countries, 33 Indian states, 2,105 dots — and for that whole window the
reader saw an empty basemap, indistinguishable from a broken embed. Adds a
notice that starts visible on the first frame and clears after the LAST paint
(paintAdm1), not on 'load': the style fires 'load' early, so clearing there
would retire the notice during exactly the wait it explains. An error replaces
it rather than stacking, and the error path clears it too, so a failure cannot
leave it spinning forever. role="status", since this is progress and not an
interruption.
connectivity.loading added in all eleven locales; parity, keys-exist,
shadow-css and legend-logical-insets all pass. The notice uses inset-inline, not
left/right: it is translated text and mirrors under ar.
Four rules in the resources list and tables are physical where the thing they
position follows READING ORDER, so they do not mirror under dir="rtl":
- .r-code sat at margin-left of the language name, landing before the name in Arabic
- .res a + a used margin-left for the gap that exists so "Holy Bible" and
"JESUS Film" do not read as one string; mirrored, the gap moves outside the
pair and the two links run together again
- .r-pop aligned right, which is the START of an Arabic row, not the end
- .gt-filter's accent bar marked the left edge rather than the leading edge
Now inline properties. The three border-left-color rules in resources-map.vue
are deliberately untouched: each sits on an element carrying dir="ltr", so its
edge is physical in every locale by design.
It derived the tree root from the page path. Embedded on a host page (/embed-test, a partner
site) the page is the host's, so the manifest fetch missed and it rendered an empty checklist.
It now uses bundleTreeRoot() (the bundle's own script URL, captured when the script starts),
and falls back to the page path only when that is unavailable, as on the dev server.
vue-i18n v9 pluralizes by an English-shaped INDEX and applies it to every
locale. Measured on a real instance, authoring 'a | b | c':
ru n=0 -> the ONE form, n=2 -> the MANY form CLDR: 0=many, 2=few
ar n=3, 11, 100 -> ALL the dual form CLDR: few, many, other
That is confidently wrong grammar that looks translated, which is worse than
the single fixed form the regions summary shipped until now. upg100 proposed a
plural key and was correctly refused on this exact ground
(upg-100-list.vue:3006) — "real plural support needs a pluralRules map at
createI18n". The refusal's premise was the missing map, so this adds it.
THE PRECONDITION SWEEP (dx-shepherd), before installing anything: every
catalogue value containing " | " across all 11 locales, and every call site
passing a plural choice.
pipe keys: wall.prayedCount, wall.havePrayed — and ONLY in en. Every other
locale ships a pipe-free label form ("Помолились: {n}").
call sites: exactly two, both in wall-of-people-groups
(CommentsDialog.vue:36, wallCanvas.js:198).
BEFORE/AFTER, 2 keys x 11 locales x n = 0,1,2,3,11,100:
132 renders compared, 0 CHANGED.
Zero changed because the map is SCOPED TO EXCLUDE en, which is the one locale
holding pipe strings today. en is also the case dx-shepherd's assertion (3) is
about: vue-i18n's 3-form default is zero|one|many while CLDR for en is
one|other, so an en string written "none | one | {n}" would change meaning at
n=0. Leaving en out means it cannot.
★ THE BUG I SHIPPED INTO MY OWN FIRST DRAFT, caught by reading the rendered
Russian rather than by any assertion: the index was taken against the fixed
zero|one|two|few|many|other list, which is only correct for a locale that HAS
all six. ru has {one, few, many, other} — no zero, no two — so 'few' sat at
global index 3 while the authored string holds it at 1, and n=2/n=3 rendered
"2 стран" instead of "2 страны". ARABIC HID IT COMPLETELY, being the one locale
that does have all six, so its two indexes coincide. The index is now computed
against each locale's OWN category subset, and ru/ro are authored in that order.
ar 0 لا دول · 1 دولة واحدة · 2 دولتان · 3 دول · 11 دولة · 100 دولة
ru 0 стран · 1 страна · 2 страны · 3 страны · 11 стран · 100 стран
(regionCount likewise; all six categories distinct in ar, all three in ru)
check:plural-forms carries studio's three assertions, each mutation-tested to
fail: FORM COUNT (a 2-form ru string fails against its 4 categories), the
tStatic PATH (reading a pipe key through tStatic fails — tStatic does no
selection at all, so the page would show the literal "a | b | c"), and ZERO (a
5-form ar string fails because ar has an explicit zero category). Every mutated
file restored byte-identical afterwards.
The regions shim gains tc(key, n). t() resolves through tm()+rt(), and rt()
takes a message only — there is nowhere to pass the choice — so a pipe string
would come back whole. tc goes through vue-i18n's own t(key, named, choice).
Registered in all three places and VERIFIED by re-reading the parsed
package.json, not by trusting the replace: my last gate registration silently
no-opped on two of three anchors and studio caught it at the landing.
Diffing what the lightweight packet ACTUALLY prints on each path against
its card (map, triangle, profiles, wheel, points) found the wall path
missing two pages and half of a third -- while the legend path matched.
Same class as the sets page: the renderer could draw every page, and
only one of the two hosts supplied the arguments.
Three gaps, three suppliers:
The map. printPacketAs passed no mapSvg and there was no default, so a
wall-printed packet opened on a title-only cover. printPacket now lifts
the bundle's own poster (posters/<id>.html, the same file printPoster
already reads) when the host passes nothing -- via a liftPosterSvg
helper shaped for unit-testing against a real generated poster, no
fetch. Verified on laos: the svg is found and lifted. And PosterWall now
actually passes assetsBase into its print opts; without that one line
the lift's guard would skip silently and the wall would STILL print a
mapless packet -- the seam-without-supplier failure one layer down,
caught in the same pass.
The triangle. No funnel was supplied and none defaulted. The default
states ONLY the steps: listCount and unassigned stay absent, so
funnelPage omits the share line and the tip rather than printing a
denominator that is wrong under a non-default list. Proved two-sided:
steps-only renders the triangle with no share claims; adding listCount
renders both.
The profiles. The wall's fetch carried primary_language but not
imb_gsec, so every wall-printed description ran at half length -- the
third fetch site with the same missing-field defect, found the same way.
setsPage landed as a renderer capability with no data supplier: it
returns '' without data -- correctly -- which meant the FULL-DETAIL
packet had never once printed its distinguishing back page in real use.
The full-vs-lightweight split existed in code while the full packet was
missing the page that makes it full.
packetSets.js is the supplier: a pure decode+join with zero imports, so
the identical function runs under Node (the generator reads the table
from disk) and under Vite (the browser fetches it) and the join cannot
fork between the two. The table stays with the caller -- the same seam
as strings, christian and describe.
The join is measured, not assumed: 2,091 of 2,107 groups match by slug,
rings distribute 1-10 with real shape, and the 16 unmatched are inked as
UNKNOWN on the bounded figure rather than guessed onto a side --
an un-inked category reads as zero on paper.
Wired at every path that prints a packet: the offline generator (all 28
zones verified carrying the page, e.g. laos: 0 inside, 94 outside, 1
unknown; 94 placed with "25 same dialect, same people"), the legend
Export menu, the poster wall, and the selection packet -- the last three
through one opts.sets loader fetching the SAME served asset-pool copy
the generator reads, so an embedded bundle prints the same page as the
dev tree.
The decode is a third reading of near-neighbors.json's cols/dict format
(nearRows.js documents being a copy of upg-neighbor's, pending promotion
to library/). Flagged in the header rather than hidden: when the loader
is promoted, the decode here collapses into it and only the join
remains.
An RTL audit of research-map, research-dashboard and its four dashboard
components. Translation was already sound -- 101 keys, all present in all eleven
locales, no hardcoded English anywhere user-visible -- so what was left was
direction: rules that pin a physical side while the reader's text flows the other
way.
Three an Arabic reader would notice immediately. Both search icons sit at
left:8px and left:10px while the input around them mirrors, so the icon ends up
on the far side of the field it belongs to. The detail drawer's border-left
stays on the geometric left, which in Arabic is the drawer's trailing edge, so
the panel appears unattached on the side it hinges from.
The rest are quieter but the same mistake: a badge, a provider chip, the two
edge affordances, a table's numeric columns and its indicator, a result row's
spacer, the projection toggle.
Every change is LTR-NEUTRAL, which is what makes this safe to land on its own.
In left-to-right, inset-inline-start resolves to left, text-align:end resolves
to right, margin-inline-start resolves to margin-left. The English view cannot
move; only the Arabic one can. Fourteen insertions against fourteen deletions,
one property each, nothing else touched.
The pattern is not new here -- .rd-legend-map already uses inset-inline-end,
with a comment recording that a physical right pinned it to the wrong edge in
Arabic. These sixteen are the rules that were missed when that lesson was
applied.
What this does NOT establish: that Arabic renders correctly. Every RTL gate in
the repo needs a browser, so this was read rather than run. The three visible
ones want eyes on ar before anyone calls RTL done.
Every scripture, name and reference, on the printed sheet and on the canvas, is set in its own direction, so an English verse on a right-to-left page keeps its full stop at the end.
useDashboardData read `lang` straight out of sources.json, where it is frozen at
"en", and nothing ever overrode it. So every row the dashboard fetched came back
English regardless of the reader's locale.
The result was not a uniformly English page, which would at least be honest. It
was worse. DashboardChartsRail localizes its country labels through
countryName(), so at ?lang=ar the top-countries chart read الصين while the table
row directly beneath it read "People's Republic of China" — and a page that is
half translated looks like a bug in the half that works. Three surfaces rendered
the raw English label: the data table's country column
(dashboard.config.js:299), the detail drawer (DashboardDetailDrawer.vue:171) and
the search-result subtitle (DashboardTopBar.vue:327).
Fixing it at the SOURCE rather than at the three render sites is what makes them
agree: ask the API in the reader's language and the labels arrive correct, with
no countryName() call and no third spelling of a country's name to reconcile.
DataSourceManager.js:261 has done exactly this for its own callers all along —
`if (this.locale) extraParams.lang = this.locale` — and doxa-research-map,
doxa-simple-map and country-posters all pass a locale in. This composable simply
never got the same treatment.
BOTH LOCALE CHANNELS, because it genuinely arrives by either: `lang` is provided
by ProfileLoader for an element configured through profile-config, and
window[PAGE_LOCALE_KEY] is what a generated leaf sets from ?lang= — the URL a
person actually shares. Reading only one left the regions dashboard's data
labels English on the shared-link path while its chrome translated, which is the
measurement this copies rather than rediscovers.
Resolved at REQUEST time, not captured at setup, so a locale switch cannot pin
the dashboard to whichever language happened to load first.
AND A LOCALE CHANGE NOW REFETCHES. load() ran only onMounted, so switching
language mid-session would have re-rendered chrome over data still in the old
language — the same defect one layer up. Same shape as region-dashboards.vue:680,
guarded on a real change so the mount fetch is not doubled.
Caught while writing it: my first version of that watch called inject() inside
the callback, where it does not work — it warns on every tick and returns null.
The ref is captured in setup and only read later, which is what this file already
does for profileConfig, for the same reason.
Proved headless: locale resolution across all four channels (leaf ?lang=,
injected profile-config, neither, and injected-beats-runtime), then that the
chart, the table column, the drawer and the search subtitle produce the SAME
string for CHN, LAO and COD at ar — the disagreement that started this. The
API's ?lang= behaviour itself is not my assumption: it was measured live at ar
and zh on region-dashboards (its comment at :366).
rega RQ20: the driven page asks for show-search:false and show-filters:false and
renders the search box and the filters bar anyway, with 0 console errors. There
were no errors because nothing failed. The key never arrived:
· the preset writes KEBAB into profile-config — "show-search": "false"
· ProfileLoader PROVIDES that object; it does not bind its keys as props
· the profile reads props.showSearch (CAMEL), which keeps its default of true
Three correct-looking layers and no join between them. A preset key that does
nothing looks exactly like a preset key that works, which is why the fix ships
with a gate rather than a comment.
The grid now reads these flags off the config as well as the prop, in the same
prop -> cfg ladder researchUrl beside them already used, and accepts BOTH
spellings: someone writing profile-config by hand reaches for the kebab spelling
because that is what the docs show on the tag, while the prop is camel.
Accepting one silently is what made this inert. An explicit prop still wins.
Proved on five cases including the exact preset, an explicit prop overriding the
config, and config:true keeping the control on.
check:instance-config-keys asserts every preset key reaches the profile that
renders it. Writing it was the interesting part, and it was WRONG TWICE:
1. First version matched only `cfg.` and reported NINE FALSE POSITIVES across
four plugins — big-picture reads config.value.accent, doxa-simple-map reads
profileConfig?.value?.tabs. The config object's variable name is each
profile's own business; matching on it measured my naming habit, not the
contract.
2. Widening it to "any accessor" then matched `props.showSearch` — the exact
line that does NOT work — and the gate went GREEN on the defect it exists
for. props is the one object that cannot carry a profile-config key, so it
is now masked out before the match.
Mutation-tested after that: reverting the fix to props-only makes it fail again,
and the grid file is restored byte-identical. A gate I have not watched fail is
not evidence, and this one passed for the wrong reason once already.
The four bundles that embed the grid are rebuilt, so the fix is in the shipped
.js and not only in the source rega cannot run.
PinHint self-gated on any <canvas>, so the Wall's own renderer got a tap-a-dot coach-mark over its
dialogs; it now matches only MapLibre and Mapbox canvases. The "?" sat bottom-right, over the map
attribution on several plugins and the zoom-out button on one; map plugins use the corners, so it
moves to the top centre.
Two interop defects on production plugins, found by reading the links rather
than the code around them, and corrected against DX-life's own component once
dx-shepherd pointed me at it.
THE COUNTRY LINK POINTED AT THE PEOPLE GROUP. On the regions dashboard's card,
the country name under the title linked to that people group's own profile —
the same destination as the "Full Profile" button directly beneath it. Two
links on one card, one labelled with a country, both landing in the same place.
Theirs, in BOTH card variants (app/components/public/UupgsList.vue:610, :638):
localePath(`/regions/${countrySlug(country_code.value, country_code.label)}`)
A page on the HOST site — not a research profile, and not the ../countries/
path my first pass reached for. So the base is a PROP, not a constant: only the
host knows where its country pages live, and DX-life's are under /regions/.
Prop, then profile-config, then the site base, so an embedder can point country
names anywhere without touching the research base.
people-groups-grid is ported from that same component and had the same defect
in a different disguise: `${researchUrl}../countries/<slug>`, climbing out of
the research base to a section DX-life does not use for this. Both now build
the same URL from the same countrySlug.
Our countrySlug is theirs: identical body, identical slugify (every transform,
in order), and the tables behind it match exactly — 242 codes each, zero
missing, zero differing labels. My first comparison of those tables parsed
their file into ZERO entries and reported "no differences", which is the shape
of a check that cannot fail; the number above is from the run that actually
parsed 242.
FOUR LINKS DEPENDED ON A TRAILING SLASH NOBODY WAS TOLD ABOUT. researchUrl and
prayBaseUrl are set by whoever embeds the element, in someone else's HTML. The
joins were bare concatenation, and RegionCountryMap's own prop comment records
the contract without ever telling the embedder:
Pray -> prayBaseUrl/<slug>, Full Profile -> researchUrl<slug>
One needs a trailing slash, the other must not have one. An embedder writing
research-url="https://doxa.life/research" — the spelling a human would choose —
got "https://doxa.life/researchbantu-tswana" on every profile link the plugin
draws: a 404, with nothing logged anywhere.
We cannot test what an embedder passes, so joinBase() encodes the rule instead
of documenting it: exactly one slash, whatever we are handed. Eight shapes
checked, including a doubled slash, a leading slash on the path, an empty path
and a null base.
Driver test round 2, #2 — the half the @media print rules could not do. The
complaint was "no loading indicator on a click": the reader presses Print, the
dialog takes a moment to rise, and nothing says anything is happening. It is
worse on this page than most, because what the dialog waits for is a MAP —
press it a moment early and the sheet carries a grey rectangle.
printInPlace.js gains printWhenReady({ waitFor, onBusy, timeoutMs }). This
pattern already exists five times in the repo (upg-100-list c54a11d5a, four of
exp's 786a83461, PeopleGroupsWall.printWall); they agree on the easy half —
set a flag, print, clear it — and each invents the hard half, which is what
"ready" means and what happens when ready never comes. The hard half is the one
that strands a reader, so it is the half that graduates:
· THE TIMEOUT PRINTS. It does not reject and does not cancel. A reader who
clicked Print and waited has asked for paper; a slow tile server is a reason
to print a plainer map, not a reason to produce nothing.
· allSettled, not all — one waiter that rejects must not cost the print.
· onBusy clears in a finally, on every path: a rejected wait, a throwing
print, a host with no window.print. A busy state that outlives its own
operation makes the page look broken rather than slow.
RegionCountryMap answers when it is done, and map.loaded() is not that answer —
it reports true while tiles still stream, which is how gospel's charts printed
blank twice today. The condition is: style applied, camera stopped, tiles for
this camera in. It checks synchronously FIRST and only then subscribes to
'idle', because this component's own header already records that once('load')
measured unreliable in both directions here — an event that fired before you
listened never arrives. Always resolves, always removes its listener.
The button is quiet, sits under the title, and is hidden on paper. Busy state
carries aria-busy and :disabled, not just a changed label: the screen reader is
told, and disabled is what actually stops a second print. Both strings are in
all 11 locales; "Print" was already translated 23 times elsewhere in the tree
under other keys, so this adds the key the dashboard's own catalogue lacked.
Mutation-tested, since the failure this guards is the path nobody takes: a
waiter that never settles returns "timeout" after the ceiling and prints anyway
(201ms measured on a 200ms ceiling), a rejecting waiter still prints, and the
busy flag returns to false in all three.
Round 2 item 6. The control was already built in PosterWall -- state,
persistence, grid binding, markup -- and gated behind an opt-in prop that
only the regions dashboard passed. So most of this is enabling it rather
than writing it, which is what "reuse the pattern, don't re-derive" asked
for. I checked that before building anything, because the last time I
added a feature without checking which surface already had it, it landed
somewhere nobody looks.
Auto is genuinely new: the choices were 2 to 5, always forcing a count,
and the brief asks for Auto meaning today's CSS with overrides on top.
gridStyle already returned {} for a non-positive value -- the comment at
that line even says so -- so Auto needed no new layout branch. It needed
to become REACHABLE: a 0 in the choice list, a label, a read path and a
write path. It extends the earlier min-2/max-5/default-2 ruling rather
than contradicting it, since those bound the counts and Auto is the option
that declines to pick one.
Round-tripping the storage values, rather than reading the code, caught a
bug I had just written: getItem returns null when nothing is stored, and
Number(null) is 0 -- which is exactly Auto now that 0 means something. So
every first-time reader would have silently got Auto instead of the
default, and merely ADDING an option would have changed the layout for
everyone who never touched the control. A missing key now returns null and
falls back to the default; a stored 0 reads as Auto.
Auto is also persisted, which needed its own clause: the range check
rejects 0, so without it the one value that is not a count would be the
one value that never survived a reload. A preference that forgets only
some of its values is worse than one that forgets all of them, because the
reader cannot tell it is broken.
The chart's theme names, its row labels and the closing line print in English (the blessPoster defaults) until studio's round B carries the theme keys; the title, the DOXA line and the glue label are already translated. lang and dir still follow the page, so right-to-left locales lay the chart out right to left.
A window key holding a closure pins the whole app after unmount -- the
component, its shadow root, and everything they reference, measured at
roughly 10 MB per mount. The three test handles here each close over live
state (the search index, the map, the selection), so each one is a full
retention path.
SIX GLOBALS, NOT THREE. The check names the three it can see, but each
named handle has a paired per-instance registry --
__UPG_SEARCH__/__UPG_SEARCHES__, __UPG_MAP__/__UPG_MAPS__,
__UPG_SELECT__/__UPG_SELECTS__. Releasing only the named half leaves the
registry holding the same closure, so the leak survives in the copy nobody
was looking at.
The two halves need opposite handling, which is why this is not one loop.
A named handle is a single slot that the last mount overwrites, so
deleting it blindly would strip a LIVE sibling's handle -- it is cleared
only if it is still the object this instance published, captured at
assignment so it cannot drift from what went out. A registry entry is
keyed by instance, so my key is always mine to delete, and deleting the
whole registry would strip the siblings.
Verified against the two-instance case rather than the single-mount one:
A mounts, B mounts and takes the named slot, A unmounts -- B keeps its
handle and its registry entry, and after both unmount nothing is
retained.
One delete per line, deliberately: the check reads these statements, so a
loop over a key list would release correctly and still be reported as a
leak. The whole block is wrapped because a frozen or cross-origin window
must never break teardown.
Twelve globals were set across three plugins and none was ever deleted. Each
pins whatever it closes over — a map instance pins its canvas, its sources and
through them the whole app — so every mount left another copy behind, measured
elsewhere at roughly 10 MB each.
Released as a set rather than one at a time. A block that sets several globals
and clears the obvious one leaks the rest silently, and the obvious one is
whichever the author happened to remember.
Identity-checked, which is why the value is recorded as it is set rather than
cleared by name. Two instances can share a page, and clearing by name alone
would let the second unmount delete the first's live hooks — trading a leak for
a harder bug in which a live map loses its test surface at a distance.
A first attempt compared against a name that did not exist. Inside the try/catch
that guards these, the ReferenceError was swallowed and every delete silently did
nothing — a leak fix that looks exactly like a leak fix and leaks. The guard that
makes the assignment safe is the same guard that hides its failure, which is the
argument for recording what was set rather than inferring it at teardown.
The guard added last time is what kept this broken. createBoardMap resolves only
after awaiting the load event, so by the time the component runs, load has fired.
The guard asked map.loaded() — which does not mean "the load event fired" but
"the style and every tile are loaded" — and the basemap tiles were still
arriving. It returned false, took the else branch, and registered a handler for
an event that had already happened. A check added for a hypothetical future
engine broke the only case that exists.
Defensive code that changes behaviour is not defensive. If a check is ever wanted
here it is isStyleLoaded(), which is true at this point; loaded() answers a
different question than the one being asked.
The figure was crushed rather than unstyled. The zero flex-basis shipped
correctly and the figure was still 39 pixels wide: a basis of zero lets it grow
from nothing, but min-width:0 also lets it be squeezed to nothing, and the
decoder card's own content width wins that contest. The figure now refuses to go
below a usable size and the card shrinks or scrolls instead, because the chart is
the main view and the legend explains it.
Seven changes to two plugins, re-applied onto current main as ONE source patch
rather than rebased, because the rebase conflicted in the generated bundles —
where a conflict is never resolvable by hand, since neither side matches what
either source builds. Source re-applied clean, everything rebuilt from it.
people-groups-grid
· the saved column count is restored before first paint, not after, so a
returning reader no longer watches the grid snap from auto to their choice.
It re-reads if the profile-config key arrives late, and never over a choice
the reader has since made by hand.
· the cards-per-row control is inset from the card edge it was sitting level
with (Driver round 2, #5).
· the window vocabulary handle is released on unmount, identity-checked so the
first of two grids on a page cannot strip the second's live handle.
regions-dashboard
· the four stat cards no longer let Population stretch across a tablet row.
auto-fit collapsed the empty tracks; auto-fill with the track minimum capped
at a quarter of the row gives at most four columns and never a stretched
orphan. Measured at nine widths.
· the dashboard is printable at all. It had no @media print rule, so the app
shell's absolute root and inner scroller meant the browser printed one
screenful; the legend stayed on top of the map; interactive chrome printed.
· both window globals are released on unmount, not just the remembered one,
each identity-checked and on its own line.
shared asset
· the worldmap watermark is 242 KB rather than 311 KB — same 394 paths, same
drawing, coordinate precision trimmed to what the render resolves.
Neither chart drew anything. createBoardMap already awaits the map's load event
and resolves after it, so attaching a second load handler waited for something
that had already happened: the layer setup never ran, each map held only its
basemap layers, and the panel showed an empty whiteboard.
It failed silently, which is why the visible-failure branch did not catch it.
Nothing threw — the map was fine, the source was simply never added. A guard that
reports errors cannot report an absence of work.
The setup now runs directly after the await, and still binds once('load') if a
future engine resolves earlier. That keeps the fix correct rather than merely
right today.
The figures also grow from a zero basis instead of from their content. The map
inside is absolutely positioned and has no intrinsic width, so a basis of auto
asks for a content size of nothing and the legend takes the column — the figure
was measured at 39 pixels across while the decoder had the rest. This was hidden
behind the first bug: an empty chart and a chart with no room look the same.
The neighbour table is 2.6 MB. Until it arrives the joined set is legitimately
empty, so both panels drew a chart of nothing and captioned it with a count of
zero — indistinguishable from a real answer of none, which is the same defect as
a join failure reported as a fact about a people.
Found by sweeping my own plugins for the Driver's bug classes rather than by
anyone reporting it, which is the point of the sweep: the newest code was the
code with the defect, because it had been read least.
The loading line is announced politely for a screen reader, since the change it
describes is a region of the page becoming real rather than an alert.
Every width change reset the legend from the breakpoint, so opening the decoder
on a phone lost it on rotation, and closing it on a wide window reopened it by
dragging the edge. The resize was undoing the reader's decision rather than
responding to it.
The width now decides only until the reader does. After that their choice holds
at both widths.
This is the same defect fixed in the other plugin's legend earlier today, written
again here a few hours later, because the two legends are separate
implementations of one control and only one of them had learned. That is the
argument for promoting the control, not for being more careful next time.
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.
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.
Two things the Driver found, both about a control being in the reader's way
rather than about it not working.
The collapse caret was absolutely positioned over the panel's top-right, and the
title was hidden when collapsed — so collapsing left a lone glyph floating on the
map with nothing to say what it would reopen. It now sits inline in a header row
beside the title, which is where the shared legend puts its own: a flex item with
no positioning at all. The header stays visible when collapsed, so the control
always carries its label.
Side by side there is room for the target and its decoder. Stacked on a narrow
pane there is not: the decoder is a tall card that pushes the chart off the
screen, so a reader who asked to see the target got a glossary. Below the
breakpoint the legend now starts closed behind a named toggle. It is not hidden —
the control says what it opens, and opening is one tap.
The toggle reuses keys that already exist in every locale rather than adding a
word of its own. A new string would have meant eleven catalogue entries owned by
another tab and landing in another batch: a cross-owner dependency bought for one
label, when the fleet already had the sentence.
The map inside is absolutely positioned to fill its figure, but the figure was
not a positioned ancestor, so it resolved against the panel instead and covered
the legend beside it. The bounded set's figure already carried position:relative;
this one did not — the difference two files drift into when the same rule is
written twice rather than shared.
In the narrow column a flex child sized by its content can resolve to zero
height, which is what a chart measured at 0x0 is. The figure now has a floor, and
stretches rather than centring itself into nothing.
The legend takes a flex basis instead of growing to its content. Reported
squeezed to 145x145 at 1440: a card that is only told a maximum will still take
what it asks for, and the figure had nothing but min-width:0 with which to argue.
The ring legend declared a fullscreen emit and rendered a labelled, focusable
button for it. The parent bound select and not fullscreen, so the button looked
right, tabbed right, announced itself right, and did nothing. It is a Driver ask,
and it is the half-wired shape exactly: the visible half worked, so nothing
appeared broken.
The fix is not to bind the emit. The button is now the shared control, so there
is one implementation instead of two — and it brings the part a second one would
have missed. iOS Safari does not implement requestFullscreen on arbitrary
elements and fails silently, so the shared control carries a pseudo-fullscreen
fallback. A hand-rolled handler would have worked everywhere I could test it and
nowhere I could not.
A first attempt kept the hand-rolled button and called the shared component's
toggle through a template ref. That component has no defineExpose, so under
script setup the method is private and the call would have resolved to undefined
— silently reintroducing the same defect while appearing to fix it. Checked
before committing rather than after.
The panel element is passed in, because full screen should show the chart and the
legend together, and the legend does not own the panel it sits in.
Found by sweeping for the half-wired-config class rather than by anyone reporting
it, which is what that class is for: nothing about it looks wrong from outside.
The layout modules moved to library when they were promoted, and three of my
files still named the old paths: the two new whiteboard components, which were
written before the move landed, and the geometry check, which was written after
it and still copied the old import from its neighbours.
GiBoundedSet also carried an unresolved conflict — markers and all — committed by
a cherry-pick loop of mine that resolved generated output automatically and swept
the source conflict along with it. The file had both imports of the same symbol,
one of them pointing at a deleted path. It parsed, so the SFC check passed; only
the bundler would have caught it, and only at the point where it could not
resolve the file.
The lesson is about the loop, not the file: resolving one class of conflict
automatically is fine, but `git add -A` afterwards does not distinguish the
conflicts you meant to resolve from the ones you did not. A loop that stages
everything needs to refuse when a marker is still present.
Both set charts now pan and zoom. The chart still makes one claim — which side
of the line a group is on — so nothing here encodes distance or rank; what zoom
buys is reading the names, because at two thousand dots the inside is a cluster
until you can get close to it.
The boundary is the shared dish layer, which is the same layer a language
family's circle uses, so it is not a new type for either of us. Its radius takes
an exponential-base-2 zoom interpolation rather than a fixed pixel value.
circle-radius is screen pixels: a constant would hold its size while the dots
spread out from under it, leaving the circle correct at exactly one zoom and
wrong at every other — and for a dot near the edge the inside/outside reading
would silently invert, which is the only thing this chart says. Pixels per degree
double with each zoom level, measured at exactly 2.000000 between adjacent
levels, so the interpolation tracks map units exactly rather than approximately.
The boundary is drawn as a line with a transparent fill. A filled disc would say
the inside is a region, when what the chart means is a threshold.
The cross rides in the point collection as its own tier rather than as an
overlay, so it pans and zooms with the drawing instead of floating above it.
The panel now mounts the zoomable chart in place of the SVG. Pan, zoom and
labels that appear by tier as the reader moves in were the ask; the engine
supplies tiling and label collision that an SVG version would have to
reimplement, and badly.
The printed component stays SVG, which is not an inconsistency: a sheet has no
zoom and no hover, so paper and screen genuinely want different renderers. What
they share is the ring vocabulary, so the two cannot drift apart in what they
call a ring even though they draw it differently.
The ask changed and so did the call. A static target is fine in SVG; a zoomable
one carrying ~2,100 labels is not. The engine tiles the collection per zoom so it
draws less when zoomed out, and its symbol layers do label collision, which is
the part that would take longest to do badly by hand.
The label staging is one layer per tier with its own minzoom: band names when
zoomed out, ring names in the middle, group names in close. None of that is
tree-shaped, which is why it is a shared layer rather than a copy of one.
Coordinates go through the shared projection, never a scale. It is the exact
inverse of the renderer's transform, so a circle stays a circle; a linear scale
would draw concentric rings as ovals worsening outward. The only number this
chart owns is its extent.
Dimming a deselected ring is a paint expression rather than a filter, for the
reason the SVG version dims instead of hiding: a ring emptied of its dots reads
as a ring with no groups, which is the opposite of what a reader selecting it
just asked for.
The layer id is read back from the handle rather than written from memory. The
first version reached for a name the module does not use; inside a try/catch that
does not throw, so the dimming would simply never have happened — a legend that
appears to do nothing rather than an error. Asking the handle means a rename
cannot silently disable it.
The projection is passed in rather than built here, so the chart and its checks
share one instance and this module still owes nothing to a coordinate system it
does not define. The dish takes its radius from the projection's exposed scale:
deriving it by projecting two points and subtracting would be right on the
equator and wrong everywhere else.
The guard reports the outermost latitude, because that is the real limit and
shape is not. The projection is the exact inverse of the renderer's transform, so
a circle stays a circle at every extentFraction — measured at 0.000% aspect
error. An earlier reading of mine said otherwise and was wrong: it computed
screen x as longitude times cos(latitude), a plate-carree correction Web Mercator
never applies, which manufactured a distortion the renderer does not produce.
What does bite is the pole. The outermost point reaches 52 degrees at the default
0.17, 77 at 0.35, 83 at 0.45, and Web Mercator is undefined at the poles with
engines clamping near 85.
That ceiling is a property of extentFraction alone, not of a chart's extent:
the scale is extentFraction over extent, so the outermost point is at
extentFraction whatever the extent, and every chart reaches the same latitude.
Measured across extents 100, 228, 260 and 1000 — identical to three decimals. So
a consumer cannot buy headroom by choosing a bigger extent, and a check written
against one chart's ceiling holds for all of them.
The shared whiteboard layer draws a point collection where each feature carries
an id, a label, a tier and a priority, and stages its label layers by tier and
zoom. This decides which features exist, what tier each belongs to and how they
rank — which is this chart's business — and it does not convert to degrees.
Ring and band labels are placed on the radii the chart already draws rather than
on a second calculation, so the label and the ring it names cannot drift apart.
Groups rank by population, so when two names collide the larger people wins: a
reader zoomed out should meet the names carrying the most people, not whichever
was drawn first. Bands outrank rings outrank groups, because that is the order a
reader zooming in meets them.
Coordinates out are abstract on purpose. Converting them is a projection, not a
scale — the whiteboard shrinks degree height outward so the map's own stretch
cancels it, and a linear scale skips the cancellation. Measured on a ring of 720
points: with a linear scale the rendered aspect is 0.9968 at the inner ring and
0.7952 at the outer, so the target draws as ovals that worsen outward, worst
exactly where the ring labels sit. With the projection it is 1.0000 at every
ring.
That measurement also settles where the guard belongs: at the inner ring the
linear error is three parts in a thousand and would pass any sane tolerance. An
assertion placed there alone would be inert against the one mistake it exists to
catch. It goes at the outer ring, and at both.