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.
Found by the bug-class sweep. RegionCountryMap is passed :research-url; the
UupgsList beside it is not — zero research mentions in its entire tag. So a host
setting profile-config.researchUrl got it honoured on the map popup's Full
Profile button and silently ignored by every profile link in the country list,
which fell back to a hardcoded doxa.life default.
The shape is worth more than the line: one config, two consumers, only one
bound, and the unbound one carrying a plausible default. A missing binding that
fell back to an empty string would have rendered a visibly broken link; this one
renders a working link to somebody else's site, which is why it survived. It is
invisible in either file alone — the parent's tag list has to be read against
the children's props.
One line. Bundle rebuilt; the binding is in the artifact.
Mobile has had a fold control since the narrow layout existed; desktop had none,
so a reader who wanted to see the map underneath had no way to move the legend
off it. The state was already there — every section read "not narrow OR open", so
on desktop the OR was always satisfied and the flag governed nothing. Now the
flag alone decides at both widths.
The control is deliberately not a new one. Same 22x20 caret, same rotation, same
aria contract as the shared SemanticTreeLegend's collapse button, which the
mobile legend also copies. Matching it costs nothing and means a reader who has
met one of these legends has met this one.
Collapsed, the panel shrinks to the caret. Hiding the contents alone would have
left a 380px box of empty background over the map: the reader presses collapse
and gets a blank panel instead of their map, which is worse than having no
control at all.
The choice is remembered for the session, so it survives working with the map but
not a fresh visit, and a remembered choice beats the width default — otherwise
dragging a window across the breakpoint would silently reopen a legend that had
been deliberately closed, undoing a decision rather than responding to one.
Storage access is wrapped: a sandboxed embed throws on it, and a legend that
cannot remember is much better than a map that will not load.
DRV1650-SR-LEGEND-FILLS-SEARCH-GAP. On Simple Research's Regions and Languages tabs the
search bar is gone but the legend does not rise into the space. The other tabs are fine.
The two-tab split was a predicate, not a coincidence. With no geocoder the legend rises
to 14px UNLESS showsFlyTools() is true, and that returns true for exactly 'doxa-regions',
'language-family' and 'language-families'. It stops at 56px to avoid a MEASURED collision
on country-posters, where LegendTools renders at top:14px — the legend's own slot.
★ BUT showsFlyTools() DESCRIBES THE LEGEND TYPE, NOT WHETHER THE TOOLBAR EXISTS. Fly and
Clusters are leaf-only: the composite — which is what a reader calls "Simple Research" —
passes legendTools:false, so the toolbar is never drawn there and the 56px reserved a
slot for nothing. rega measured it at 1440x900 on main e1d0bc20: the band from the map's
top edge (y 44) to the legend card (y 100) is 56px of EMPTY map on both tabs, with no
LegendTools anywhere in the strip.
So the offset now asks whether the toolbar is RENDERED, not whether the type allows one.
Keying it on the tab name would have closed the Driver's gap and reintroduced the
country-posters collision the moment the tools were shown — the guard is kept, it is
simply asked the right question.
showLegendTools moved ABOVE legendTopVar, which reads it. A computed body is lazy so a
later const would have resolved at runtime, but this file has a TDZ history and
check:setup-order exists because of it; position is cheaper to keep honest than to reason
about.
check-legend-top-offset asserts BOTH inputs are consulted — drop either and it fails —
and that the declaration still precedes its reader. Negative-tested.
This is reading (b) of the two I put to dx-shepherd, chosen because rega measured it
rather than because it was likelier. Reading (a) would have been a cosmetic change to a
toolbar that turned out not to be there.
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.
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 button shows only on a discovered tab with an entry in experimentalHelp.js. It opens a modal
dialog titled with the tab name: focus moves to Close, Tab stays inside, Escape or Close returns
focus to the button, and switching tab closes it. close is a new shell string in all 11 locales.
Follow-up on DRV1650-SR-POSTER-EXPORT: the print came out vertical, where a horizontal map fits the shape better.
The export asked for 8.5x11 portrait unconditionally, so a landscape map was letterboxed
onto a tall sheet -- white above and below, the map small in the middle. That was the
other half of the wrong vertical aspect; the first half was the tile slicing, fixed in
859bb268.
Orientation now follows the map: landscape when the map's own container is wider than it
is tall, portrait otherwise. The PAPER is unchanged -- still 8.5x11 stock, only turned --
so the sheet stays a size printers have rather than becoming a custom one.
Measured from map.getContainer() rather than from the window, because the map may be one
pane of a composite and it is the MAP's shape being exported, not the browser's.
The element-bootstrap migration called defineMapElement in eleven entry files that never
imported it. A build does not fail on an unbound name; the element does, the moment it loads,
with 'defineMapElement is not defined'. The landing precondition caught it by importing each
profile. check:element-bootstrap now fails when an entry calls the helper without importing it.
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.
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.
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.
Dictation was "doing better before" sentence detection was switched on for the robot voice: with
asr.endpointing.enable the server ends an utterance at every ~0.8 s pause and the next one starts fresh.
- Default (robot voice off): the speech server runs exactly as it shipped — plain streaming, no pause cutting.
- Robot voice on (High performance mode): the server is restarted with sentence detection, which speaking
sentence by sentence needs; turning the robot off restarts it without. A switch made mid-recording waits
until Stop, so a recording is never cut. A planned restart is not reported as a crash.
- Robot voice pipelined: the next sentence is synthesised while the current one plays, so the ~1 s gap
between spoken sentences is gone (same work, overlapped).
- Verified against a stand-in speech server (go -race): startup without the flag; robot on → restart with
it; robot off while recording → deferred; after Stop → restart without; 0 data races.
The legend was stacked below the chart in a centred column. On its own it is
taller than the pane — a 360px card with a nine-cell grid and four footer lines —
so the figure was pushed out of view entirely and the panel overflowed its host:
the legend covered the row beneath it and the caption drew over the page tabs.
The chart was not visible at all at 1440x900.
It is now a row: figure left, legend right, which is also the side placement the
legend was asked for. Every flex child takes min-height and min-width 0, because
a flex item's default minimum is its content size — without that an item never
shrinks below its natural height and the container overflows however little room
it has. That default is what turned a tall card into an unbounded one. Under
900px it returns to a column and scrolls rather than clipping.
Both charts now clear the centre tab strip, which is absolutely positioned near
the top and was covering the top of the bounded set's dots and all of the
centered set's caption. The bounded set also takes overflow:hidden, so a tall
caption can never do to it what the legend did to its neighbour.
Nothing about the counts changes. The page reads 2,082 placed where a slug-only
join predicts 2,074: the difference is the name-and-country fallback recovering
eight of the sixteen groups the neighbour table carries no slug for, and both
readings still sum to the full 2,107.