Repaint the region fill once its boundaries load, and gate every arrival order

HARDENING, NOT THE FIX for the grey Regions tab. That tab has been seen loading
grey on first load and coloured after a reload. This closes one ordering that
can reach a flat-grey fill. On built bundles that ordering did not reproduce the
grey tab (0 of 20 loads grey, the fill present with its expression at addLayer),
so the reported bug stays open; the lead is the dev/HMR re-mount path.

THE ORDERING. regionFillExpression builds a ['match'] from props.points; with
no points it returns a FLAT no-data grey. addLayers SNAPSHOTS that value into
the layer's paint when it creates the layer. applySubject does repaint the fill
correctly, but it guards every write on getLayer() — deliberately — so a change
arriving BEFORE the layer exists is silently dropped and never retried. And on
MapLibre, the DEFAULT engine, the country boundaries are added as an EMPTY
source and populated by a later fetch's setData(), which nothing repainted
after. In that ordering the fill keeps the colour it was born with.

THE CHANGE is one call: repaint after the boundaries setData, which is the first
moment the layer has any features on the MapLibre path and the only repaint
guaranteed to run with both inputs present. applySubject is idempotent and
getLayer-guarded, so it is harmless when the colour was already right — it only
removes the ordering's power to be the last word.

★ TWO THINGS I GOT WRONG FIRST, both caught by the gate rather than by review,
and both recorded because the wrong version was plausible:

1. MY MODEL COULD NOT REPRODUCE THE BUG. The first version's negative control
   fired: no arrival order greyed even with no repaint, so it could not have
   detected what it existed for. The greying order is not "data before the
   layer" — by then the snapshot is already coloured. It is points arriving
   AFTER both addLayers and its mount repaint, with the watch's repaint not
   landing. Corrected, exactly 1 of 3 orders greys without the fix and none
   with it.

2. I BLAMED THE EMPTY SOURCE, WHICH CANNOT CAUSE GREY. An empty source draws
   NOTHING; fill-color is an expression over feature properties. A grey
   landmass therefore means features ARE present and the expression came back
   flat. The repaint after setData is still required, for the narrower reason
   above. The gate carries that correction so the wrong reason cannot be
   inherited from a comment.

THE GATE'S OWN INSTRUMENT WAS WRONG TWICE TOO. A 400-char window between
setData and the repaint failed on the repaint's own comment. Widening it to
1600 then PASSED when I deleted the real repaint and left an unrelated
applySubject() 1200 chars downstream — measured, not feared. A distance is the
wrong instrument, so the .then() block is now extracted by brace matching and
the repaint required INSIDE it. The first structural attempt anchored on the
nearest '=>' and landed inside the callback body, because `for (const f of ...)`
has one; it anchors on the enclosing '=> {' now. Four arms mutation-tested,
including that decoy, and every mutated file restored byte-identical.
This commit is contained in:
Doxa Maps 2026-09-28 23:57:07 +00:00
parent a62920c75e
commit 608e8ec294
3 changed files with 144 additions and 1 deletions

View file

@ -0,0 +1,131 @@
/**
* check-region-fill-repaint.mjs — the region fill must be repainted after its data, in EVERY order.
*
* ★ HARDENING, NOT THE FIX. The Regions tab has been seen loading GREY, first load only, coloured
* after a reload. This gate guards ONE ordering that can reach a flat-grey fill (points arriving
* after the layer and its mount repaint). On built bundles that ordering did NOT reproduce the grey
* tab (0 of 20 loads grey, the fill present with its expression at addLayer), so the reported bug is
* still open; the lead is the dev/HMR re-mount path. The repaint below is idempotent, so guarding
* this ordering costs nothing.
*
* ★ THE MECHANISM, read out of RegionCountryMap.vue. The fill colour is a ['match'] built from
* props.points; with no points regionFillExpression returns a FLAT no-data grey. addLayers()
* SNAPSHOTS that value into paint['fill-color'] when it creates the layer. There are then TWO
* independent async inputs that can arrive afterwards:
* 1. props.points — the prayer rows, fetched by the dashboard.
* 2. on MapLibre (the DEFAULT engine), the country boundaries geojson, added EMPTY and
* populated by a later src.setData(). Nothing repaints the fill after that setData.
* Whichever arrives after the snapshot, the layer keeps the grey it was born with unless something
* repaints it. applySubject() does repaint correctly — but every write is guarded on getLayer(),
* so a change arriving BEFORE the layer exists is silently dropped and never retried.
*
* ★ WHAT THIS ASSERTS, and it is a MODEL not a mock: the four arrival orders, against the real
* repaint rule. It fails if any order can leave the fill flat-grey while points exist. A browser
* cannot be run here (that is rega's seat), so the ordering is modelled and the SOURCE is asserted
* to carry the repaint that makes the model's good cases reachable.
*/
import { readFileSync } from 'node:fs'
const SRC = new URL('../plugin-designs/regions-dashboard/src/RegionCountryMap.vue', import.meta.url).pathname
const src = readFileSync(SRC, 'utf8')
const fail = []
const GREY = '#grey-no-data'
const fillExpression = (points) => {
const byCode = new Map()
for (const p of points) { if (p?.code && p?.region && !byCode.has(p.code)) byCode.set(p.code, p.region) }
return byCode.size ? ['match', ['get', 'iso_3166_1_alpha_3'], ...[...byCode].flat()] : GREY
}
const isGrey = (v) => typeof v === 'string' && v === GREY
/**
* One run of the component's lifecycle under a given arrival order.
* `repaintAfterData` models the FIX: a repaint that runs when late data lands, not only when a
* layer already existed at the moment the data changed.
*/
function run({ pointsArriveLate, watchDropped, repaintAfterData }) {
let points = []
let layer = null
// The real applySubject guards EVERY write on getLayer(): a change arriving before the layer
// exists is silently dropped and never retried.
const applySubject = () => { if (!layer) return; layer['fill-color'] = fillExpression(points) }
const addLayers = () => { layer = { 'fill-color': fillExpression(points) } } // :322, the snapshot
if (!pointsArriveLate) points = [{ code: 'TCD', region: 'africa' }]
// mount: whenStyleReady -> addLayers(); applySubject()
addLayers()
applySubject()
// ★ THE GREYING ORDER: points land AFTER the layer and its mount repaint. The watch on
// props.points is what must carry them. watchDropped models that repaint not happening — a
// deep-watch that does not see a mutated-in-place array, or a fill whose repaint is skipped.
if (pointsArriveLate) {
points = [{ code: 'TCD', region: 'africa' }]
if (!watchDropped) applySubject()
if (repaintAfterData) applySubject() // THE FIX: repaint on data, not only on a live layer
}
return layer['fill-color']
}
const ORDERS = [
{ name: 'points present before the layer is created', opts: { pointsArriveLate: false, watchDropped: false } },
{ name: 'points arrive late, watch repaints', opts: { pointsArriveLate: true, watchDropped: false } },
{ name: 'points arrive late, watch repaint MISSED', opts: { pointsArriveLate: true, watchDropped: true } },
]
// ── 1. WITHOUT the fix, at least one order MUST grey — else this gate proves nothing ───────────
const greyWithout = ORDERS.filter((o) => isGrey(run({ ...o.opts, repaintAfterData: false })))
if (greyWithout.length === 0) {
fail.push('NEGATIVE CONTROL BROKEN: no arrival order greys even without a repaint, so this model cannot detect the bug it exists for')
}
// ── 2. WITH the fix, NO order may grey ────────────────────────────────────────────────────────
for (const o of ORDERS) {
if (isGrey(run({ ...o.opts, repaintAfterData: true }))) fail.push(`${o.name}: still flat grey with the repaint in place`)
}
// ── 3. THE SOURCE MUST CARRY THE REPAINT the model assumes ────────────────────────────────────
// A model that passes while the component lacks the repaint is a model of nothing.
if (!/applySubject\(\)/.test(src)) fail.push('RegionCountryMap.vue: no applySubject() call found at all')
// ★ THE WORDING HERE WAS WRONG ONCE AND THE CLAIM IS NARROWER THAN IT LOOKS. An EMPTY source does
// not draw grey — it draws NOTHING; fill-color is an expression over feature properties. So the
// late boundaries fetch cannot by itself produce the reported grey landmasses: a grey COUNTRY means
// features are present and the expression came back flat. The repaint after setData is still
// required, for a different and real reason: it is the moment the layer first HAS features, and the
// only repaint that is guaranteed to run with both inputs present on the MapLibre path.
// ★ SCOPED TO THE .then() BLOCK, NOT A CHARACTER WINDOW. The first version allowed 400 chars and
// failed on the repaint's own comment; widening it to 1600 then PASSED when I deleted the real
// repaint and left an unrelated applySubject() 1200 chars downstream — mutation-tested, so this is
// measured slackness, not a worry. A distance is the wrong instrument: what matters is that the
// repaint is INSIDE the promise callback that received the data. So the block is extracted by
// brace-matching from the .then((gj) => { that contains setData, and the repaint is required
// within THAT block.
function thenBlockContaining(needle) {
const at = src.indexOf(needle)
if (at === -1) return null
// Anchor on the ENCLOSING callback's opening brace. Walking back to the nearest '=>' lands
// INSIDE the body (`for (const f of ...)` has one), so the brace before it is the wrong one —
// that is what made the first structural version report a missing repaint that was present.
const head = src.lastIndexOf('=> {', at)
const open = head === -1 ? -1 : src.indexOf('{', head)
if (open === -1) return null
let depth = 0
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth++
else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(open, i + 1) }
}
return null
}
const setDataBlock = thenBlockContaining('src.setData(gj)')
const afterSetData = !!setDataBlock && /src\.setData\(gj\)[\s\S]*?applySubject\(\)/.test(setDataBlock)
if (!afterSetData) fail.push('RegionCountryMap.vue: the country-boundaries setData() is not followed by a repaint — on MapLibre (the default engine) that is the first moment the fill layer has features, and nothing re-evaluates the colour after it')
const pointsWatch = /watch\(\(\) => \[[^\]]*props\.points[^\]]*\][\s\S]{0,80}applySubject/.test(src)
if (!pointsWatch) fail.push('RegionCountryMap.vue: props.points is not watched into applySubject')
if (fail.length) {
console.error('REGION-FILL-REPAINT: FAIL')
for (const f of fail) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log(`REGION-FILL-REPAINT: PASS — ${ORDERS.length} arrival orders, none flat-grey with the repaint; ${greyWithout.length} of them DO grey without it (so the check can fail); source carries the repaint after setData and the points watch.`)

View file

@ -231,6 +231,7 @@
"check:public-hygiene": "node internal/public-hygiene-check.mjs",
"check:published": "node internal/check-published-matches-build.mjs",
"check:quoted-scripts": "node internal/check-quoted-scripts.mjs",
"check:region-fill-repaint": "node internal/check-region-fill-repaint.mjs",
"check:rtl-shaping": "node internal/check-gospel-rtl-shaping.mjs",
"check:rtl-shaping:poison": "node internal/check-gospel-rtl-shaping.mjs --poison",
"check:ring-vocabulary": "node internal/check-ring-vocabulary.mjs",
@ -297,7 +298,7 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
@ -485,6 +486,7 @@
"check:provider-switch": "the provider switch must actually SWITCH: counts requests BY HOST (not window globals, which stay defined after a swap) and asserts the maplibre run stays within the leaf HTML's own declared api.mapbox.com preloads. Defaults to the upg-100 leaf; pass a URL or set BASE_URL for another. CONDITIONAL: SKIPs when nothing is served.",
"check:published": "what readers are SERVED must be what we BUILT. Every other gate measures the local tree, so all of them stay green while a stale publish serves something else — and manufactures bug reports that cannot be reproduced. Compares the sha384 per bundle. SKIPs when the published host is unreachable (a network gate that goes red on flaky wifi gets ignored).",
"check:quoted-scripts": "documentation/README-SECTION-3-cli-commands.md QUOTES the scripts block verbatim; it had silently drifted TWICE (a stale bunx cap:sync, and build:unlocked missing check:public-hygiene + check:label-joins). package.json is what RUNS — update the DOC.",
"check:region-fill-repaint": "the region fill must be repainted after its data arrives, in EVERY arrival order (hardening: this ordering is NOT the cause of the reported grey Regions tab, which stays open). fill-color is SNAPSHOTTED into the layer when addLayers creates it, from a match-expression built out of props.points — with no points that expression is a flat no-data grey, and applySubject guards every write on getLayer() so a change arriving before the layer exists is dropped and never retried. Models the arrival orders (asserting at least one DOES grey without the repaint, else the model proves nothing) and asserts the source carries the repaint INSIDE the .then() block that received the boundaries, brace-matched rather than within a character distance — a 1600-char window passed on an unrelated applySubject 1200 chars downstream.",
"check:religion-label-not-value": "the library's two RELIGION_FAMILIES sites (useLegendData, useSearch) show religionFamilyLabel() to the reader, while the value a filter or search matches on stays the canonical English family name: a translated label must never become a wire value.",
"check:ring-vocabulary": "the ring names the printed packet uses (packet-strings.json) agree with the ones the screen uses (gospelInternet.json) in every locale, so a packet and the map cannot name the same ring two ways. By the UPG-100 owner. Headless; in gates:pre.",
"check:scrim-viewport": "a full-screen SCRIM must cover the VIEWPORT, not just the content pane. A position:fixed;inset:0 scrim is PANE-relative wherever an ancestor sets contain/transform — VerticalTabsLayout sets contain:layout on .vtl-content — so a scrim dimmed 1024x793 @ (256,107) at a 1280x900 viewport and left the 256px rail CLICKABLE behind a modal. A source grep cannot see it: the declaration is correct, the ANCESTOR is the variable. Needs `bun run dev`; browser gate, not in build:unlocked.",

View file

@ -307,6 +307,16 @@ function addLayers() {
}
const src = map.value && map.value.getSource(SRC_COUNTRIES)
if (src) src.setData(gj)
// ★ REPAINT ONCE THE LANDMASSES EXIST (hardening; check:region-fill-repaint). fill-color
// is SNAPSHOTTED into the
// layer's paint when addLayers creates it (:322) from an expression built out of
// props.points — and with no points yet that expression is a FLAT no-data grey. On
// MapLibre, the default engine, this fetch is the first moment the layer has any
// features at all, and nothing re-evaluated the colour after it: whichever of the two
// async inputs landed last, the fill kept the colour it was born with. applySubject is
// idempotent and guards on getLayer, so this is harmless when the colour was already
// right — it only removes the ordering's power to be the last word.
applySubject()
})
.catch((e) => console.warn('[rcm] country boundaries failed to load', e))
} else {