research-dashboard and dashboard-plus-grid imported '../doxa-research-map/src/colors/…' — another plugin's
private code — which is how two copies of the same palette drifted (C1a moves them to library/colors).
The gate refuses any relative import that resolves into plugin-designs/<other>/ below that bundle's root;
a composite mounting another bundle's PUBLIC profile .vue (the capability-4 pattern) stays allowed. It
FAILs on main (the 4 palette imports C1a removes) and PASSes with C1a; --self-test in gates:post.
First palette migration from SPEC-LIBRARY-CURATION.md. Source only; studio rebuilds
(rule 5B — library/ plus four other owners' plugins).
WHY THIS ONE FIRST: the duplication was not idle. doxa-research-map's
affinity-block.js carried A018 'Tibetan Peoples' and A019 'Himalayan Peoples' —
live on the pray-tools API — with colours measured to WCAG 1.4.11's 3.0:1 non-text
floor (A013 5.28 unchanged, A018 7.69, A019 3.53; sibling separations 2.61/1.46/1.79).
country-posters' copy covered only A001–A017, so 134 rows (6.4% of 2,107) fell to the
grey fallback #95a5a6 — the "bunch of grey colours" report. exp-shepherd confirmed
that code path headlessly. So the correct copy wins with no judgement call, and the
move FIXES country-posters rather than changing it.
FOUR CONSUMERS, NOT THE TWO THE SPEC COUNTED, and finding that is the sharpest
argument for the migration: research-dashboard/index.js and dashboard-plus-grid/index.js
each imported `../doxa-research-map/src/colors/affinity-block.js` — a cross-plugin
RELATIVE import into another plugin's PRIVATE folder. Both now import @map/colors/.
Only the build found this; no gate names cross-plugin reach.
THE GLOB STAYS in both plugins' index.js, deliberately, and is documented there:
resource.js is still plugin-local pending C1b, so deleting the glob would unregister
it and colour those pins by the soft fallback — the same silent wrong-colour class
being fixed here. Deleting the two STALE FILES is what stops the stale copy winning.
THE REJECTED-CANDIDATE NOTE MOVED ABOVE `export const PALETTE`, and this is a real
constraint rather than tidying: check:one-palette slices from that export to END OF
FILE and matches every hex, despite its own comment claiming it reads "only the
PALETTE export". #AF7AC5 was rejected for A019 at 2.95 — under the accessibility
floor — and mentioned below the export it would have been DEMANDED of every consumer
bundle. The note keeps the reasoning without creating the obligation; the gate bug
itself is reported separately since internal/ is not mine to edit.
The legend needed no change and that is by design: useAffinityBlockLegendData resolves
via getColorStrategy(COLOR_MODES.AFFINITY_BLOCK) — the same strategy the pins use — and
reads labels from the data, so map and legend gain A018/A019 together. Its comments
claiming the strategy lives in a plugin folder were corrected; they now describe where
it actually is.
GATES: check:one-palette PASS — 8 strategies, 16 bundle/palette pairs (baselined at 6
and 12 before the move, so a red would have been mine). check:library-index PASS after
regenerating the derived menu. check:no-free-identifiers PASS. All four consumer
bundles build; their artifacts reverted for studio.
MapLibre's and Mapbox's own control labels stayed English under ar/es (rega's row 30). Both engines take
a `locale` map of their UI strings at construction; useMapInstance now passes one built from the new
mapui catalogue (17 strings x 11 locales, parity PASS) for the reader's language. The keys are the
engines' own (MapLibre 5.24's list, plus Mapbox's gesture-hint names); an engine ignores the rest.
Built doxa-simple-map carries the translations; check:i18n-treeshake PASS.
SPEC-LIBRARY-CURATION.md's first migration, in the order the shepherd approved.
Source only; studio rebuilds (rule 5B — library/ plus eight other owners' plugins).
THE ASYMMETRY, which is why 12 sites each solved it or failed at it separately:
Mapbox getProjection() -> { name } setProjection('mercator')
MapLibre getProjection() -> { type } | undefined setProjection({ type: 'mercator' })
Passing the Mapbox shape to MapLibre THROWS. Measured on regions-dashboard, where
the throw aborted the camera fly so a region dashboard never framed its region.
WHAT THE 12 SITES WERE DOING:
· NINE passed a bare string, so on MapLibre the projection silently never changed.
Eight swallowed the throw in a try/catch; doxa-simple-map.vue:1244 did NOT, so
there it threw uncaught.
· TWO had solved it independently and differently: pplr-data-maps'
setProjectionFor() (instance-tested — the correct one, and the shape adopted)
and regions-dashboard's local pair (config-derived, which is weaker: it trusts
what the page was configured with rather than what the map IS).
· ONE probed by exception, trying the object form and falling back on throw.
The helper reads the engine from the INSTANCE, never from config, because a page can
hold a map built by the other engine. Both functions return something observable —
setProjectionOn returns whether it applied, projectionOf returns null rather than
throwing — on upg100-plugin's argument that a precondition which bails without
recording it is indistinguishable from the request never arriving.
pplrData.setProjectionFor stays as a delegating named export so its three views need
no change.
GATE: _chk-c2-projection-helper-equivalence.mjs, 20 assertions. It runs all THREE old
patterns and the helper against the same stub maps and compares outcomes, rather than
testing the helper alone. Controls prove pattern A really does fail on MapLibre and
leave the projection untouched, and that the bare string really throws — so the
"equivalence" claim has something to be equivalent to. A NEG control catches a helper
that reports success it did not achieve.
★ I DESTROYED 984 LINES OF ANOTHER TEAM'S CODE GETTING HERE, and only the build caught
it. My import-inserting script did `s[:at] + IMPORT` and never appended `s[at:]`, so
every file where the import was ADDED rather than extended was truncated at its last
import line: MapCell.vue -458, FlowerClusters.vue -334, lang-cluster.vue -192.
check:no-free-identifiers PASSED on all three, because a truncated script block is
still valid script. Restored from HEAD and redone with an assertion that a migration
must change the line count by exactly +1 and never remove a line — which is the check
that would have caught it in the first second.
Verified after: all 9 affected bundles build, both gates green, and a per-file line
delta on all 13 changed files with the only negative being the 2 lines I deliberately
deleted from RegionCountryMap when its local pair became an import.
library/lists/lists.json carried one English label per list, so every surface
that showed a list's name printed English in every locale (reported by upg100,
which worked around it inside the packet's own strings).
- A `lists` catalogue area, library/i18n/locales/<lc>/lists.json: a label and a
definition for each of the five lists, in all 11 locales.
- listLabel(id, t) / listDefinition(id, t) in peopleGroupList.js resolve
lists.<id>.label through the caller's translator (useI18n's t, tStatic, or any
key -> string), accept ids and aliases ('700'), and fall back to the
registry's English. The module still imports no i18n runtime, so it stays pure
and testable in Node.
- The registry's labels are now documented as the English fallback only, and the
doxa list gains the definition its catalogue entry has.
- check:list-prop-unit (24 cases, was 17): the Arabic and German catalogues come
through, a missing translator or a raw key falls back to English, the English
catalogue must equal the registry word for word (a drifted registry label fails
it, naming the list), and every locale must carry both fields for every list.
- documentation/reference-universal-props.md: read names through listLabel.
Headless: list-prop-unit, i18n-parity, i18n-never-translated,
no-free-identifiers and universal-props pass. No consumer calls listLabel yet
(the packet has its own strings), so nothing renders differently until one does.
check:composite-freshness found stale on main: cities-internet
country-posters
key-cities
my-upg-100-list
regions-dashboard. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
build -> tree -> fix:sri, run outside the lock as the shepherd asked, so the
landing itself is only a merge, a scan and a push. No conflict markers in the
output (checked).
ITS ONLY WITNESS WAS MY OWN HARNESS. The browser path honours the universal
`list` prop — upg-100-list.vue calls applyPeopleGroupList() at every fetch site —
but library/scripts/posters/packet.mjs had NO list support at all. So the
non-default-list branch of funnelPage, including the six-row shape where a list's
own total is inserted before the reader's row, could not be produced by the only
pipeline rega can drive. I had unit-tested it through a script I wrote about it,
which is exactly the state I have been objecting to in other people's gates.
rega offered to shoot a ?list= packet and there was no command to give them.
PACKET_LIST=<id> closes that. Rendered for the first time, and it is right:
rows 12,379 -> 7,341 -> 4,754 -> 2,107 -> 1,745 -> 37
bars 1080 / 640 / 415 / 184 / 152 / 26 (six rows, six bars)
label "Project 4700: frontier people groups" between the network's list and
the reader's 37
share "This packet is 2.1% of the 1,745" (no false "1 of 27 lists")
foot provenance only, no coverage claim
row gap auto-adjusted 218 -> 147px for the extra row; last row stays 14px so
the share still reads as its conclusion.
IT READS THE MEMBERSHIP FILE FROM DISK, not through loadListMembership(), which
fetches from the asset pool over HTTP — there is no server in a Node build. Same
file, same slugs, same counts.doxa.
AND IT FAILS CLOSED: an unknown or empty list exits non-zero rather than quietly
producing a packet of every group. peopleGroupList.js makes the same choice for
the same reason ("a map that silently showed all 2,107 while an embed asked for
Project 700 would be showing the wrong list with no sign of it"), and a packet is
worse than a map — it is printed and handed to someone.
⚠ buildFunnel() IS NOW A FUNCTION, NOT A CONSTANT, because it depends on a list
resolved after the records load. It mirrors upg-100-list.vue's packetFunnel()
exactly: the default list is the only one the zone catalogue describes, so it is
the only one that may claim "1 of K lists" and an unassigned count.
── AND A CORRECTION TO MY OWN VERIFICATION ─────────────────────────────────
"Numerals untouched" is about DIGITS, not GROUPING, and I verified the digits and
assumed the rest. rega measured the rendered hi sheet: Indian grouping,
"8,28,85,63,969 लोग" (lakh/crore 2-2-3) rather than Western 3-3-3. That is
CORRECT hi-IN formatting, so changing it would be worse — the real risk was never
the grouping but INCONSISTENCY, the same number printed two ways in one document.
Measured and it is consistent: num() is the only formatter in the renderer, so the
funnel's populations and the card pages' populations group identically — confirmed
against the rendered hi packet (card populations 1,70,000 / 3,37,000 / 8,10,000,
Indian-grouped like the funnel's). Only values at or above 100,000 can distinguish
the systems at all; my first check classified "1,400" and read a mismatch that did
not exist.
Verified: default path byte-identical (43 groups, 7 pages, 5 rows, 5 bars); all
four fixtures PASS with rows==bars; PACKET_LIST=frontier gives 37 of 43 records
and 6 pages; check:packet-strings-parity PASS; vite build clean, bundle unchanged
(the change is Node-side).
A LIVE WRONG NUMBER, not a future risk. The universal `list` prop is already on
main (e3d25ff6) and this profile already honours it — applyPeopleGroupList()
filters the rows at all four fetch sites, packetRecords included — and the
membership files are on disk. So under ?list=frontier-500k the packet's rows were
the 71 network groups that are Project 700, while the funnel took its denominator
from the last step of vision-funnel.json and printed "of the 2,107". Off by a
factor of 30, with nothing on the page looking broken.
Measured from data/people-groups/lists/*.json (counts.doxa, what a Tier A map
shows): frontier-500k 71 · frontier 1,745 · unengaged 1,929 · unreached 2,082 ·
the default 2,107.
THREE NUMBERS WERE WRONG, NOT ONE, because all three descended from one total:
the share's denominator; the footnote's "N are in no list" (total minus assigned);
and "1 of 27 lists", which is a property of the UPG-100 ZONE CATALOGUE, not of the
active list — the zones partition the default list and nothing else.
A non-default list now gets its own step, labelled with the list's own label, so
the narrowing does not jump from one list's total to another list's packet.
AND EACH CLAUSE IS SUPPRESSED SEPARATELY, because they are provable separately.
A first pass dropped the whole share line whenever listCount was absent, throwing
away a true statement ("5.4% of the 1,745") to avoid an unprovable one. The
footnote splits the same way: provenance holds for every list, coverage does not.
⚠ The footnote is NOT split by parsing the translated sentence — hi ends in "।"
and zh in "。", and reading translated prose for structure is the trap
check-packet-render's own comment warns about. It is a second key.
⚠ And `funnel.unassigned ?? 0` would have printed "0 of the 1,745 are not yet in
any list" — a confident falsehood whose zero reads as a completeness claim.
── AND THREE FINDINGS FROM rega'S RENDER, none of which I could see ──────────
"0 people" PRINTED ON THE READER'S ROW OF EVERY PACKET. Number(null) is 0 (only
Number(undefined) is NaN), so `Number.isFinite(Number(x))` admitted the
deliberate `population: null` marker as the number zero. I asked "is this a
finite number?" when the question was "did anyone give me a population?" — the
same presence-vs-value confusion as inferring "did it work" from a field that
answers "did it isolate". Zero is suppressed too: a people group with no people
is missing data wearing a number's clothes.
THE SHARE LINE READ AS PART OF THE FOOTNOTE. Measured: 218px below the reader's
row, 42px above the footnote, because the last row kept the full inter-row gap.
Proximity is the only thing grouping them, so an even gap grouped the share with
the wrong element. The last row's margin is now 14px: share 14px under its row,
288px clear of the footnote.
THE GATE ASSERTED PAGE 2 AND PRINTED NOTHING ABOUT IT. rega: "a reader cannot see
WHAT it asserted." A silent pass is the mirror of a printed-but-unasserted number
— the check is real and the evidence invisible. It now reports rows, bars and the
share line, and gained two assertions: every row must have a BAR (the reader's
step had none, so the funnel never visibly narrowed to "yours"), and no row may
print a zero population.
Verified: all five list cases render their own true numbers — doxa keeps the full
claim, unreached/frontier/unengaged get their own step and a percentage with no
false "1 of K", frontier-500k reads "100% of the 71" with the duplicate row
suppressed (a step that repeats its neighbour is not a step). The four fixtures
are byte-for-byte unchanged on the default path, so the list-awareness is purely
additive. check:packet-strings-parity PASS with the three new keys. Both new gate
assertions mutation-tested in en and ar: removing a bar is DETECTED, injecting a
zero population is DETECTED. 5 rows = 5 bars, 0 zero-population rows, "0 people"
absent. Six-row layout recomputed: fits with 318px headroom, 206px if every label
wraps. BUNDLE=my-upg-100-list vite build clean.
NOT re-rendered in a browser: rega measured the previous state and these are
responses to it, so the new geometry wants one more look.
TWO CHANGES, both from other agents catching things I could not see from here.
1. NAMESPACED, on upg100-plugin's point that the host element is SHARED SURFACE.
data-select-zone-state / -applied become data-doxa-select-zone-state / -applied.
A partner page embedding this element could reasonably own a generic name like
the first pair, and a diagnostic has no business squatting one on someone else's
element. Renamed AFTER rega's re-shoot rather than before: their probe polls
these names, so renaming mid-flight would have burned a measurement to save a
few minutes.
2. THE RETAIN CHECK WAS WRONG IN BATCH MODE, caught prospectively by upg100-plugin
before it bit anyone. applySelectZone inferred failure from !selectedKey.value,
but onLegendSelect feeds the selection UNION in batch mode and returns without
ever setting selectedKey — isolating is not what it should do there. So correct
behaviour read as a failure, and retaining on it would have re-applied the zone
and toggled it back OUT of the union. Un-isolating (node == null) has the same
shape: the un-isolate IS the action, and selectedKey is null afterwards by design.
They have given onLegendSelect a boolean return for exactly this (fb076c8b on
packet/vertical), where false means one thing only: the map was not ready.
FORWARD-COMPATIBLE, deliberately. That change is not on main yet, and a function
with no explicit return yields undefined — which is FALSY. Depending on it today
would read every success as a failure and retry it, which is precisely the trap
they warned about. So the boolean is authoritative when it IS a boolean, and until
then the old inference stands with batch mode excluded.
Their design note is the part worth keeping: the early return stays and stays
side-effect free, because other code depends on it BAILING; a boolean makes the bail
OBSERVABLE without changing when it happens. That is the smallest change that fixes
the epistemic problem rather than the control flow.
The India layer drew on UPG-100's curated partition (34 of 36 units), so
Mizoram and Nagaland had figures but no geometry. studio staged the complete
ADM1 beside it (data/boundaries/gb-IND-ADM1-full.geojson: gbOpen
IND-ADM1-1811400, DataMeet / ECI, CC BY 2.5 IN, with its .meta.json). The
curated file is untouched.
- connectivity-map.vue fetches boundaries/gb-IND-ADM1-full.geojson.
- The crosswalk maps Mizoram -> "Mizoram" and Nagaland -> "Nāgāland" (the
boundary file's spelling) instead of null. The generator validates against the
full file, and the table's meta names it with its licence.
- The regenerated table has 36 areas drawn and none missing, so the legend's
"no boundary yet" line no longer shows (it renders only when that list is
non-empty).
Headless: build and tree OK; both boundary files are staged; the fill
expression (maplibre-gl-style-spec) colours 36 of 36 states (Mizoram women
67.6, Nagaland women 49.9).
A SILENT `return` ON A PRECONDITION IS INDISTINGUISHABLE AT THE RENDERED LAYER
FROM THE REQUEST NEVER ARRIVING. The "Show on map" interop bug had three layers
stacked under one symptom: an element event with no replay, an id resolved
against legendNodes that had not loaded, and this function no-op'ing on a missing
'tapestry-fill' layer. Each fix looked sufficient because all three produced the
same nothing. regions-plugin fixed the first two by retaining the request and
flagged the third as mine, since it has three call sites — correctly, and this is
it.
IT NOW RETURNS A BOOLEAN. true when the selection was applied, false when it
bailed on the readiness check.
THE EARLY RETURN STAYS, AND STAYS FREE OF SIDE EFFECTS. It is a documented
readiness gate — the comment at :630 calls it "THE REAL READINESS SIGNAL" — and
other code relies on it bailing rather than throwing or queueing. A boolean is
the smallest change that makes the bail OBSERVABLE without altering when it
happens.
TWO EXITS RETURN true THAT MIGHT LOOK LIKE FAILURES, and the distinction is
load-bearing rather than cosmetic:
- batch mode fed the selection UNION instead of isolating. That is the correct
behaviour there, not a failure to isolate. A caller retrying because it read
`false` would toggle the zone back OUT of the union.
- the un-isolate (node == null) IS the action, and it succeeded.
And the success path returns true EXPLICITLY: an implicit fall-through yields
undefined, which is falsy, so a caller checking the result would read success as
failure and retry — the opposite of what this value is for.
No caller reads the value yet, so the change is purely additive and cannot
regress: verified all four call sites (:945 un-isolate, :1539 paintAll's
re-apply, :1638 and :1641 in the select-zone path) ignore it today. regions'
applySelectZone is where it becomes useful — it already retains on no-effect by
checking whether selectedKey held, and can now ask the applier directly instead
of inferring.
BUNDLE=my-upg-100-list vite build clean.
THE HALF I OWED. check-packet-render gained --expect-funnel last commit so a
packet with no funnel page can fail — but nothing passed the flag, and I said so
to regions-plugin while advising them not to reuse the pattern as though
declarable meant covered: "I have traded an invisible defect for a visible
obligation, which is better but not the same as fixed." This is the obligation.
check-packets-all now passes --expect-funnel for every packet it sweeps.
WHY IT CAN BE UNCONDITIONAL. Every packet in out/ is produced by
library/scripts/posters/packet.mjs, which always passes FUNNEL to buildPacket. So
a packet there without page 2 was either generated before the funnel existed or
by a caller that dropped the argument, and this sweep should say both out loud.
Verified across all 16 packets in out/ — the 4 fixtures, the Arabic tibet, and
all 11 laos locales, which I regenerated for this: 95 groups, 9 sheets each (was
8), 95 photos, page 2 present in every one. So the requirement matches a property
the pipeline actually holds rather than one it aspires to.
THE ESCAPE HATCH IS DELIBERATELY AWKWARD: PACKETS_ALL_NO_FUNNEL=1, which prints a
warning. A legitimate funnel-less host is a real state and deserves to be
recordable, but it must be a decision someone takes rather than a default that
quietly tolerates a missing page.
Verified: node --check clean; the arg construction exercised both ways (flag
present by default, absent under the env var). All 16 packets satisfy the sheet
arithmetic with 0 failures. Hindi laos re-checked as the longest-label locale —
5 rows, 5 bars, 0 Devanagari digits, longest label 51 chars so the wrap slack is
not even consumed.
NOT re-run end to end: check-packets-all spawns check-packet-render per packet,
which launches Chromium, and the Playwright policy reserves browsers to rega.
That is also how I learned this wrapper starts browsers — I ran it earlier
without realising and reported it.
TWO FIXES FROM A BROWSER RUN I COULD NOT DO MYSELF. rega measured page 2 in
print media and found the opposite of the defect I feared: no overflow, but the
four global steps ended at y~330 of 1056 while "43 · are in THIS packet" sat
alone at y~965, leaving the middle ~60% of the sheet empty so the reader's own
step read as a detached footer. And it had NO BAR, so the funnel never visibly
narrowed to "yours" — a funnel whose final term is not drawn as part of the
funnel fails at the one thing it is for.
So the five steps are now ONE list with the reader's included, and it has a bar
like the others: 1080 -> 640 -> 415 -> 184 -> 26px, the last two tinted so the
network's list and the reader's list read as one thought. The 26px floor is what
keeps the smallest list a visible sliver rather than a hairline.
THE ROW RHYTHM IS DERIVED FROM MEASURED ROW HEIGHTS, not guessed. A first
attempt subtracted a magic 118px as "about one row" and left 418px of dead space
at the foot — rega's emptiness, just redistributed. Summing the real heights
closes it to ~298px.
AND IT KEEPS DELIBERATE SLACK, because two runtime facts are unknowable at
layout time: whether a step carries a population line, and how many LINES its
translated label wraps to. Arabic and Hindi labels are far longer than the
English, so LINE_SLACK reserves one extra wrapped line per row. Computed: 298px
headroom normally, 196px even if EVERY label wraps an extra line. Tight-packing
this page would trade "60% empty" for an overflow in the locales I cannot eyeball.
── AND A GATE HOLE A NEAR-MISS EXPOSED ──────────────────────────────────────
regions-plugin reported, unprompted, that a merge resolved with
`git checkout --ours` silently deleted the printPacket call site supplying the
funnel data — 60 lines — AND EVERY GATE STAYED GREEN. They filed it as merge
discipline. It is also gate design, and that half is mine: the funnel page is
OPTIONAL by design (buildPacket omits it when the arg is null), so a packet
without page 2 is not malformed, just smaller. My own choice made the deletion
invisible.
--expect-funnel closes it. The wrong fix is to always require the page: the 11
pre-funnel laos packets and any host with no funnel data would go red, and a
false red costs as much as a false green. Instead the CALLER, which knows whether
this packet was built with funnel data, declares it. An absence is then either
DECLARED (no flag, fine) or a FAILURE (flag passed, page missing) — never a
silent nothing.
Verified: all 16 generated packets satisfy the sheet arithmetic, 0 failures,
including the 11 laos packets that have no funnel page. fixture-tibet renders 5
rows with 5 bars ending in 43. Arabic regenerated: 7 sheets, dir="rtl", 5 rows,
5 bars, ZERO Eastern-Arabic digits on the sheet. Mutation-tested --expect-funnel
three ways: baseline+flag PASS, funnel-sheet-deleted+flag FAILS, deleted without
the flag PASSES. Flag filtered from the positional read, so a bare
--expect-funnel is not eaten as the packet path (that trap has bitten this repo
three times). BUNDLE=my-upg-100-list vite build clean, +141 bytes vs main.
NOT re-verified in a browser: check:packet-render needs Chromium, reserved to
rega. The geometry above is computed, not rendered — the print-media layout is
exactly what rega's eyes caught that my arithmetic did not, so this wants one
more look.
The served gb-IND-ADM1.geojson is the 100-list's curated partition (Mizoram and Nagaland excluded,
listId-tagged), so a map colouring every state had data for 36 and shapes for 34. data/boundaries/
gb-IND-ADM1-full.geojson is all 36 from geoBoundaries gbOpen (boundaryID IND-ADM1-1811400, DataMeet /
Election Commission of India, CC BY 2.5 IN), with source and licence in gb-IND-ADM1-full.meta.json;
generate-tree stages both, and the dev server serves data/ directly. Reproducible with
library/scripts/boundaries/source-full-adm1.mjs (coordinates rounded to 3 decimals: 1.84 MB). The curated
file is untouched.
Country-level internet data is not good enough for India. This adds the first
sub-national layer, from the one open, machine-readable source verified for it:
NFHS-5 (2019-21, IIPS / MoHFW), "women / men aged 15-49 who have ever used the
internet (%)", urban / rural / total, for every state and UT (CSV transcription
of the state fact sheets, CC BY 4.0; SPEC-GRANULAR-INTERNET.md).
- data/internet/: the two NFHS-5 internet rows (with provenance, licence and
upstream commit), plus a strict crosswalk from source names to the shipped
gb-IND-ADM1 shapeNames.
- plugin-designs/cities-internet/scripts/build-adm1-internet.mjs (`bun run
internet:adm1`) writes data/tables/internet-by-adm1.json. It is GENERIC, one
row per iso3 x adm1 x measure x sex x area, with meta naming measures,
sources, boundary files and the national figures, so go-int and other plugins
can reuse it. It fails on an unmatched name or a value outside 0-100: a name
is never guessed.
- The map draws India's states in a blue ramp (0-25-40-55-70%+), leading with
women, with a Women / Men toggle. A tapped state shows both halves, urban /
rural / total. Its own legend block names the measure, survey, years and ages,
and says it counts people, not subscriptions, so it is never read as the
World Bank country bands. India's national band is taken out under its states.
- The shipped boundary file is the UPG-100 partition (34 of 36 units): Mizoram
and Nagaland have data but no geometry. They stay in the table and the legend
names them.
- 8 new strings under connectivity.adm1, in all 11 locales (library change:
consumers need rebuilding).
Headless: build and tree OK, and the table and gb-IND-ADM1 are staged under
assets/. The fill expression validates with maplibre-gl-style-spec, and all 34
shipped states get a colour (e.g. Bihar women 20.6 -> 0-25, Delhi men 85.2 ->
70+). A crosswalk poisoned to a wrong shapeName fails the generator. Passing:
universal-props, no-free-identifiers, i18n-parity, i18n-never-translated,
profiles-mount, composites-mount, no-orphan-gates, script-flags, quoted-scripts.
Not rendered in a browser.
25 stale shots (derived from check-shot-freshness on this tree), shot with internal/readme-shots.mjs
in its tokenless default ('token: none, by design (engine auto -> MapLibre)'). 24 images changed,
1 byte-identical (cap4-upg100-poster-wall). check:shots PASS (25 of 26 compared; deploy1-iframe
skipped as not a /dx-maps/ leaf).
Localizing the grid's data (ae5e27c3) would have broken the language link
silently, and the shepherd asked me to prove the coupling with a test. Measuring
it first showed the coupling should not exist.
MEASURED against the live API, en vs ar, 2,107 records:
primary_language LABEL 864 names in en, 864 in ar, ZERO shared → localized
primary_language VALUE 864 codes, ALL 864 shared → stable
religion / wagf_region VALUE also identical across locales
So the fix is not "make both halves fetch the same locale and test that they do".
It is to link on the form that does not move. Region already links on a slug and
religion on a family letter; language was the one facet still travelling as a
display name.
The map now emits `languageCode` alongside `language`, the composite carries
{value, label} for language as it always has for region, the GRID is driven by
the code (valueMatches compares the normalized value as well as the label, so a
code matches in every locale) and the MAP is still driven by the name, because
its legend selects by name. Each half gets the form it can actually use, and
neither depends on the other's locale.
★ THIS TREE HAD ALREADY PAID FOR THIS ONCE. deriveLanguageFamily in useMapData
carries a comment about the 2026-06-22 locale colour bug: deriving a family from
the localized label collapsed every pin onto one bucket, and the fix was to use
primary_language.value. Same trap, one facet over, three months later.
check:linked-vocabulary now runs per LOCALE as well as per provider — en as the
control, ar as the case that would have broken in silence — and asserts the
language facet with a poison case (a language that cannot exist must NOT narrow
the grid). It is a browser gate, so rega runs it; I have not.
★ AND check:no-free-identifiers CAUGHT MY OWN BUG IN THIS COMMIT on its first
outing: I wrote `rows.find(...)` in the emitter where no `rows` was in scope. The
gate named the file and line before anything was built. That is the class of
mistake that broke both composites yesterday, found in seconds this time.
It claimed to read "only the PALETTE export" but matched every hex from `export const PALETTE` to the
END OF THE FILE, comments included. A later note such as "REJECTED #AF7AC5, scored 2.95, under the 3.0
floor" (regions' affinity-block.js, being prepared) would have obliged every dashboard to ship a colour
rejected on WCAG grounds. paletteHexes() now slices to the export's matching closing brace and reads
hexes only inside string literals. --self-test (check:one-palette:self-test, gates:post) plants a hex
in an inline comment, a block comment, a trailing comment and a later export, and requires only the
two real values back. The verdict on main is unchanged (PASS, 6 palettes).
d6bf410a (a merge) committed conflict markers into 10 built files under public/ (big-picture,
dashboard-plus-grid, research-map, research-dashboard, both manifests) and every precondition passed:
a built bundle with markers throws on load while its SRI hash still matches. Step 0 now scans the
COMMIT's tree with git grep (1.3 s, every file, whatever the working tree holds) and refuses.
Run on d6bf410a it names exactly those 10 files; --markers-self-test builds a throwaway commit object
holding a textbook conflict under public/ and requires the scan to refuse it (PASS).
9384c1ba (simpleresearch) captures the instance in setup, so research-map.vue's
map-filtered is dispatched again, and the gate correctly reported the leftover
LATE_KNOWN entry as stale. It failed on main 688ba6cf with exactly that one
finding. With the entry removed, the gate passes and enforces the rule for
research-map.vue again. The two reflectDir entries stay: those sites are
unchanged.
Two defects rega found at ?lang=ar and ?lang=es (row 30).
★ THE CHROME TRANSLATED AND THE DATA DID NOT. The `&lang=` on the people-groups
fetch was built from props and profile-config only, never from the URL — so a
reader who asked for Arabic got Arabic buttons around English country, affinity
and religion names. That reads as a half-finished translation rather than the
wiring bug it is. It now uses the library's own localeFromUrl() — imported, not
re-implemented — so the fetch and the chrome cannot disagree about what the URL
asked for. Same ladder regions uses, and a host passing languageCode still wins.
★ AND THERE WAS NO RTL AT ALL. In ar the cards, the total and the filter bar laid
out left-to-right around Arabic words. createAppI18n reflects an RTL locale onto
document.documentElement, which is right for a page it owns and useless for an
element dropped into somebody else's — which is this grid's whole job. `dir` is
set on the HOST now, like both composites: it takes effect through the inherited
CSS `direction`, and inheritance crosses shadow boundaries, so one attribute
turns the element and everything it renders. Reflected on mount as well as on
change, because the common case is a page that loads in Arabic and never changes.
⚠ ONE CONSEQUENCE I AM NOT HIDING. Localizing the DATA introduces a coupling the
linking did not have: the composite's language facet travels as a DISPLAY NAME,
so the map and the grid must now fetch in the SAME locale for that link to match.
Region links on a slug and religion on a family letter, so both are unaffected;
language is the one that depends on it. check:linked-vocabulary compares the two
halves' vocabularies and would catch a mismatch, but it runs in English today —
extending it to a non-English locale is the honest next step, and it is not done.
country-languages, translation-credits and the SUN index each wrote straight to
disk. Each had a floor of its own — 150 countries, "something joined", 3 verified
pages — and a floor only catches a collapse. The run that half-succeeds clears it
and overwrites good data with less of it, which is the exact failure the guard was
written for after a rate-limited FCBH run lost a third of its video scopes.
All 16 fetchers now write through it. Re-ran two of them against the live
catalogues: 254 countries and 929 credited codes, byte-identical but for the date,
and with the floor forced high the guard refuses and leaves the file alone.
★ THE PRECONDITION WAS RIGHT AND I COULD NOT READ IT. It refused four landings
saying "a committed composite is not what its source builds" without naming one,
and my own freshness run said clean. Running check-composite-freshness at the
precondition's exact scope threw instead: a SyntaxError parsing
public/dx-maps/manifest.json — '<<<<<<< HEAD' at position 2317.
Ten built files carried committed conflict markers: both manifests and eight leaf
pages. They came in through my own merge resolutions. 'git restore --ours' on a
conflicted path gives MY side — and once markers were committed once, my side had
them, so every later merge carried them forward and the precondition kept
refusing a tree it literally could not parse.
Regenerating from source clears all ten. The lander now checks for markers before
committing built output, because a gate that cannot parse its input fails in a way
that looks like the thing it was checking for.
The province data was readable only inside the map's own sheet. It is a tab now:
44 provinces of India and China, the same file the map reads, so the two cannot
disagree — and unlike the sheet it sorts and exports.
Every row still says placement is approximate. A people group is assigned to the
province its map point falls in, and one point stands for a people who may live
across several.
The credit line said who translated the Scripture and called them "the likeliest
people to ask", then stopped at a name. 838 of the 929 credited groups now reach
that organisation's own homepage from the sheet.
The URL does not come from eBible's homeDomain field, which is still dropped: it
comes from a hand-screened list of 16 organisations, every one fetched on the run
that wrote the table. Where a credit names two, the one it LEADS with wins, so
"Pioneer Bible Translators and The Word for the World International" reaches
Pioneer and not the other way round.
National Bible Societies hold 89+ credits and are deliberately linked to none of
them: several UBS members publish editions carrying the deuterocanonicals, so
whether a Society contact sits inside the 66-book filter is a ruling to be made,
not one to assume. They keep the name without the link, and the count says so.
A contact is screened as somewhere to write to, not as a publisher this map reads
text from — two separate lists, so wycliffe.org appearing as a scripture link is
still a failure. Four organisations answered a datacentre IP with a bot challenge
while serving browsers normally; those are proved by robots.txt and the table
records which method proved which.
The list table is generated from library/lists/lists.json on main (ids, aliases, labels, default
doxa), and the behaviour from peopleGroupList.js: Tier A shows the DOXA groups in the list, an
unknown list falls back with a warning, a membership file that cannot load shows no groups. The
header toggle is from plugin-studio.vue (setHeaderHidden, remembered in localStorage). The CSP
section was already accurate: check:no-eval and check:cdn-pins exist, and cdn.js pins exactly
cdn.jsdelivr.net and api.mapbox.com.
pplr-data-maps and the Wall each carried the same ~40-line d3 loader (pinned, SRI, and a trap that keeps
the UMD from writing into a host page's own window.d3). It is now one library helper, fed from the
CDN registry. check:pinned-umd (headless, gates:post): the tag carries the pinned integrity, a host's d3
gains no keys, and a failed load rejects leaving the host's d3 in place. Both bundles build.
library/i18n/index.js globbed every catalogue area into every bundle: 25 areas x 11 locales, other
plugins' strings included, and each new area grew every bundle (CRITIC-10).
- internal/i18n-areas.mjs walks a bundle's SOURCE import graph (relative, @map/, dynamic import,
import.meta.glob) and keeps an area when its name appears as a key prefix or a bare string literal
anywhere reached; runtime-fetched data carries no i18n keys (measured), and every rule errs toward
including.
- vite.config.js: in a BUNDLE build, a pre-transform narrows index.js's glob to those areas; dev keeps all.
- check:i18n-treeshake (gates:post): every area-prefixed key literal in each BUILT bundle belongs to an
included area. Poisoned (one area dropped) it fails.
Measured, raw (gzip): people-groups-grid 611,595 -> 210,308 (203,176 -> 72,658); wall 659,876 -> 332,644;
doxa-simple-map 902,907 -> 686,910; research-dashboard 843,625 -> 637,940.
rega falsified my own fix. The button went 25.44 -> 17.63 against their 37.63 --
FURTHER from theirs than before I touched it.
I wrote padding:var(--spacing-xs) var(--spacing-2xl). --spacing-xs DOES NOT EXIST,
and dxlife-tokens.js:43-45 says so on purpose: "--spacing-xs (10px) and --spacing-sm
(13.3px) are deliberately NOT tokenised: '10px' is far too common a literal in this
CSS to substitute safely, and a wrong substitution here is worse than a magic number
because it reads as deliberate." I substituted anyway, into the one file whose
comment forbids it.
An undefined var() invalidates the WHOLE declaration at compute time, so the padding
fell to 0 on BOTH axes -- not just the vertical I was aiming at. Everything else on
that line landed: border 1px, radius 100px, line-height 15.625. Nothing errored, the
build passed, check:sri passed, and the failure was invisible from here.
Now the literal: padding:10px var(--spacing-2xl). Theirs computes "10px 41.6158px",
so 15.625 + 2x10 + 2x1 = 37.63 tall and ~210.7 wide against their 210.73.
GUARDED so this class cannot recur silently: the spacing check now resolves every
var(--spacing|--font-size|--line-height) reference against dxlife-tokens.js and
fails on any that is undefined, with a NEG control asserting --spacing-xs really is
absent so the check has something real to catch. rega found this by grepping every
var() by hand; that is now automatic. Currently 19 tokens defined, 0 undefined
references.
THE OVER-REACH WAS REAL AND rega QUANTIFIED IT: because the button shrank 7.81px,
all 121 leaves below it on ethiopia moved UP by the same amount at both 1440 and 768
-- the Total count and every UUPG card's name, country, tags and Full Profile. With
the padding resolving, the list sits 12.19px LOWER than before, which is correct
because it now matches theirs.
Confirmed in the same run, unchanged: all four stats at 47.6837px, identical to
theirs, with the only knock-on being the Population label moving down 9.55px as its
value grew -- 93 of 95 africa leaves untouched.
The old skill predated MapLibre, the universal props, the dx-maps tree and the three outputs.
plugin-designer teaches an agent to design a plugin in plugin-designs/ against the Plugin Studio:
bundle = folder = one JS file; the starter (now a real minimal map) and its exact renames; reuse
library/ first, private src/, library changes to the build owner; the universal props (engine
auto/mapbox/maplibre, locale, token, token service) with their channels in order; seven engine
rules, each from a real bug, including parsing expressions headless with
@maplibre/maplibre-gl-style-spec (a transitive dependency, so the skill says so); localization
with right to left on the element only; the five capability levels; learning from the seven
production plugins (each rule mapped to the one that shows it best); dev in the studio, the one
build, the three outputs. Reviewed by studio and exp-shepherd; every path it names exists on
main; references nothing outside this repo.
P1 rendering. Opening India or China now draws its provinces, coloured by the
same ladder as the country beneath them, and tapping one gives its groups, its
people, how many have nothing, the largest of those and the way in.
It arrives on intent rather than on load. India's ADM1 geometry is 1,628 KB —
nearly the size of the whole world ADM0 file — on a leaf that already loads about
4 MB and is meant to be embedded on thousands of partner sites. Loading it for
the 173 countries nobody is looking at would tax every reader for the benefit of
two. Opening one of the two IS the signal; China is 54 KB, and both are cached
after the first open.
The counts come from the measured table, never recomputed in the leaf. The
point-in-polygon belongs in the build, and a second implementation here is how
two surfaces come to disagree. Provinces repaint when the mode changes, because a
country saying one thing while the province on top of it says another is worse
than having no provinces at all.
The panel repeats that placement is approximate, and that the way in was chosen
for the COUNTRY — which is exactly the limitation going below the border exposed.
Also fixes the gate I added for this, twice. The province fetch interpolates its
filename, so the parse captured the literal prefix "boundaries/" — a real
directory of 4,096 bytes that always exists, proving nothing. The first fix
expanded that prefix against the filesystem, which was worse: unstaging a real
geojson simply shortened the list and the gate stayed green on nine assets
instead of ten, exit 0. The expectation now comes from the filenames the leaf
names, so a file the map asks for and the tree lacks fails. Poison-tested both
ways.