Follow-up to the doc landing in 49, which was written before the guard existed.
Adds the guard: every browser gate reaches Playwright through
internal/lib/playwright-guarded.mjs and refuses unless browser-slot.sh exported
DOXA_BROWSER_SLOT; an unslotted run exits 75 with the command to use, quoted
verbatim. And says the slot is a COUNTING SEMAPHORE, not a mutex, with the reason
from its own header -- a single lock queued 10-19 deep for hours because the real
constraint is memory, ~300-500MB per chrome, not exclusivity.
Replaces the grep advice with `bun run browser-gates`. The grep still works and
is fine for a quick look, but it is a heuristic over file text: my own count from
grepping internal/check-*.mjs said 87 browser gates; the repo's own detector says
97, because browser gates also live in plugin-designs/*/scripts/. Corrected. The
authoritative list cannot drift from what is enforced, because
check:browser-gates-guarded uses the same detector.
One line the guard's header earns and this doc had not stated: being able to
launch is not the same as it being yours to launch. The guard stops the accident;
it does not hand a newcomer the seat. Testing whether the guard refuses is itself
a browser launch.
Does NOT claim path-into-node_modules detection: studio has that in landing 50,
and it is not in this tree, so the doc does not describe it yet.
check-experimental-shelf imported resolve(ROOT, 'node_modules/playwright/index.mjs')
dynamically, named no package specifier, and so launched Chromium outside the
slot while check:browser-gates-guarded passed it (upg100 found and measured it).
It now imports the guard, and an unslotted run exits 75. The check also fails a
path to node_modules/playwright (or playwright-core) anywhere in code; with the
old import restored it fails that file.
Landing 48's browser guard made check-packet-render refuse to launch outside the
browser slot and exit 75. This gate read `ok = res.status === 0` -- two outcomes,
not three -- so it reported "5 packet(s) FAILED, 0 passed" about five packets it
had never opened, naming five innocent files. Someone would have gone looking for
a rendering bug that does not exist.
run-parallel.mjs:262 states the contract: 75 (EX_TEMPFAIL) means the gate COULD
NOT LOOK, which is neither a pass nor a failure. Unmeasured packets now print ⊘
and are excluded from the pass/fail count, with the count of them in the summary.
AND THE CASE THAT MATTERS MORE: zero failures and zero looks are the same
`failed.length === 0`. Without a guard, a run that could not open a single packet
prints "PASS -- every generated packet renders" -- the most flattering possible
lie about a measurement nobody took, and the same shape as the stale-artifact bug
this file was fixed for earlier today. When nothing was measured it now exits 75
itself, naming the command that runs it in the slot, so the runner buckets the
whole gate NOT MEASURED rather than green.
Verified: outside the slot the gate now exits 75 and says so, instead of exiting
1 with five false failures.
Seven conversions, one deliberate keep, and a stale comment.
CONVERTED -- each is text that should follow the reading direction:
LegendDesktop.vue:138,139 the count and population chips, text-align:right ->
end. They are numeric badges at the trailing edge of a row; in RTL the
trailing edge is the left.
LegendDesktop.vue:154,157 the stats and footer VALUE columns, right -> end.
Same argument, and they already used padding-inline-end, so the rule was
half-logical and read physical.
SemanticTreeLegend.vue:711,1105 two button labels, text-align:left -> start.
Label text in a flex row; it follows the reader.
SemanticTreeLegend.vue:764 .stl-tab-info border-left -> border-inline-start.
This is a DIVIDER between two adjacent chips. In RTL the info button sits on
the other side of its sibling, so the divider has to move with it or it
draws on the outside of the pair.
KEPT PHYSICAL, deliberately:
SemanticTreeLegend.vue:805 .stl-hdr-num::after { left:50%; top:50% } with
transform:translate(-50%,-50%). That is the CENTRING IDIOM for a 24x24 WCAG
2.5.8 hit target, not a direction: it computes identically in both, and
converting it would add review noise that hides the real conversions.
PROVEN, not asserted: measured the GLYPH POSITION in a browser rather than the
computed keyword, because getComputedStyle returns text-align as the keyword
("end"), so a naive comparison reports DIFFER in LTR for rules that lay out
identically. Measuring the span's left edge in a 200px box:
LTR right=201px end=201px IDENTICAL left=8px start=8px IDENTICAL
RTL right=201px end=8px MIRRORS left=8px start=201px MIRRORS
And for the border, where computed style does resolve: LTR 1px/0px both ways
(identical), RTL 1px/0px vs 0px/1px (mirrors).
Also fixes a comment that said "margin-left:auto pins it to the right edge"
above a rule that already read margin-inline-start:auto. Prose naming the
physical side is how a reader talks themselves back into a physical property.
library/ is the build owner's under rule 5B -- source sha to studio.
It counted any non-zero exit as FAIL, so a gate that could not measure (no
browser slot, a server mid-build) read as a broken product, the same bug
upg100 found in check:packets-all once the browser guard made gates refuse
outside the slot. Exit 75 is now NOT MEASURED, shown as such, and a run with
no failures but any unmeasured gate exits 75 itself (INCOMPLETE), never green.
The Driver reported the pins as hard to see. On the chooser this is the state
that answers it: what the page shows on load, before any hover.
Two things make a pin harder to read here than on a cloned country page, and
neither is a matter of taste. The chooser renders the globe, so every pin sits
at the lowest useful zoom instead of a framed close-up; and the 1px white stroke
is a fixed cost, so at radius 3 it consumes a third of the pin's width and the
coloured core carrying the region's identity is only about 2px. Raising the
resting radius from 3 to 4 at zoom 2 grows that core from 2px to 3px -- a ~50%
gain in the part a reader actually distinguishes, for ~33% more footprint.
Opacity goes to 1 for the same reason: 10% transparency over a basemap costs
contrast and buys nothing while nothing is being de-emphasised.
Only the chooser changes. The cloned country and region pages keep DX-life's
exact geometry, and the hover behaviour is untouched -- dimming the other
regions is the design saying "these pins are one group", which is a taste call
rather than a legibility defect.
Whether this is now easy enough to see needs eyes; the argument here is only
that the readable part grows faster than the footprint.
check:composite-freshness found stale on main: all-people-groups
cities-internet
country-posters
dashboard-plus-grid
demo-map
doxa-research-map
doxa-simple-map
gospel-internet
gospel-resources
key-cities
my-upg-100-list
pplr-data-maps
prayer-walk-planner
regions-dashboard
research-dashboard
template-bundle
upg-neighbor
wall-of-people-groups. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
dx-shepherd asked for this with a7b12b44 and the reason is exact: every caller of readSourceFeatures is
written to do NOTHING when it finds no features, deliberately, so that a legend row for an off-screen
region degrades quietly instead of throwing. That is why the MapLibre bug produced no error, no console
line and no visual difference except a camera that did not move — and why it survived until a packet
printed the wrong 50 people.
check:pin-selection drives the reader with fixtures for both engines and no map at all:
· the MAPBOX shape (_data IS the FeatureCollection) reads its features;
· the MAPLIBRE shape (_data is { geojson }) reads the SAME features — the regression that shipped;
· a REMOTE source ({ url }) returns [] AND WARNS;
· the serialize() fallback works, a missing source is [] rather than a throw, a bare Feature is one;
· selectedPinIds ANDs its expressions, and with none returns every pin rather than none.
★ NEGATIVE-TESTED, which is the only thing that makes it worth having: run against the reader as it
shipped, it fails with exactly four findings — the maplibre shape reads 0 instead of 3, selectedPinIds
narrows to nothing, "no expressions" returns nothing, and the remote case returns [] silently. Against
the fixed reader it passes. A test that cannot fail on the code it was written for proves nothing.
★ AND THE REMOTE CASE IS NOW A DECISION, not an accident. dx-shepherd asked what { url } should do. It
returns [] and warns once per source: the features live on a server and cannot be enumerated locally, so
"I cannot read this" is a different fact from "there is nothing here". Collapsing those two is precisely
how the MapLibre bug hid for as long as it did, and no caller can tell them apart from an empty array.
Verified: check:pin-selection, check:mjs-parse, check:doc-scripts, check:package-json-unique-keys all
PASS. gates:pre was NOT run clean here — my main clone sits three landings behind origin/main, so a
newer package.json lists check:targets while that file is absent from the working tree; that failure is
my verification setup, not this change, and I checked it on an untouched tree before reporting.
rega's console answered the question in one line: "0 pin(s) selected but no rows matched", for both a
language-family selection and a prayer selection, with the pins plainly on screen. Not an id mismatch and
not a missing filter — readSourceFeatures returned an empty list.
THE TWO ENGINES STORE _data DIFFERENTLY. maplibre-gl's GeoJSONSource wraps it:
this._data = typeof options.data === 'string' ? { url: options.data } : { geojson: options.data }
so `data.features` is undefined there. Mapbox stores the GeoJSON directly. This code was written against
Mapbox, where it works, and has been returning nothing on MapLibre ever since — which is the keyless
engine, the one the public builds use.
★ THE PACKET IS THE SMALL HALF. This reader is what zoomToLegendRow uses to answer "which pins does this
legend row select", and it backs research-map, country-posters and doxa-simple-map. On MapLibre every one
of them has been failing to fit the camera to a clicked legend row — silently, because the function is
written to no-op rather than throw when it finds no features. The original comment above it explains that
querySourceFeatures is viewport-clipped and would make off-screen rows "silently no-op"; the replacement
did exactly that on one engine, for a different reason.
The wrapper is unwrapped, and a URL source returns [] explicitly — having no local features to read is a
different fact from finding none, and collapsing the two is how this hid.
Verified: gates:pre 13/13. Needs eyes, and the second one matters more than the first:
· the packet on MapLibre prints the SELECTED groups, cover count no longer the whole list;
· clicking a legend row on MapLibre FITS THE CAMERA to that row — on research-map, country-posters and
doxa-simple-map. If that has been dead on the keyless engine, this is the commit that revives it.
regions-plugin challenged the rule and was right. A BUNDLE reads
`stage: production` when ANY ONE of its profiles is production, so scoping the
loop by b.stage drags every experimental sibling in with it.
Measured: ALL SEVEN violations it reported were false. cities-internet is a
production bundle whose connectivity-list and data-tables are EXPERIMENTAL;
gospel-resources likewise for charts / data-tables / resources-list; and
my-upg-100-list's own poster-wall and upg-100-list are experimental too -- only
upg-100-mfe is production, and it has carried a label all along. With p.stage the
gate reports 0.
THE SAME TRAP A THIRD TIME. I moved off stageOf (bundle-keyed) after catching it
once, and moved to b.stage -- the same mistake wearing a different field name.
The profile's own stage is the only one that answers "is this row production".
THE SELF-TEST HAD A HOLE THAT MADE THE BUG INVISIBLE: every fixture bundle had
exactly one profile, so bundle-stage and profile-stage always agreed and scoping
by the wrong one could not be detected. A fixture where the two CANNOT disagree
cannot test which one you read. Adds a mixed bundle whose production profile must
FAIL and whose experimental profile must PASS; 6/6.
The two meta files in be398ce8f stay. They were a fix for a non-problem -- both
profiles are experimental -- but a named row is better than a humanized id, and
they cost nothing.
The rule is extracted as a pure undeclaredLabels(manifest) so it can be tested
over a fixture without standing up Vite or needing a built tree, and runs before
that setup so `--self-test` works on a fresh clone.
Four planted cases, and the SECOND is the one that matters -- a rule that fails
everything is as useless as one that fails nothing:
production + no label must FAIL
experimental + no label must PASS (the stage scope is the design)
production + a real label must PASS
production + whitespace-only must FAIL (`label: " "` satisfies a null
check and still renders blank)
Wired as check:studio-rail:self-test. Verified the extraction changed no
behaviour: the live gate still reports the same 8 violations it did before.
It exists because the first version of this rule PASSED on a manifest that
contained the very defect it was written for -- humanize(id) meant the rendered
label was never empty, so nothing could fail.
The rail showed tiles nobody had named while this gate stayed green. The obvious
fix is a no-op, and writing it first is how that came out.
THE NO-OP: testing the rail's label for null/empty can never fail, because
rowsFromManifest falls back to humanize(id) (plugin-studio.vue:421,429). A
profile that declares nothing still arrives as "Doxa Simple Map" -- not empty,
and not equal to the bare id either, so the existing bare-id rule misses it too.
humanize() defeats BOTH rules. That is why country-posters could ship a tile
nobody had named.
So the property is the DECLARATION, read from the manifest, not the rendered
label: does this profile's <profile>.meta.json carry a "label"? A humanized id is
a reasonable DEFAULT and a bad NAME -- "Doxa Simple Map" tells a reader nothing
the folder did not already say.
PRODUCTION-STAGE ONLY, deliberately: 15 profiles declare no label today and 14
are experimental -- work in progress nobody has named yet, which is not a defect.
A gate that is red for a condition the repo accepts gets ignored.
Also declares the two labels my own plugin was missing: upg-100-list becomes
"UPG-100 — Zoned Map" and poster-wall "UPG-100 — Poster Wall".
⚠ THIS GATE IS RED ON MAIN TODAY, 8 violations across 4 plugins and 4 owners
(cities-internet 2, doxa-simple-map 1, gospel-resources 3, my-upg-100-list 2).
It must NOT land before those labels do, or it blocks every other agent's
landing for a defect that is not theirs. The two meta files here fix my share;
they reach the manifest only through the full build, which is the build owner's.
internal/ is the build owner's under rule 5B -- source sha to studio, sequenced
after the other three owners declare their labels.
238 checks; 148 run anywhere, 87 need a real browser, 3 shell out. A newcomer
cannot tell from the name -- check:packet-render sounds like a file check and
drives a browser; check:composites-mount sounds like it needs one and does not.
The test is one grep for playwright/chromium, and the doc says so.
The part worth writing down is exit 75. run-parallel.mjs:262 defines three
outcomes, not two: 75 (EX_TEMPFAIL) is NOT MEASURED -- the gate could not look --
and a gate that exits 0 while announcing SKIP is also NOT MEASURED. Only another
non-zero is a FAIL. Two traps follow: exit 0 alone does not mean a gate looked at
anything, and your own `timeout` produces 124, which says you stopped it, not
that the code failed.
Also states the rule the split exists for -- one machine, one browser seat, so
one person runs them -- and why it is not just logistics: a source read and a
render are different measurements. Four catalogue gates can pass while a page
still paints English; and of 16 cards parked as "needs a browser", 7 were about
markup, a path, a manifest field or a gate, and settled headlessly.
Every number measured on main d8baaefd2 rather than estimated, including
correcting gates:pre from 13 to 15 after re-running it.
check:composite-freshness found stale on main: none. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
The runner recognises a browser gate from its file, comments included, so the
check's own comment made it one and it went NOT MEASURED on a headless seat.
The comment is reworded; detection is unchanged (stripping comments instead
dropped four real browser gates).
The dev server's first line on a fresh clone was a warning that rudder-engine is
not built, with a go build command and no word that it is optional or needs Go.
A backend can now carry an optional note, printed as information instead; Rudder's
says it is desktop voice-to-text, fine to skip for the maps, needs Go 1.22+, and
points at the desktop HOWTO.
Excluding the two detector files by path also skipped any real import added to
them. The check now matches only a static import statement at the start of a
line or a literal import('playwright') / require('playwright') call, which the
detectors' escaped regex literals never are; only the guard itself is excluded.
Planted a real import in browser-gates.mjs and one in this check: both fail.
internal/lib/browser-gates.mjs holds the regex that looks for a playwright
import, so the check read it as an import once the file was tracked. The guard
and the two detectors are excluded by path, so a real import added to one of
them still fails.
Two defects, both found by the done-card sweep rather than by looking for them.
1. A STALE ARTIFACT WAS GRADED AS A DEFECT, which is this gate's own recorded bug
recurring. BUG-PACKET-SWEEP-GRADED-AN-ACCIDENTAL-SET-OF-GITIGNORED-ARTIFACTS is
marked DONE, but the card was closed without the mechanism changing: the gate
still readdirSync'd every .html with no freshness test. It went red again on 43
packets left from a run three days earlier -- reporting "0 prayer tick boxes"
about files generated before tick boxes existed, so they could not have had
them.
The directory is gitignored (.gitignore:94), so nothing in it reaches main or
another agent; it is one developer's scratch. The gate already knew an EMPTY
directory is not a failure ("a clean checkout has none"); a directory of
LEFTOVERS is the same fact wearing different clothes. A packet older than the
renderer that produces it is now SKIPPED, loudly, with the command to
regenerate it. The renderer's mtime is the reference because that is what
decides the shape a packet should have -- comparing against "today" would
re-fail every morning.
2. THE UNCONDITIONAL --expect-funnel STOPPED BEING TRUE. The comment above it says
every packet comes from packet.mjs "which always passes FUNNEL". That held
until SERIES landed: a country packet DROPS the funnel page deliberately,
because the funnel narrows every people group on earth down to THIS UPG-100
list, which is not a description of a country. So the gate failed a correct
packet.
The expectation now follows the series that produced the packet, identified by
the series' own NETWORKS text surviving into the artifact, and read from
series.json rather than hardcoded -- adding a series with funnel:false must not
require editing this gate. A packet whose series cannot be determined keeps the
strict expectation, because the safe default for "I do not know" is the
stricter check.
Verified: PACKETS-ALL PASS with 43 stale skipped and the country packet accepted;
and a POISON TEST inside the tree (a upg100 packet with its funnel-title removed)
is still caught -- so the relaxation discriminates rather than passing everything.
The first poison attempt ran outside the checkout and failed on a missing strings
catalogue, proving nothing; re-run in place.
internal/ is the build owner's under rule 5B -- source sha to studio, not landed
here.
Only the browser seat runs browsers, and only inside internal/browser-slot.sh.
That rule was remembered, not enforced: an agent ran a gate it took for a
headless source check and it launched Chromium outside the slot.
internal/lib/playwright-guarded.mjs is now the one way a repo file reaches
playwright: its launch(), launchPersistentContext(), launchServer(), connect()
and connectOverCDP() exit 75 (NOT MEASURED) with a message naming
browser-slot.sh unless DOXA_BROWSER_SLOT is set, and browser-slot.sh exports it
for the command it runs, so a bash -c grandchild inherits it. All 72 files that
imported playwright switch to it (internal/lib/browser.mjs and 64 internal gates;
plugin-designs scripts: doxa-research-map 3, my-upg-100-list 2,
research-map-plus-grid 2, research-dashboard 1): a mechanical import swap.
check:browser-gates-guarded (gates:pre) fails a direct import;
bun run browser-gates lists the 97 scripts that launch a browser. isBrowserGate
moves to internal/lib/browser-gates.mjs so the runner and the check share it.
The legend and search column were placed with left:10px and styled only under Mapbox's
.mapboxgl-ctrl-top-left, so on the keyless engine they kept MapLibre's defaults and in
Arabic stayed on the left. Both engines' classes now take inset-inline-start (the same
fix country-posters carries), and the help close button and the detail labels use
logical sides.
charts, data-tables and resources-list had no <profile>.meta.json at all, so the manifest carried
no label for them and the Studio rail humanized the folder name: "Charts", "Data Tables",
"Resources List". Those say what the file is called, not what the page answers.
Each now has a meta with a label and its stage:
charts Gospel Resources -- where the gap is, by language
data-tables Gospel Resources -- every resource and every gap, in tables
resources-list Gospel Resources -- what each people group has
All three declare "stage": "experimental" EXPLICITLY, which is what they already were. A meta-less
profile and a meta without a stage key both default to experimental in profile-meta.mjs, so the
value would otherwise rest on that default -- and these three sit in a bundle whose stage reads
production through gospel-dashboard, so a later change to the default would silently promote them.
Measured after the change: the three stay experimental, gospel-dashboard stays production, and
bundleStage still returns production.
Type is deliberately not declared. generate-tree resolves the composition tier from meta.json
`type`, not from whether the file exists, so omitting it leaves all three inferred `single`
exactly as before. Nothing in the manifest moves except the three labels and three stage values
that already held.
connectivity-list and data-tables had no profile meta at all, so the Studio and
the gallery fell back to a humanised profile id: a reader picking a view saw
"connectivity list" and "data tables" rather than what the pane actually is.
The sibling connectivity-map already carried a label; these two are now in step,
so the three read as one family in the picker instead of sorting apart.
The wording is not invented. Each pane renders its own heading from i18n —
connectivity.list.title is "Cities with Internet" and connectivity.tables.title
is "Cities Data" — so the labels lead with the product name the reader already
sees, then name the view, matching the shape connectivity-map established.
Both metas declare stage experimental explicitly. That is the existing default
for a profile with no meta, so nothing is promoted and the manifest is byte-for
-byte unchanged; it is stated because bundleStage turns a whole bundle
production as soon as one profile claims it, and a stage left implicit is one
default change away from moving on its own.
Source only, no bundle rebuild: the label is read from the meta by the gallery's
discovery, and never travels into manifest.json.
countries-map, country-poster-wall and doxa-simple-map carried label:null, so the Studio
rail and the gallery showed their ids. Each now has a label in its meta; types are
unchanged (doxa-simple-map stays parameterized).
An array is not the same as data-driven: a zoom-only interpolate is an array and
still compiles a variant on the first gesture. The warm-up now leaves alone only values
that read a feature property, and wraps constants and zoom-only expressions.
Choosing "Asia" in the legend printed the same first 50 groups as any other row,
Sudan included. The packet now takes the pins the selection shows, through the shared
library/maps/pin-selection.js (reading pins as drawn, not raw rows). Selected pins
that match no rows fall back to the full list with a warning; an empty selection
reaches the printer's own error; over 50, the cover says '50 of N'.
highlightCountryByIso makes the regions fill and border paints data-driven on a click,
and the first such click paid a one-off shader-variant compile (rega measured 5.5 s /
11.1 s on the research map). The three paints are now data-driven from the moment the
layer is added, with identical branches so nothing changes on screen.
check:composite-freshness found stale on main: dashboard-plus-grid
doxa-maps-spa
doxa-research-map
gospel-resources
plugin-studio
research-map-plus-grid
stack-demo. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
exp-shepherd's copy of this pre-warm is better than mine in one way and accidentally right in another,
and both are worth taking.
BETTER: it leaves an existing expression alone instead of wrapping it. Mine wrapped regions-border's
line-color — which arrives as a region-coloured match on a feature property — into a case around that
match. No visual change, but a gratuitous rewrite of an expression that was already data-driven and had
no variant left to warm.
ACCIDENTALLY RIGHT: their test is "is it an array". For this layer that coincides with the right answer,
because the only array here is that match. It is not the right test in general — a zoom expression like
['interpolate',['linear'],['zoom'],…] is also an array and is NOT data-driven, so skipping it would
leave that property to compile its variant on the first gesture. That is the partial pre-warm this whole
exercise exists to avoid, and it would measure as a failure with no visible cause.
The test is now whether the expression reads the FEATURE — get, has, or feature-state, at any depth.
Constants and zoom-only expressions get wrapped; anything already keyed on a feature is left exactly as
the layer wrote it. The regions layers have no zoom expression today, so this changes nothing that runs
right now; it means the rule is correct for the reason it is correct.
Verified: gates:pre 13/13. Chained on 790d8838.
Found by reading addRegionsLayer while checking exp-shepherd's copy of this fix, not by anything that
failed. My pre-warm called paintRegionScope(map.value, []) — the CLEARED look: flat grey borders at
width 0.6. The layer does not start there. useMapLayers adds regions-border with line-color set to the
REGION-COLOURED match expression and line-width 1, so the moment the Regions tab was first opened, every
border turned grey and thinner than the layer had defined.
Nothing was going to catch it. The shader compile moved as intended, the write count was unaffected, the
highlight still worked, and the checks in flight were a timing measurement and a write count — a change
of appearance is invisible to both. It would have been found by a person looking at the map, eventually,
and blamed on something else.
prewarmRegionPaint reads each property back and re-writes it wrapped in a case whose two branches are
the value it already had. The appearance is preserved exactly as the layer defined it, and the property
still becomes data-driven, which is all the shader variant depends on. A property the layer does not
define is skipped rather than invented, and one already wrapped is left alone so a second call cannot
nest cases.
paintRegionScope keeps its idle values for the CLEAR path, which is a different thing from the initial
state and always was — conflating the two is what caused this.
Verified: gates:pre 13/13. Chained on 196eca13. Needs eyes: rega — on the first visit to the Regions
tab, the country borders should be REGION-COLOURED at their original width, not grey.
Two findings from upg100's census. check-one-palette read strategy palettes from
library/colors/ only, so a bundle's own src/colors/ strategy (which overrides the
library one for that bundle) was never checked; it now reads both, the bundle's
over the library's, and --self-test plants a private palette. Rudder's
engine-offline notice gave a go build line without saying Go 1.22+ is needed or
that setup-runtime.sh downloads the model; it now says both and points at
bun run desktop:build and the desktop HOWTO.
VerticalTabsLayout kept a private script loader without the dev-server ES-module
rule, so a bundle nested through it under bun run dev loaded as a classic script,
reported success, and never defined its tag. It and StackLayout now call
library/utils/embedScript.js loadScriptOnce; StackLayout keeps its own
resolveBundleSrc step first. loadScriptOnce gains an empty-url no-op (a
<script src=""> re-fetched the page) and carries StackLayout's stack-demo
measurement beside the gallery's. One copy of the pattern and one cache.
DEDUP-AUDIT.md (measured today: 253 files, 31 cross-plugin duplicate clusters,
622 duplicated lines) ranks this tenth by raw cost and first by suitability. It
is the only cluster where the copies are identical in code, close over nothing,
and sit in two plugins with one owner between them.
loadScriptOnce (13 lines) and whenTagDefined (11) were byte-identical between
doxa-maps-spa and plugin-studio. deriveTreeBase (9) differed by ONE COMMENT LINE
and not one character of code -- drift in the documentation of a shared function,
which is its own argument for having one copy.
They are one concern, not three: load a bundle's script once, wait for its custom
element to register, and work out the tree base to load it from. That is what a
capability-5 app shell does to nest another plugin, and both plugins here ARE the
capability-5 shells. Any future shell needs the same three.
Verbatim move: no parameterisation, no behaviour change, callers import instead
of declaring. The comments travel with the code because each records a
measurement -- particularly the one explaining why onload is not a usable success
signal for a dev-server ES module.
ONE BEHAVIOUR CHANGE, AND IT IS AN IMPROVEMENT: the _loaded cache is now shared.
Two shells on one page previously kept separate caches and each loaded the same
bundle; the second now reuses the first's promise.
deriveTreeBase reads document.currentScript, so it is only valid AT MODULE
EVALUATION TIME. The call sites did not move -- both entries still call it at
module top level -- and the library file documents the constraint, because a
later call silently returns '' and every nested bundle URL would break.
Verified: both bundles build; each built file contains all three functions
exactly once and still derives currentScript; check:sri, check:series-isolation,
check:studio-rail and check:capability-derived all PASS.
library/utils/embedScript.js is the build owner's under rule 5B. The
plugin-designs and public/ changes are in the same commit because they import it
-- landing them apart breaks the build in between.
check:composite-freshness found stale on main: doxa-research-map. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
Canonicalising the ISO list made the two region painters COMPARABLE. Reading the two sources shows it
cannot make them EQUAL, so the write count would have landed at six rather than three — and the reason
is a real defect underneath, not a tuning problem.
The two sets are different by construction:
· node.isoCodes comes from useWagfRegionsLegendData, which builds its regions by walking the
PEOPLE-GROUP LIST, so it holds only countries that have pins in the current data.
· mapLayers.regionIsoMap comes from regionsData.isoToRegion — every country assigned to the region.
So a region highlight painted from the legend has HOLES wherever a country in that region has no
unreached group in the list, and which of the two a reader saw depended on which writer ran last. The
feature is "highlight the countries belonging to the selected WAGF region", so the regions dataset is
the right answer and the legend's pin-derived list is not.
region: rows therefore stop painting here and defer to the canonical path they already trigger
(selectRegion -> the store watcher -> applyDimFilter). block: and country: rows keep painting from
node.isoCodes, because nothing else paints them — only region: reaches selectRegion — and for those two
the legend's own list IS the scope being asked for.
★ THIS IS A PREDICTION, and it is falsifiable: region clicks should now be 3 writes, not 6. If rega
counts 6, the two sources disagree for some other reason and my reading of them is wrong. If they count
3 but the highlight covers FEWER countries than before, then the pin-derived set was the intended one
and this commit is the thing to revert — the previous behaviour was last-writer-wins, so nobody has
decided which is correct until now.
Verified: gates:pre 13/13. Chained on 1be024ac.
rega's L43 check: three of four pass — the row-click shader compile is 0 ms on both engines (was
3.8 s / 7.5 s), the tab visit is no worse, the highlight stays. The collapse did not: still nine
setPaintProperty calls per row click instead of three.
Their hypothesis is right, and I had the evidence before I wrote the code. The two callers build the
same region from different lists — node.isoCodes and mapLayers.regionIsoMap[slug] — and they do not
agree on ORDER. rega said so in the very first stack trace on this bug: "a case over ['IND',…]" from one
path, "a case over ['MYS',…], a different literal order" from the other. I then wrote a JSON-string
compare, which cannot match two orderings of one set. The pre-warm half worked because that expression
is a shared constant; this half compared text that was never going to be equal.
The list is now canonical before both the compare and the write — de-duplicated, upper-cased, sorted —
so the expression is a function of the SET, which is the thing the two callers actually agree on. Order
is irrelevant to `in` membership, and the case normalisation is a no-op on today's data (both lists come
from the same regions dataset, and the highlight matches now, so they are already upper-case); it is
there so a future source that ships lower-case cannot silently select nothing.
★ IT IS ALSO A TEST, and worth reading as one. If the count drops to 3, the two sources agree about
which countries are in a region. If it does not, they disagree about the CONTENT — a data defect this
compare would otherwise have hidden behind a last-writer-wins repaint, and the next thing to chase.
Verified: gates:pre 13/13. Chained on f7a2f001 (same file). Needs rega: re-count with the same probe.
The claim was wrong when I wrote it and I only checked the built artifact afterwards. jspdf arrives
through useMapPoster.toPdf, which this map still uses for the poster, so the packet path was never the
thing holding it. The bundle in fact GREW about 131 KB (983,264 -> 1,117,687 on L42) when the shared
HTML renderer came in: each bundle is a single .js with no code splitting, so the dynamic imports in
this function are inlined at build time rather than lazy, which also makes the older "keep it off the
critical boot path" note beside them untrue.
Correcting it in the source rather than only in the ledger because the sentence was copied VERBATIM into
country-posters when that map moved to the same renderer — a wrong comment written confidently travels,
and the next person to plan a dependency removal would have started from it.
The change itself stands: the packet prints instead of downloading, and the duplicate implementation is
gone. It cost bytes rather than saving them, and now the file says so.
Chained on 2cf369be because it edits the same function. gates:pre 13/13.
research-map and countries-map moved to the shared printer, leaving the
composable with no importer on main. jsPDF stays: useMapPoster.toPdf,
posterSizes and my-upg-100-list still reach it. The file map row goes and the
library index is regenerated.
A plugin's <profile>.meta.json targets (web, mobile, desktop, widget; absent = web)
now decide where it is built. internal/plugin-targets.mjs is the one reading.
desktop:build builds a desktop plugin into .desktop/<plugin>/ with a page that
names its engine, builds its backend as a Tauri sidecar for this machine, and runs
tauri build with a --config override; tauri.conf.json and public/ are untouched.
The shell starts the bundled rudder-engine (RUDDER_AUDIO=browser, RUDDER_HOME in the
app data folder) and stops it on exit. A plugin whose targets leave out web
(Rudder: desktop + widget) is no longer built for the web tree: run-parallel,
generate-tree and check-composite-freshness skip it, and its public/ folder is
removed; the dev server still gives the Studio its corner. check:targets
(gates:pre, with a self-test of five planted violations) validates the values,
widget corners and backend folders and fails if a non-web plugin reaches the built
tree. Measured: desktop:build rudder-voice on Linux produced Rudder_0.1.0_amd64.deb
(8.8 MB) with the app and the engine side by side; the app has not been launched.
check:composite-freshness found stale on main: country-posters
dashboard-plus-grid
doxa-research-map
doxa-simple-map
research-dashboard. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
PACKETSEL, found by exp-shepherd: the prayer packet printed the first 50 of EVERY people group whatever
was selected, so a reader who had narrowed the map to one language family got a packet about somebody
else. I carried that behaviour through verbatim when I moved the packet to the shared printer — it was
already wrong and I preserved it, which is worth saying plainly.
The rows now come from the pins currently SELECTED: the layer's own filter decides which pins render,
`_pinMatchExpr` decides which of those are lit (the affinity tab dims rather than filters), and both
must match — a pin at opacity 0 is not what the reader is looking at.
★ ASKED OF THE MAP'S FEATURES, NOT OF THE ROWS, and this is the part that had to be got right rather
than guessed. Pin properties are DERIVED from rows: useMapLayers fills languageFamily in when the API
returns null for all 2,069 groups, and canonicalizes doxaRegion onto the palette's key forms precisely
so setFilter matches. So evaluating a pin filter against a raw data row is wrong in a way that NEVER
THROWS — it just selects a different, plausible-looking subset. The features the map holds are the only
place the two vocabularies agree.
Answering that question already existed, privately, inside useMapFly: zoomToLegendRow has to solve it to
fit the camera to a legend row, including the reason it cannot use querySourceFeatures (viewport-clipped,
so an off-screen region silently selects nothing). Those three functions move VERBATIM to
library/maps/pin-selection.js and useMapFly imports them, so there is one evaluator rather than two —
and country-posters, which prints the same kind of packet from the same kind of selection, has the same
helper to reach for instead of a second copy.
Two failure modes are distinguished rather than collapsed: pins selected but NO row matching them is an
id mismatch, so it prints the full list and warns; a genuinely empty selection still reaches the shared
printer's own "that list resolved to no records", which is honest. And when the 50-row safety cap bites,
the cover now says "— 50 of 214" instead of quietly being a different packet than the one asked for.
Verified: gates:pre 13/13, check:composite-interop PASS. Needs eyes: rega — select one language family,
print, and check the cards are that family and the cover carries the count.
country-posters-mfe's @element said country-posters-mfe, a tag nothing defines; the
bundle registers country-posters-map, which the embed snippet and leaf page already
use with profile country-posters-mfe. Anything building the tag from the manifest
(check:composite-embed-knobs) mounted an element that never upgraded, so its map
cells could not be measured.
The map's packet built a jsPDF file and downloaded packet.pdf while its poster already
printed. It now goes through usePosterPrints.printPacket, the same path as Simple
Research (8457b5dc): the live map rides in as an image data URL, and an explicit
adapter maps the rows, reading religionName and countryName so a card never prints a
religion code ("MSN") under a people group's name.
check:composite-freshness found stale on main: doxa-research-map. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.