Commit graph

176 commits

Author SHA1 Message Date
doxa-maps-88655c70
a334e8ebff Merge main into bundler-1/work
Main moved 20+ commits while the regions work was on its own branch. Every
conflict was a GENERATED file — manifest.json, the root page, and the
regions-dashboard bundle's .js and leaf pages. Zero source conflicts: all six
of my source files (the two regions .vue, the new map child, fix-sri.mjs,
sri-consistency-check.mjs, package.json) auto-merged.

Generated files are not hand-merged. They were resolved to main's side and then
REGENERATED from the merged source — BUNDLE=regions-dashboard vite build, then
bun run tree, then bun run fix:sri. `bun run tree` rewrote every bundle's pages
and produced byte-identical output to main's committed ones for all twenty
other bundles, so the only files this commit changes beyond the merge are the
six that had to be rebuilt. That agreement is also evidence main's committed
tree matches main's source.

The pinHint:false regression I saw before is gone: main's 566ad46 committed the
two metas that were previously only on disk, which was the root cause — a tree
regeneration now preserves them instead of dropping them.

Re-verified against the merged library, which is the part that mattered: main
changed useMapInstance.js, StackLayout.vue and ViewSelect.vue, all of which the
regions element sits on. All three suites still pass — the map draws 934 pins /
51 outline features for a region and 61 / 5 for a country, the pin mode still
follows the subject, the country page still renders CountryDetail's shape, and
every width still matches DX-life's container formula at six viewports.

23 of 25 gates green. The two that fail are pre-existing and not in this diff:
check:library-index is an mtime artifact (regenerating the index produces
byte-identical content), and check:backlog-status reports 6 of 546 non-enum
statuses in the shared _qa-cycle board, which lives outside every repo.
2026-09-24 00:33:28 +00:00
doxa-maps-88655c70
20a3d3d245 Run the verification chain in phases instead of one long queue
The 29 gates in build:unlocked cost 84.5s chained with &&, but 21 of them
finish in under 700ms — they were queueing behind check:mjs-parse (26.9s)
and check:parse (18.7s) on an otherwise idle 8-core box. The 22 vite builds
cost 129.0s and were never ordered at all: vite.config.js gives each bundle
its own outDir with emptyOutDir:false and emits nothing outside it, so they
only ever needed to not overwrite each other.

internal/run-parallel.mjs runs a set of independent steps concurrently and
grades the set by exit code. build:unlocked now reads:

  gates:pre -> library-index -> clean -> token -> build:bundles -> tree
            -> gates:post -> gallery

Measured end-to-end, both chains green, two rounds each, clean+token
excluded as common to both: 165.7s -> 65.3s mean (170.7/160.8 vs 60.2/70.5).

No gate was removed, weakened, sampled, or made conditional on what changed.
The step set is identical — 31 in, 31 out, asserted at edit time — and each
step runs as the same command graded by the same exit code. Only start times
moved. The && BETWEEN phases is load-bearing and stays: a source gate still
blocks the build, and the build still blocks every gate reading its output.

Within a phase the runner reports ALL failures rather than stopping at the
first, so three broken gates cost one fix-and-rerun cycle instead of three.
It imposes no timeout of its own, because a command killed by a short
timeout is not a failure.

Negative-tested three ways, each required to exit 1: a failing gate inside a
phase, a red phase halting the chain, and a failing vite build inside
build:bundles. The bundle case also confirmed the runner enumerates
plugin-designs live (it picked up 23 dirs, not a cached 22).
2026-09-23 20:57:13 +00:00
doxa-maps-88655c70
6734be1ad6 Repair SRI hashes without a full rebuild
check:sri failed whenever a single-bundle `BUNDLE=x bun x vite build` left a
leaf's baked integrity behind its bundle — and the gate then told the agent the
fix was `bun run build`, whose first step deletes public/doxa-maps/ and tears
the tree out from under every concurrent reader.

Adds `bun run fix:sri`: rewrites the disagreeing hashes in the built tree and
nothing else — leaf <script integrity>, manifest bundles[].integrity, and the
paste-verbatim embed snippets. 0.4s, writes only the offending pages, and the
bytes it writes are identical to what the full build wrote.

check:sri's failure text now prescribes that instead of a rebuild, and its
header no longer claims generate-tree runs only inside the full build (it does
not; `bun run tree` re-bakes the hashes too, at the cost of rewriting 1234
files under public/ and re-reading .vue source every run).

Negative-tested: `node internal/fix-sri.mjs --self-test` builds a poisoned
synthetic tree, requires check:sri to go RED on it first, then green, then a
second repair to write zero files.
2026-09-23 20:35:07 +00:00
doxa-maps-88655c70
d01951bdd6 dev server: answer the production embed URL instead of silently serving the studio
Some checks are pending
build-gate / build (push) Waiting to run
A copied embed URL — <bundle>/<profile>[/<instance>]/index.html, the shape the
manifest advertises and a person actually pastes — had no route on the dev
server, which served its leaf stand-in only at /__leaf/<bundle>/<profile>.

It failed silently, which is the expensive part: with no route, Vite's SPA
fallback answers an unknown path with the studio's HTML at status 200. The
reader asks for one map and receives a different, working page, with no 404 and
nothing in the console.

leafRewrite() maps the embed shape onto the existing /__leaf/ renderer before
any branch runs, so there is one renderer, not two that can drift — the point of
the route is that one URL renders the same thing in dev and in the built tree.
Both the bare tree-relative form and the /<treeName>/-prefixed form are accepted;
the tree name is read from the build config rather than hardcoded. Traversal,
encoded traversal and malformed escapes fall through untouched, and an unknown
bundle keeps the leaf route's 404 JSON rather than becoming HTML at 200.

Verified: /doxa-simple-map/doxa-simple-map/index.html and
/__leaf/doxa-simple-map/doxa-simple-map return byte-identical bodies (742 B);
the prayer URL mounts doxa-map at 1280x800 with a live canvas in a real browser.
2026-09-23 19:53:56 +00:00
doxa-maps-88655c70
1cf4e89dab Resolve a composite's child bundles against the tree root, not a guessed depth
Some checks are pending
build-gate / build (push) Waiting to run
Driver-reported console text:
  Could not load <doxa-research-map> from ../../doxa-research-map/doxa-research-map.js

A composite declares children as src: '../../<bundle>/<bundle>.js'. That literal
is right from a one-deep leaf and wrong from a two-deep one, and the generator
emits both:
  .../research-map-plus-grid/index.html          ../../ -> /doxa-maps/          200
  .../research-map-plus-grid/default/index.html  ../../ -> /doxa-maps/rmpg/     404

So the path encoded an assumption about the host's depth that the host does not
control and cannot see. It survived because every gate exercised the shallow
leaf, which is genuinely fine.

StackLayout now derives the tree root from the document's own pathname (up to
and including the /doxa-maps/ segment) and rebuilds the path from there --
depth-independent by construction, so a third leaf depth cannot reintroduce it.
Absolute, protocol-relative and root-relative srcs pass through untouched, and
a missing /doxa-maps/ segment returns the original string, so this can only fix
a path, never break a working one.

Measured, both leaves, real browser with live data:
  one-deep  mapEl=true mapH=792 canvasH=856 0 4xx
  two-deep  mapEl=true mapH=792 canvasH=856 0 4xx   (was 404, map absent)

Also carries source that had been left behind by its own built output:
the cities/gospel legend bounding (shipped in 5008a6f as output only) and the
per-profile config carrier in generate-tree.mjs, plus a new fits-screen clause
that wheels inside every column rather than one.
2026-09-23 19:34:28 +00:00
doxa-maps-88655c70
c9d547c039 Update the clause-C self-test poison to a defect the code can still have
Some checks are pending
build-gate / build (push) Waiting to run
The original poison froze map.scrollZoom.disable() to a no-op, reproducing
syncScrollZoomToHost's old conditional-disable defect. That code path is now
dead: the panel calls .enable() unconditionally and nothing calls .disable(),
so poisoning it exercised nothing and the self-test could not fail for
clause C.

A negative test that cannot fail is the same hazard as a gate that cannot
fail -- it certifies the gate as working while proving nothing about it.

Self-test passes: the poisoned build is correctly rejected (clause=A),
14/16 clause-checks PASS.
2026-09-23 17:58:26 +00:00
doxa-maps-88655c70
7917cbdd7e Publish composes edges into manifest.json at the bundle level
Some checks are pending
build-gate / build (push) Waiting to run
Bundles that declare composition in their profile meta.json (composes:
[...]) now carry that array on the manifest's bundle object, so a
consumer like the plugin studio can render the composition tree without
re-deriving it. 7 bundles affected: big-picture, dashboard-plus-grid,
research-map-plus-grid, my-upg-100-list, stack-demo, doxa-maps-spa,
plugin-studio. Omitted for bundles with no composed children.
2026-09-23 17:51:17 +00:00
doxa-maps-88655c70
eb95d02597 Make bun run build refuse by default instead of asking politely
Some checks are pending
build-gate / build (push) Waiting to run
"Agents never run the full build" is already a top-level heading in
AGENT-BRIEF.md, and the research that established it was three hours old.
It was violated anyway -- by a dispatch prompt that said `bun run build`
while telling the agent to read the brief first. The clean step then wiped
public/doxa-maps mid-probe and an unrelated bundle read back as "did not
mount", one step from a fictional defect in a file that was fine.

Documentation cannot fix that; an explicit instruction beats a standing rule
at the point of work. So the build refuses with exit 75 and prints the
cheaper paths (bun run tree, BUNDLE= vite build, settled-tree-gate) unless
BUILD_ALLOW=1 is set.

Opt-in rather than caller-detection, deliberately: the first draft keyed on
two environment variables that do not exist in a spawned process -- a guard
that could never fire. A probe then reported a third variable as the
discriminator; it is set in the parent too, so it marks any session rather
than a child. No environment variable distinguishes them.

Negative-tested both directions: no flag exits 75 with the refusal,
BUILD_ALLOW=1 reaches "build lock acquired".
2026-09-23 17:48:27 +00:00
doxa-maps-88655c70
8a51dd061c Raise the settled-tree floors, on the gate's own advice
Some checks are pending
build-gate / build (push) Waiting to run
The gate had been printing a NOTE on every green run: "the tree has outgrown its
floors (leaves 46 vs floor 40, dirs 23 vs floor 17) -- they still catch a
CATASTROPHICALLY gutted tree but no longer a partial one." It was right. At a
floor of 17 it would have passed a tree missing eight directories.

That matters more than it sounds. A totally wiped tree announces itself; a
PARTIAL one is the state that produces a confident false measurement -- "that
bundle is missing", "the dashboard tab does not exist" -- which this flight has
already paid for several times over.

MIN_DIRS 17 -> 21 (the manifest's current bundle count, the number that grows
when a plugin is added, leaving headroom against the directories on disk).
MIN_LEAVES 40 -> 44 (two leaves of headroom for a mid-build transient).

Deliberately NOT derived from the live tree: a floor computed from what is there
can never notice that something is missing.

Negative-tested 5/5: healthy 25/46 passes; the OLD floors exactly (17/40) now
FAIL as the partial wipe they are; losing 5 dirs fails; losing 3 leaves fails;
mid-clean fails. And it still exits 0 on the real tree, with the stale-floor
NOTE gone. check:mjs-parse passes.

These floors stay coarse by design -- check:embed-targets remains the
load-bearing assertion that every embed.iframe target actually resolves.
2026-09-23 17:00:40 +00:00
doxa-maps-88655c70
56c3b2700d Derive the gate's wheel point from the band, instead of hardcoding it
Some checks are pending
build-gate / build (push) Waiting to run
Clauses B and C(below-map) wheel-tested at hardcoded y=800 and y=850. Those were
tuned when the dashboard band measured ~1213px. A later fix dropped the
page-scroll map default from 58svh to 40svh to satisfy clause A, shrinking the
band to ~892px -- so both coordinates stopped being "below the band" and started
landing INSIDE it, on .rd-td, the table's own windowed scrollport, whose
~54,500px internal scroll a bounded gesture can never exhaust.

★ The product was fine the whole time. A real gesture from a natural cursor
position (y=870) moves the grid 2159px, fully reachable. The gate was failing on
its own staleness and reporting it as a product defect -- the most expensive kind
of wrong a gate can be, because it sends someone to fix working code.

Both points now derive from the band's measured bottom: 20px clear of it, never
past the fold, floored at 120px. Verified across four band heights including the
degenerate ones -- at the current 892px band it resolves to y=870, precisely the
position where the real gesture was measured; at a band taller than the viewport
it clamps to the fold, where clause A is the clause that should be failing
anyway; and it stays sane when the band cannot be found at all.

Found by two agents who cross-verified each other with elementFromPoint rather
than geometry inference, and who correctly declined to patch a shared gate file
a third agent had already touched today -- they routed it instead.

check:mjs-parse passes.
2026-09-23 16:57:44 +00:00
doxa-maps-88655c70
4d9b90f3e3 Stop the fitness gate reporting Mapbox's own wrappers as clipped
Some checks are pending
build-gate / build (push) Waiting to run
On its first real verdict, clause E failed on ALL EIGHT viewport/target
combinations with docOverflow=0 and the same two elements every time:
mapboxgl-canvas-container and mapboxgl-control-container. Both are GL-JS's own
wrappers, legitimately zero-height with children -- the canvas container
collapses because the canvas is absolutely positioned, the control container is
an empty positioning shell. Neither is a layout defect.

That was 8 of the gate's 12 failures: a false signal loud enough to drown the
one real finding underneath it (clause B -- a wheel below the map scrolls
nothing on the composite leaf). A gate that reports four-fifths noise teaches
its reader to skim the summary line, which is the failure this file was written
to end. So the exemption is a correctness fix, not a convenience.

Scoped by PREFIX rather than an exact class list, because GL-JS composes these
as multi-class strings ("mapboxgl-canvas-container mapboxgl-interactive") and
adds more across versions. Kept narrow to mapboxgl-/maplibregl- so it cannot
silently swallow a real defect in our own markup.

Negative-tested as a predicate, 7/7: both real false-positives and the maplibre
equivalent are exempt; rd-shell, pgg-root and region-card__footer are NOT; and
"my-mapboxgl-thing" does not match mid-string. check:mjs-parse passes.

The gate could not be re-run end-to-end for a before/after verdict -- the shared
build queue has been busy continuously (check:settled reported dirs=2/17
leaves=0/40 mid-clean). Predicate proven in isolation; the full verdict is owed.
2026-09-23 16:36:51 +00:00
doxa-maps-88655c70
8029282f46 Add the fitness-for-use gate, and wire its negative test
Some checks are pending
build-gate / build (push) Waiting to run
A green state suite said nothing about whether the product is usable. The suite
was 56/58, verify:gate1 13/13 on live data, verify:map-paint 11/11 with real GL,
verify:interop 4/4, 363 done cards -- and the Driver opened the product and
found five real defects in one sitting, several CAUSED by a change a gate had
certified. Not one was visible to any gate.

The gap is what they assert. Existing gates ask does it mount, does it fetch, do
the counts agree, does a canvas paint. The Driver asks: is it slim, can I see it
at once, does the wheel do what I expect, does it tell me what I am looking at.

check:fits-screen asserts five clauses on the built composite with a real
browser and live data:

  A  the dashboard band fits ONE 1440x900 viewport, and is shorter than its own
     tree-legend's scrollHeight
  B  the people-groups grid is still reachable by scrolling underneath -- a fix
     for A must not re-break this (AP-fixing-reachability-by-making-the-thing-taller)
  C  a wheel over the map changes zoom and does not scroll the pane; a wheel
     below it scrolls and does not zoom -- instrumented on the DISPATCHED EVENT
     via composedPath, never on geometry, per the four wrong diagnoses that
     rule already cost
  D  the grid's live count is visible and CHANGES when a facet narrows it
  E  a 1440/1280/768/390 sweep has no horizontal overflow and no element
     clipped to zero height

It ships with check:fits-screen:self-test -- one regression mode per clause,
because a gate that has only ever printed PASS is unproven.

It also skips honestly: exit 0 with an explanation if the tree is not settled at
START or is no longer settled at END, or if the live API is unreachable. Proven
on its first real run, which reported SKIP with dirs=3/17 while a build was
mid-clean rather than inventing a defect.

Started by a delegated agent that hit its session limit; the two hardcoded-port
probes it left beside this file were scratch and moved to _qa-cycle. Verified:
check:mjs-parse passes, the gate is wired in package.json, and its settled-tree
skip path works.
2026-09-23 15:50:27 +00:00
doxa-maps-88655c70
61866f3e06 Make the interop harness see through shadow DOM, and wait long enough
Some checks are pending
build-gate / build (push) Waiting to run
The mobile pair reported "composite did not mount (map=false, cards=24)" on a
tree verified SETTLED both before and after the run, so it read as a real
defect. It was not. A direct browser probe of the same leaf found the composite
completely healthy: both children mounted, a 1440x856 canvas PAINTED, 0 console
errors, 0 failed requests, body 3168px.

Two causes, and the asymmetry with the desktop pair is what hid them.

SHADOW DOM DOES NOT PIERCE. The gate used document.querySelector
("doxa-research-map"), a single-level query. The desktop gate's
document.querySelector("dashboard-plus-grid") works only because that element IS
the top-level custom element on its own leaf. Here <doxa-research-map> is nested
inside the composite's shadow root, where a top-level selector cannot reach it.
The harness already carries __walk(), a recursive shadowRoot walker, and simply
was not using it on that line.

THREE SECONDS IS NOT LONG ENOUGH. The direct probe needed ~22s before the nested
map had mounted and painted. A composite that lazily injects a child bundle's
script, waits for customElements, then initialises a GL map does not finish in
3s here. Now it polls up to 30s for both halves.

The failure message also reports the custom tags it DID find when it gives up,
because "map=false" alone cannot distinguish "never mounted" from "mounted
somewhere this query cannot reach" -- which is exactly the distinction that cost
a tick.

A gate that cries wolf on a healthy surface gets ignored, and then it cannot
report the real thing.

Verified: verify:interop now PASSES 4/4. Mobile grid search "India" -> Total:
941; map mounts independently. Desktop unchanged -- facet -> "Showing 1,309",
and row selection still moves the camera 28.000,18.000@1.45 ->
73.666,13.162@2.48. check:mjs-parse passes.
2026-09-23 15:13:14 +00:00
doxa-maps-88655c70
f48cad8dbb Widen the red ramp, add a greyscale ramp, and gate cap-4 interoperability
Some checks are pending
build-gate / build (push) Waiting to run
THE RAMP. The Driver reported that 60-149 reads as the same red as the 150+
heartbeat bin. The old ramp was mathematically even and he was still right --
that tension was the finding, not a contradiction. Measured in plain sRGB
distance the top step (60.0) looked like the LARGEST in the ramp; that
measurement was lying, because RGB distance is not perceptual. Re-measured as
deltaE the old steps were 15.5 / 15.8 / 16.7 / 17.7 -- nearly flat, while chroma
collapses at the dark end so hue and saturation stop carrying information there.

The new ramp deliberately ACCELERATES toward the dark end: 14.1 / 17.9 / 21.4 /
24.9. Top-bin separation 17.67 -> 24.94, a 41% improvement on exactly the pair
he named, and total lightness range widens from 37.2 to 49.0 L*.

A named greyscale ramp ships alongside it, grey to black, as he asked ("just in
case i need to swap from red to black scale"). Its steps measure 22.8 / 22.4 /
22.4 / 22.8 -- near-perfectly even, every step well above the ~10 threshold for
clear distinguishability. It is an OPTION, selected by name; the red ramp
remains the default, so no prior decision is silently reversed.

Bin EDGES are untouched -- check:heartbeat passes, which replays the bin
assignment and fails if a default moves.

THE INTEROP GATE. verify:interop drives the actual compiled cap-4 composites in
a real browser against live data, closing the gap verify:gate1 leaves open. On
its first run it PASSED both desktop directions: a facet-rail click narrowed the
grid to "Showing 1,309", and selecting three rows moved the camera from
28.000,18.000@1.45 to 73.666,13.162@2.48 -- read back off the live map, not
inferred. It asserts the grid's live count line rather than card count, because
card count is a proven false-negative (always 24).

It ships with verify:interop:self-test, a two-mode negative test -- grid-prop
freeze and camera freeze -- each required to fire its own combination-named
failure. A harness that has only ever reported PASS is unproven.

Gates: sfc-parse, template-literal, one-palette, synced-css, mjs-parse,
heartbeat, doc-scripts all pass.
2026-09-23 14:53:51 +00:00
doxa-maps-88655c70
0f7ef172c4 Re-baseline the source check, and record that the two lostness axes disagree
check:legend-source was failing: the corpus moved 2,107 -> 2,108 and Unengaged
2,080 -> 2,081. The gate's own text forbids updating the baseline alone, since
that silences the check while leaving the maps wrong -- so the maps were checked
first. Grepping 2107/2,107/2080 across plugin-designs/ and library/ found the
number only inside explanatory comments; no shipping code hardcodes a corpus
total. Every legend and KPI computes from the rows it loaded, so the display
already followed the data and only this baseline lagged.

Also recorded, because two independent lostness measurements now ship together
and they are not the same question. imb_gsec measures evangelical presence and
church-planting activity; the six imb_*_available flags measure whether a
resource exists. Over the live corpus:

    gsec rung 0 "No Evangelicals, Churches or Resources" .... 225
    zero of the six resource flags ......................... 285
    BOTH ................................................... 209
    rung 0 yet HAS at least one resource .................... 16
    zero resources yet NOT rung 0 ........................... 76

Of the 225 groups filed under "No Evangelicals, Churches OR RESOURCES", 11 have
one of the six and 5 have four each. The "or Resources" clause is not literally
true in the data and must not be repeated to a mission team as fact.

Deliberately NOT asserted as a gate condition: which figure is official is an
open Driver decision, and a gate must not quietly canonise one answer to a
question still being asked.

Negative-tested: a bogus baseline drives the gate to exit 1 naming the corpus
size, and restoring it returns exit 0.
2026-09-23 13:28:45 +00:00
doxa-maps-88655c70
121275e0f2 Say what the console noise is, so the count stops reading as a defect
"17 pass-with-console-errors" is a bare number that invites investigation, and
it cost a full tick: the count was carded as a suspected hidden defect on the
real precedent that a thrown setup() in this repo once removed a subtree
silently while the gate still counted the element as mounted.

It was noise, and the partition is the proof. Every noisy element is map-bearing
or bakes in a map-bearing child; every silent one is not. On a box with no GPU
and no Mapbox token, a map reporting a failed init is correct behaviour.

One message class was an artifact of the report itself: console.error('[x] Map
failed to initialize --', err) passes two arguments, and Playwright renders the
Error argument as the bare word "Error". Storing each argument separately made
those look like 12 anonymous errors of unknown origin; they were the second half
of messages already counted.

The report now lists each noisy element with its child count and first message,
and prints an explicit "what WOULD be a defect" counter -- a noisy element with
0 shadow children or an error card -- which reads 0 today.

Negative-tested: a synthetic tree with a 0-child element and an error card
drives that counter from 0 to 2, so it is a live signal and not a decorative
zero.
2026-09-23 12:34:50 +00:00
doxa-maps-88655c70
d08edbc957 Watch only the directories the library index is generated from
check:library-index failed, naming library/scripts/research/build-trade-languages.mjs
as the reason the component menu was stale. That file is a build script. The
menu is a catalogue of reusable components, composables and utilities, and
generate-library-index.mjs reads exactly three directories — components/,
composables/, utils/ — so a change to a build script cannot alter one byte of
the output.

The gate walked all of library/. Measured: 75 files are indexed, 173 are not —
scripts/ 58, i18n/ 98, plus data/, layouts/, api/ and constants/. It watched 2.3
times more than the thing it guards, while its own comment said "Newest source
the generator actually scans", which is exactly what it did not do.

It fired for real: adding a comment to a build script two commits ago turned it
red. That is the expensive kind of false positive, because the remedy looks
cheap — regenerate and move on — and a gate that asks for a pointless
regeneration teaches the reader to do it on reflex, which is how a staleness
check stops being read at all.

Now scoped to the three indexed directories, with a note that the scope must be
extended in the same commit if the generator ever adds a section().

Negative-tested both directions rather than only confirming the fix: touching
library/composables/useMapFly.js still FAILS and names it; touching the build
script again PASSES. The gate discriminates where it could not before.

Worth recording that the regenerated index came out byte-identical — git reports
only the gate file as modified. That is the claim made concrete: the change
which had been failing this gate could not affect its output.

Five gates pass.
2026-09-23 11:41:55 +00:00
doxa-maps-88655c70
1ddfde6b6b Make the two gates say where the tree name came from
Last tick I fixed a dead config read in check-composite-srcs and
check-nested-token, and logged the lesson as an anti-pattern whose advice was:
make the successful path observable — print what was READ, not just what was
used. I did not take my own advice, so this applies it.

Both gates printed "checked against public/doxa-maps/" whether the name came
from the build config or from the hardcoded fallback. Those two states were
indistinguishable in the output, which is precisely why a permanently-failing
read survived unnoticed: nobody was ever going to rename the tree, so the only
thing that could have exposed it was the output, and the output said nothing.

They now print the source. A green run reads "(tree name from
vite.doxa-maps-build-config.json)"; with the read broken it reads "(tree name
from built-in default (build config unreadable))". Negative-tested by pointing
the read at a nonexistent filename and confirming the line changes, then
restoring and confirming it changes back.

Also corrects a comment I got wrong yesterday. The note explaining the bug said
the gate "said vite.doxa-maps-build-config.json — the pre-rename project name",
which is the NEW name; my sed had rewritten the string inside the prose that
described the OLD one. It now names vite.1040-maps-build-config.json, which is
what was actually there. A correction note that misstates what it corrects is
worse than none, because the next reader takes it as the record.

Six gates pass.
2026-09-23 11:29:05 +00:00
doxa-maps-88655c70
0235b75394 Read the build config that exists, so two gates stop passing by coincidence
check-composite-srcs and check-nested-token-delivery both resolve the output
tree name from the build config, with a comment explaining why the fallback
matters: "Defaulting silently would make this gate check a directory the build
never writes."

Both named vite.1040-maps-build-config.json — the pre-rename project name. That
file does not exist. So the try ALWAYS threw, the catch ALWAYS fired, and the
tree name was ALWAYS the hardcoded default. The read was dead code, and the
gates have been correct only because the default happens to equal the config's
current name, which really is "doxa-maps".

The comment described an intent the code had stopped delivering, which is the
part worth fixing rather than the string. Rename the tree in the build config
and both gates would have walked public/doxa-maps while the build wrote
somewhere else, then SKIPped with "an absent tree is not evidence either way" —
silent, delayed, and the exact outcome that comment exists to prevent.

Proved the read now runs rather than assuming it: parsing
vite.doxa-maps-build-config.json succeeds and yields name "doxa-maps". Then
negative-tested by renaming the tree in the config — the gate now FOLLOWS it and
reports "SKIP — no built tree at public/renamed-tree-probe/", where before it
would have checked the wrong directory and reported PASS. Config restored,
re-verified green.

Seven gates pass, including both repaired ones.

Found by scanning _qa-cycle/CLAUDE.md, the oldest unread doc in the ideation
sweep (2026-07-12) and an inheritor's entry point. It carried the same stale
filename twice, once under the heading "Build config — ONE file", followed by
"Edit it, run bun run build, done" — pointing a reader at a file that is not
there. Corrected in that file too. A third occurrence sits inside a DONE card in
03-deployment-cycle describing a past state; that is a historical record and is
deliberately left alone.
2026-09-23 11:24:11 +00:00
doxa-maps-88655c70
718ad723dc Check that the numbers the maps show match the numbers the source has
Every gate in this repo verifies INTERNAL consistency. check:facet-changes-map
proves the dots change and that the categories sum to 2,107 — but it asserts
that inside the page, against data the page already loaded. If the upstream
corpus shifted, the page would stay perfectly self-consistent while its legend
quietly stopped describing reality. Nothing fetched the live endpoint: not the
six data guards, not check:label-joins, not any browser gate.

That is the worst failure this product can ship, because it is invisible to
every structural check. The element mounts, the canvas paints, the counts are
formatted correctly, the partition sums. Only comparison against the SOURCE
catches a legend that is beautifully wrong — and these maps exist to say where
the gospel has not reached, so a wrong number is not a cosmetic defect.

Measured by hand first, then encoded. The cap-2 engagement legend shows
Unengaged 2,080 / Engaged 27; the live API returns 2,107 rows splitting exactly
2,080 / 27, with no third bucket and no remainder. The cap-4 dashboard KPI strip
prints "1% ADOPTED"; the live API gives 21 of 2,107, which is 1.0%. Three
independent surfaces agreeing to the unit.

The gate also asserts the partition is COMPLETE and that no category exists which
the two-row legend does not show — a third engagement value would hide rows from
the reader while every count still looked right.

The expected values are a baseline, not a law, and the failure message says so:
the corpus is living data and these numbers will move. A mismatch is a prompt to
update the legend, the KPI and the baseline TOGETHER, which is the coupling that
was missing. Updating the baseline alone would silence the gate and leave the
maps wrong.

Offline SKIPs at exit 0 rather than failing, because reporting a mismatch that
may not exist is the fail-open mistake in reverse. Negative-tested both ways:
drifting the baseline fails naming the exact category and both numbers;
an unreachable host skips and says why.

The browser user-agent is deliberate — pray.doxa.life answers 403 to a default
script UA, a trap already recorded here, and one that presents as "the data is
gone".
2026-09-23 10:54:59 +00:00
doxa-maps-88655c70
c7029aaa55 Point the filter-width gate at pages that exist, and make it say so when they do not
check:filter-width was failing on both hosts with "UNMEASURED: no
.filter-dropdown__trigger found in the composed tree" and "no tab matched
/dashboard-plus-grid/i". Neither was true of the product. Both paths ended in a
trailing slash — directory URLs — and `bun run serve` is serve-strict.mjs, which
404s a directory with no index fallback because the live static layer does. A
404 page has no trigger and no tab rail, so a missing PAGE was reported as two
missing FEATURES. Measured: both directory URLs return 404, both index.html URLs
return 200.

Now passes in both hosts, and what it reports is the Driver's fixed-width
decision holding: all eight triggers at 168px before and after selecting the
longest option, identical in the standalone dashboard and inside the cap-5
studio.

Two things were checked rather than assumed. The tab regex looked like a second
bug — the rail labels tabs "Big Picture", not by bundle id — but the studio also
renders a developer row reading "dashboard-plus-grid<dashboard-plus-grid>", so
/dashboard-plus-grid/i correctly matches tab 3. Single cause, not two. And the
trigger class the gate looks for does exist (FilterDropdown.vue:278), which is
what made "the page is wrong" the remaining explanation.

Also adds a response check before any content assertion, so this failure mode
cannot recur silently. Negative-tested by pointing it at a nonexistent page: it
now fails with "UNMEASURED: HTTP 404 for /research-dashboard/NO-SUCH-PAGE/
index.html — nothing below this means anything", which names the cause instead
of describing a symptom.

Worth recording why this survived: one tick earlier I swept for directory URLs
and reported the class closed. That sweep grepped for goto(`${BASE}...`) as a
template literal, and this file builds its URL as BASE + path by concatenation,
so the pattern could not match it. A sweep is only as wide as its pattern. The
re-sweep looked instead for any goto whose URL lacks ".html" — a property of the
RESULT rather than of the syntax — and found this one plus nothing else among
the static-tree gates. The BASE + '/' users in smoke-studio, smoke and
check-scrim target the vite dev server on 5173, which does resolve directories,
so they are correct as written.
2026-09-23 10:03:03 +00:00
doxa-maps-88655c70
dbb917cbed Point the tab-order gate at a page that exists
check:spa-tab-order was FAILING with "no .vtl-tab rows found in the composed
tree", which reads as a missing tab rail. The rail is fine. The gate navigated
to a DIRECTORY url — .../doxa-maps-spa/doxa-maps-spa/ — and `bun run serve` is
serve-strict.mjs, which 404s a directory request with no index fallback because
the live static layer does. Measured: the directory url returns 404, the
explicit index.html returns 200.

Under the old `python3 -m http.server` this line passed, since that server
silently resolved a directory to index.html. So the gate was green locally and
would have 404'd against production — the exact defect class serve-strict was
introduced to expose, after it hid a bad url in verify-shell-scroll for a whole
flight. A repo-wide sweep found this was the last remaining directory url in
internal/.

The symptom deserves recording because it was maximally misleading: a 404 page
has no rail, so a missing PAGE and a missing FEATURE produce the same message,
and the line immediately below warns about that same confusion arising from
shadow roots. A correct warning about one cause makes the wrong diagnosis of
another cause more tempting, not less.

Now passes, and what it prints is the canonical Gate-1 chain: 12 rows opening
Big Picture ("dashboard + people-groups grid, stacked"), UPG-100 List, Prayer
Progress, Gospel Resources. Big Picture is tab-1 of the cap-5 SPA, as specified.
2026-09-23 08:41:26 +00:00
doxa-maps-88655c70
c4c5e366ad Say what the embed-smoke node count does and does not mean
smoke:embeds reports a node count per profile, and the counts vary enormously:
dashboard-plus-grid mounts 1,499 nodes with a canvas, big-picture 262,
research-map-plus-grid twelve. Read side by side that last one looks like a
capability-4 composite whose two children never rendered.

It is not. Measured with a recursive shadow-root walk at mobile viewport: 518
nodes, 1 canvas, both children present as real custom elements
(doxa-research-map and people-groups-grid), 13,867px of content, zero console
errors and zero responses >= 400.

The gate counts ONE shadow level. research-map-plus-grid nests its children by
TAG and bundle URL rather than importing their .vue files — deliberately, and
its meta.json explains why: importing the research map's component directly
would bypass doxa-research-map/index.js and silently drop that bundle's private
colour strategies, its profileModules registry, its Pinia/i18n pair and the RTL
plugin. Children mounted that way live in their own shadow roots, which a
single-level querySelectorAll cannot see.

So the count is a MOUNT signal, not a quality signal, and the `< 5` bar it feeds
is still exactly right — that bar catches a mount which produced nothing, which
is what this gate exists to assert. Only the reading was wrong, so only a
comment changes here.

Worth recording because this repo already carries the lesson that shadow DOM
does not pierce, and it cost three wrong diagnoses of a non-bug once before. It
reappeared inside a gate rather than in an ad-hoc probe, where it is harder to
notice: the gate is green, the number is simply not measuring what a reader
assumes. A near-miss, caught by checking the outlier in a browser instead of
filing it.

Whole run for the record: 43/43 pages mount cleanly, enumerated generically from
manifest.json — every bundle x every profile, not a hardcoded list. That is the
"Playwright coverage over every plugin" item on the standing work list, and it
is real coverage.
2026-09-23 07:53:31 +00:00
doxa-maps-88655c70
bd504063a1 Shoot the README with the token the bundles ship, and make the fidelity gate ask
Two related fixes, both about a measurement that had outlived the thing it
measured.

The shot harness fetched its Mapbox token from doxa.life/api/maps/token on every
run. That token belongs to the Mapbox account `doxalife` and is URL-restricted
to doxa.life, which is why the harness also had to spoof a Referer header to get
any tiles at all. Meanwhile the built bundles carry a different token entirely —
`gosystems`, baked in from vite.doxa-maps-build-config.json — and that one is not
restricted. Measured with no Referer: mapbox-streets-v8, country-boundaries-v1
and bathymetry-v2 all return 200 on the baked token and 403 on the fetched one.
The harness was doing extra work to use the weaker of the two.

It now reads the build config first and keeps the fetch as a fallback. Beyond
simply working, a README image is evidence about the SHIPPED artifact, so it
should render with the credential the artifact ships with; rendering with a
different token means the picture stops proving what a reader downloads. The
local read is also deterministic — the network path degraded silently, setting
the token to empty when doxa.life was slow, after which every shot was taken
token-limited and still written to disk.

check:shot-fidelity had the same problem one layer up. It fired its tileset
findings whenever any file referenced a mapbox:// tileset, and printed
"Referer doxa.life -> 200 · 127.0.0.1 -> 403 · no referer -> 403" as though it
were a live reading. It was one, on 2026-09-21, against the token that shipped
then. After the token swap in 1b6316a the sentence kept printing — warning about
a blank void while the shots came out fully painted. A gate that states a
measured fact has to re-measure it, or it becomes a confident lie at exactly the
moment someone fixes what it describes. It now fetches one real tile with the
baked token and no Referer, and suppresses the tileset findings when that
returns 200.

Network failure is deliberately not treated as a restriction. An offline box
cannot tell "restricted" from "unreachable", and asserting a restriction that
may not exist is the defect being repaired.

Negative-tested: pointing the probe at the restricted doxalife token makes the
gate report RESTRICTED and fire all three findings; the shipped token makes it
report REACHABLE and suppress them. The first attempt at that test proved
nothing and is worth recording — the poison never applied, because doxa.life
returns 403 to Python's default user agent, so the copy was never written and
the unmodified gate ran and "passed". A negative test that cannot fail is the
same defect in miniature. Re-run with a browser UA, it discriminated correctly.

The 12 regenerated screenshots are the point of all this. cap3-monolithic-research
was read as an image, not inferred from the generator's exit code: it now shows
coastlines, country borders, place labels, ocean names and Mapbox/OpenStreetMap
attribution, with the 2,107 data dots sitting on real geography instead of an
empty void. Note the PNG got 67KB SMALLER while gaining all of that, so file size
was never going to answer the question.

check:shot-fonts still fails, and that is pre-existing and honest: this box has no
emoji font, so six bundles shoot tofu boxes. Verified by stashing these edits and
re-running — exit 1 either way. The markup is correct; the fix is a font on the
shot machine, never removing emoji from the product.
2026-09-23 06:26:39 +00:00
doxa-maps-88655c70
5b3618d0d6 Check that the CLI doc still quotes package.json correctly
Applying last tick's lesson -- grep for the class, not the instance -- found a
third surface teaching the old serve command. README-SECTION-3-cli-commands.md
transcribes the package.json scripts so a reader need not open the file, and it
still showed python3 -m http.server two commits after serve became
serve-strict.mjs. An inheritor following the CLI reference would have started the
permissive server, the one whose forgiveness hid a URL bug in two gates for a
whole flight.

Measured before fixing: ten quoted scripts, nine matching, exactly one drifted. I
had expected build:unlocked to be stale too and it is not -- the doc truncates it
rather than misquoting it, which is the right call for a seven-hundred-character
line.

Hand-fixing the one line would leave the next script change to rot the same way,
so the doc is now checked. The comparison is loose in the three ways a
human-facing doc needs: package.json's trailing hash comments are ignored, the
doc's own slash-slash annotations are ignored, and truncation passes on a prefix
match either way. A quoted script that does not exist in package.json is a hard
failure, since that is a command someone would type and get "script not found".
Completeness is deliberately not asserted; the doc is a curated subset and
demanding otherwise would make it churn on every internal gate.

Self-test poisons the doc in memory two ways, a changed body and a nonexistent
script name, and requires both to be caught. Both are. Full build passes with the
gate in the chain.
2026-09-23 04:19:42 +00:00
doxa-maps-88655c70
7e1baac4a1 Hand an inheritor a token that works, and stop claiming one is present without checking
The golden path told a new author to fetch a token from doxa.life/api/maps/token.
Measured: that token is URL-restricted to doxa.life, so a tile request without
that Referer returns 403 and every basemap in their smoke test renders blank
while the pins draw fine. That is exactly the symptom the Driver reported as
loading the pins but not the underlying map, and it is why tokenUrl was emptied
in the build config the day before. The first command in the hand-off doc
reproduced a known bug.

It now reads the token from the build config, which is the source that works:
same tile request, config token 200, endpoint token 403. That is a pk. public
key baked deliberately, protected by URL restrictions rather than secrecy, and
check:public-hygiene allows pk. while failing the build on sk. and tk. -- so it
is safe to read and never a secret to guard. The doc shows the command, not the
key.

The aggregator had the same bug and a worse symptom: it fetched from the same
endpoint and printed "token present", which meant a string had been obtained,
not that it worked. So a run could report a present token while every map gate
measured a blank basemap. It now prefers the config token, falls back to the
endpoint, and validates whichever it gets with one tile request.

Verified in all three states: the config token reports "present and serves
tiles", a fabricated token reports "tiles 401 - basemaps will be blank", and the
old endpoint token reports "tiles 403 - URL-restricted?". The banner reports
what was measured rather than what was fetched.
2026-09-23 04:09:07 +00:00
doxa-maps-88655c70
aa196db1d8 Refuse to serve a half-built tree, so a gate cannot mistake one for a regression
A build removes public/doxa-maps before rebuilding it, so a browser gate running
during one reads an empty tree and reports a confident wrong answer.
verify-shell-scroll said it could not find the dashboard-plus-grid tab twice in
ten minutes while a swarm rebuilt underneath it. That reads exactly like a real
regression and cost three ticks of this flight.

The server now answers 503 for anything under /doxa-maps/ while the manifest is
absent, with a body naming the cause. A 503 rather than a 404 is deliberate: 404
means "this is not here", which a gate may reasonably report as a product defect,
while 503 with a reason cannot be mistaken for a finding. Assets outside the
bundle tree are unaffected.

The check belongs in the server because all nine gates fetch through it. Guarding
one chokepoint means none of them can misread a torn tree and none of them needs
editing, where having every gate take the build lock is nine changes plus a rule
every future gate has to remember.

The aggregator already refused to start against a missing tree, but only at
startup, and a full sweep runs for seventeen minutes. It also blamed a missing
server for a 503, sending the reader to start one that was already running; it
now names the real cause. The gap the server closes is the one that actually bit:
a single gate run directly, at any moment, including mid-sweep.

Verified by hiding the manifest in place: a leaf returns 503 with the explanation
while /assets/ still returns 404, and both return to 200 when it is restored.
2026-09-23 03:52:30 +00:00
doxa-maps-88655c70
236d289cc7 Point the anchoring gate at index.html too, so it can actually measure
Second gate caught by the strict server. check-fixed-anchoring loaded both hosts
by bare-directory URL, which the old permissive local server resolved and the
live layer does not, so it was green only because local was more forgiving than
production.

Worth noting how it failed: UNMEASURED for both hosts, not a false pass. It
refuses to report success when it could not measure, which is the difference
between this gate and one that would have found zero dropdowns and called that
zero anchoring problems.

Now passing with 16 triggers anchored across both hosts, Status among them --
so the eighth filter anchors correctly in the composite as well as standalone.

A sweep of internal/ finds no remaining bare-directory leaf URLs.
2026-09-23 03:27:10 +00:00
doxa-maps-88655c70
0f0bebd9c2 Serve locally the way production serves, so a bad URL fails here instead of live
bun run serve was python3 -m http.server, which resolves a directory request to
its index.html. The live static layer returns 404 for the same request. So a gate
could pass locally on a URL that does not work in production, and nothing local
could ever catch it.

That is not a harness quirk, it is a blind spot with a green light on it: every
defect inside the permissive gap passes locally by construction and can only be
found in production, which is the worst place to find it. It cost a false
"the publish failed, zero filters live" reading on a publish that was
byte-for-byte correct.

serve-strict.mjs returns 404 for a directory, exactly like production, with no
index fallback and no directory listing. It also refuses to read outside its
root. Loopback only, inherited from the script it replaces, whose own comment
records that the bind was once 0.0.0.0 and answered on the box's public IP while
a gate was running. The permissive server is kept as serve:permissive rather
than deleted, so the difference can be demonstrated.

It earned itself immediately: verify-shell-scroll held two bare-directory URLs
and failed the first time it ran against the strict server. Both now ask for
index.html, which is the form the manifest already ships for all 28 profiles,
and the gate passes again -- both wheel gestures moving the grid 1500px onto
screen.

Fixing the server rather than the URLs is the point. The repo was already
production-correct: 28 of 28 manifest URLs and 82 gate references use
index.html. What was missing was anything that could fail when one did not. A
rule you have to remember fails under time pressure; a server that returns 404
does not.
2026-09-23 03:09:25 +00:00
doxa-maps-88655c70
1652559e06 Teach the synced-CSS gate what a selector is, and turn it on
The gate reported 14 conflicts across two files. Thirteen were false and it was
blind to real ones, and both came from a single cause: it keyed a rule on the
last class before the brace, so any qualified rule was filed under its own base
rule. A dark-mode override, a .dsm-dark variant and a :host(:fullscreen) rule all
merged into the unconditional rules they were meant to override, and the pair was
then reported as the file disagreeing with itself. In every case both copies were
identical and the code correct.

Merging is last-write-wins, so the same bug also HID conflicts. Poisoning
.dsm-root inset from 0 to 7px in the shadow copy produced no finding at all,
because the fullscreen rule's own inset:0 was merged in afterwards and restored
it. A property could be shared, genuinely divergent, and invisible.

The key is now the full selector, and at-rules are part of it. The header already
claimed media-query variants were treated as distinct rules; nothing implemented
it, and PeopleGroupDetail's 18px name with a 16px @media override read as the
file contradicting itself when the shadow copy simply has no mobile rule -- which
this gate's own contract explicitly allows. At-rule bodies are brace-matched
rather than regex-matched, since a lazy pattern stops at the first inner brace.

The shadow literal is also sliced AFTER its opening backtick. Slicing from the
anchor left useShadowStyles(` at the head of the CSS, which a full-selector
pattern absorbs into the first rule -- .dsm-root was captured as
"useShadowStyles(` .dsm-root" and silently dropped.

That leaves one true finding, and it was real: .action-buttons padding-top was
12px as a Vue child and 4px in a shadow root. Both values date from the initial
commit, so there is no later intent to infer; aligned to the shadow copy's 4px
because that is the copy that reaches the shipped bundles, so it preserves what
users have been seeing rather than changing it.

check:synced-css:self-test poisons three properties in memory and requires each
to be caught. inset is there deliberately -- it is the one the old implementation
missed, so it is the case that proves the fix. Now in build:unlocked, which it
could never be while it was red.
2026-09-23 01:22:36 +00:00
doxa-maps-88655c70
1c01992a88 Gate the tab radius so a superseded rationale cannot quietly restore the pill
The 999px stadium was a real instruction that shipped, and its rationale is still
in the layout file as history. A superseded rationale that still reads as current
is how a reversed decision gets undone -- a reader trusting it would restore the
pill believing they were fixing a regression. The comment now says not to; this
makes that enforceable.

It asserts the TOKEN rather than the value, because the requirement is that the
tabs, the collapse button and the filter toggles agree. A shared knob guarantees
that; three matching literals only coincidentally satisfy it. The expected
fallback is READ FROM .vtl-collapse rather than hard-coded, so a gate asserting
8px cannot pass while the collapse button moves to 10px and the two surfaces
silently diverge. Both synced copies are checked, so a fix applied to one host
fails here rather than shipping correct in one and wrong in the other.

.vtl-cap keeps its pill and is deliberately not asserted: the complaint was the
ratio of radius to size, and a gate banning 999px everywhere would over-apply the
correction.

check:tab-radius:self-test poisons the source in memory and requires both
regressions to be caught -- the pill restored, and a bare literal beside the
token. Both caught, tree untouched. Wired into build:unlocked beside the other
source-level checks so it fails in a second rather than after the vite loop.

Noted while wiring: check:synced-css is NOT in the build chain, which is how two
files have stayed in conflict. Left alone here -- turning it on is its own change
with its own failures to fix first.
2026-09-23 01:08:10 +00:00
doxa-maps-88655c70
285ba39c31 Stop the data table from swallowing the wheel, and grade the gesture honestly
The Driver could not scroll down to the people-groups grid. Three separate
diagnoses blamed the map, including one of mine that shipped. Measured in the
cap-5 shell: a ten-tick wheel with the cursor over the dashboard moved the outer
pane 300px while .rd-grid's own scrollTop went 0 to 2606. The wheel was being
consumed by the table -- a virtualized grid whose scrollHeight is 54834px inside
a ~207px window. That is ordinary scroll-chaining: a nested scrollport eats wheel
events until it reaches its own end, and with 54 thousand pixels of table it
never reaches the end. The Mapbox canvas sits above the cursor and was never
involved.

overscroll-behavior:contain does not stop the table scrolling; it stops it
chaining, so the remainder now reaches the pane.

The gate that should have caught this had two defects of its own. It measured the
gesture the Driver makes and then asserted on a different number, so it passed at
2611px while his gesture moved 300. When that was fixed the threshold was written
as "moved less than 200px" -- a figure picked without comparing it to the value
already measured as broken, which was 300, so it still passed. The threshold is
now derived from a measured baseline: the same gesture performed lower down the
page, where it is known to work. Two identical gestures on one surface should
move it comparably, and there is no constant left to rot.
2026-09-23 00:31:35 +00:00
doxa-maps-88655c70
81fbf7c89f Do not report a filter bug when the gate's own map handle went stale
The panel destroys and re-creates its Map on a re-render, and the dev server's HMR
does this freely, which leaves window.__rdashMap pointing at a torn-down instance
whose getSource() throws "Cannot read properties of undefined (reading
'getOwnSource')". Measured one run in three failing that way on the dev surface
while the product was fine — the first invocation through the package.json script
failed for exactly this reason and the next passed unchanged.

A gate that reports a product bug when its own reference went stale is a gate that
gets switched off. __live() now proves the handle still works before any assertion
reads through it, and readState() waits for the re-created instance to repopulate
rather than treating a dangling reference as a verdict. A state that is still
stale after the grace period is reported as an infrastructure fault, named as such,
not as a filter failure.

Verified 4/4 consecutive passes on the dev surface where it previously flaked, and
all four negative-test modes still catch their regressions (the retry must not mask
a real failure).
2026-09-22 22:50:21 +00:00
doxa-maps-88655c70
2a9198fa3c Assert the shipped heartbeat ramp, not just the panel's fallback
check:heartbeat proved the PANEL FALLBACK was intact and called that "default
ramp unchanged". It is not the same property. The running app resolves the ramp
with dashboard.config.js's map.* layered on top of that fallback, and the gate
never read the real config: assertion F checked only that heartbeatThreshold was
DECLARED, never what it was SET TO.

Measured consequence, reproduced before fixing:

    sed -i 's/heartbeatThreshold: 150,/heartbeatThreshold: 999,/' \
      plugin-designs/research-dashboard/dashboard.config.js
    node internal/check-heartbeat-threshold.mjs   # exit 0

The shipped map could silently change its ramp while the gate reported it
unchanged. Now exits 1, naming the key and its value.

Adds assertion A2: resolve the ramp the way the running app does — panel
defaults with the shipped map.* applied — and require it to equal the historical
5/20/60/150/Infinity with its labels. A (fallback) and A2 (shipped) share one
comparison helper so they cannot drift. A2 also rejects a shipped heartbeatMode
other than 'fixed': a data-derived ramp is right for an unknown dataset and
wrong for this one, where 150 is deliberate. Changing the shipped ramp is now a
deliberate act that must update HISTORICAL in the same commit.

dashboard.config.js is read by brace-matching its `map:` literal rather than
import(): it pulls in @map/* aliases and a JSON module, so importing it would
need Vite's resolver — a dependency that buys nothing for a bag of scalars.

The self-test now runs three cases, each required to fire the assertion that
exists for it, and poisons in memory so a crash cannot leave the tree edited:

    [literal]         resolver replaced by the old array        -> must fire B
    [shipped-default] shipped heartbeatThreshold changed        -> must fire A2
    [shipped-mode]    shipped heartbeatMode flipped to quantile -> must fire A2

The header previously claimed the gate was "negative-tested" on the strength of
the first case alone. True of that case, misleading overall, so it now states
per-case coverage and what is NOT covered (colour contrast, and whether the map
paints at all). Testing that the FEATURE works — a configured threshold moves
the edge — is not testing that the REGRESSION is caught, and only the second
protects the requirement that today's map must not change.
2026-09-22 22:44:10 +00:00
doxa-maps-88655c70
04c2a3d2eb Gate the rule that a facet selection must change the dots — and that the dots say what they mean
The prayer filter was reported as "not applying" to the map. Measuring it first
changed the fix: the filter was never broken. Selecting "Has Prayer" already
narrowed the rd-bubbles source 2107 -> 689 features, exactly matching the
dropdown's own tally, disjoint from "Needs Prayer" (1418), summing to 2107 and
round-tripping on clear.

What was broken is that circle-color was the flat literal '#ef6c00' — read back
off the live layer, the bare string, not an expression — so every dot painted the
same orange whether it had prayer or not. A prayer question got an answer with no
prayer information in it, and the surviving dots looked identical to the ones the
user started with. That is why a working filter reads as "not applying".

This gate asserts all three halves, because any one alone is a false pass:
  1. the selection changes WHICH dots are on the map, by count AND by identity
  2. every surviving dot satisfies the level that was selected
  3. while a prayer filter is active the colour is a data-driven expression
     reading peoplePraying, not a flat literal

Assertion 3 is the one that matters: 1 and 2 were already green while the bug was
visible. Identity is checked as well as count because the filter removes from the
tail of an alphabetical list, so the first five slugs are byte-identical before
and after — a gate reading the head of the list would report "unchanged".

NEGATIVE-TESTED in four modes, each isolating one assertion, all verified caught:
  colour  reverts only the paint (the reported bug)    -> caught by assertion 3
  props   strips peoplePraying                          -> caught by assertion 2
  frozen  blocks the source update entirely             -> caught by assertion 1
  both    reverts the lot                               -> caught
A gate that cannot fail is worse than no gate.

Verified on both hosts against the BUILT tree: the cap-5 shell and the standalone
leaf. Browser gate, so not in build:unlocked.
2026-09-22 22:42:42 +00:00
doxa-maps-88655c70
34cbb39a5c Make the heartbeat-zone threshold a variable, not a constant
The choropleth's bin edges were a 5-stop literal (5/20/60/150/Infinity) in
DashboardMapPanel.vue. Those numbers are not a property of choropleths; they
are a property of THIS dataset — ~2000 people groups in focus. Pointed at a
200-group dataset, a 150 cutoff puts every country in the bottom bin and the
map renders uniformly pale: no crash, no error, correct code, meaningless map.
A dataset assumption wearing the costume of a constant.

The top band is the HEARTBEAT ZONE — the countries holding the most people
groups. Its edge now comes from dashboard.config.js as map.heartbeatThreshold,
alongside the other per-dataset facts (facets, columns, kpis). Re-pointing the
dashboard at a new dataset is a config edit, not a source edit.

Splits the old fused BINS array into the two independent facts it always was:
BIN_COLORS (measured against the navy pin, dataset-independent) and the edges
(pure dataset assumption). Fused, moving a threshold meant restating every hex
— silently reverting the contrast work — and restyling meant restating every
edge. Set either alone now.

Setting heartbeatThreshold alone rescales the three lower edges by their
original ratios, so the ramp keeps its shape (long tail of small counts, short
head of large ones) at any dataset size. Legend labels derive from the edges,
so a moved threshold cannot leave a stale "150+" on the legend. Opt-in
'quantile' mode derives the edges from the data actually loaded; it is NOT the
default, because 150 is a deliberate number for this dataset and a derived
default would silently change today's map.

Two bugs found and fixed while measuring:
- Edges could land at 1, creating a bin no count can enter (colorFor tests
  `count < upTo`): the ramp painted 4 of its 5 colours while the legend
  advertised 5. Appeared at threshold 40 and again in quantile mode.
- profileConfig merged shallowly, so a host passing the single key it cares
  about — {map:{heartbeatThreshold:40}} — replaced the whole map object and
  dropped mapStyle:'paper'. The one override a host is most likely to write
  was the one that broke the basemap. `map` now merges one level deep; every
  other key stays shallow.

No defineProps: the registered custom element is ProfileLoader, which renders
the profile with no props bound, so a prop declared on the profile would read
as configurable and never fire. The reachable attribute path is ProfileLoader's
existing profile-config JSON, verified end-to-end.

Default rendering is unchanged, verified by measurement rather than eyeball:
bin assignment over the 114-row committed fixture (59 countries) plus a 17-point
count sweep is identical before and after, with and without config supplied.

internal/check-heartbeat-threshold.mjs executes the resolver from the live SFC
and asserts the bins MOVE when the config moves — a grep for "150" would both
false-positive (it is the legitimate default) and false-negative (a
re-hard-coding at 200 sails through). Its --self-test re-hard-codes the ramp in
memory and requires the gate to reject it.
2026-09-22 22:25:38 +00:00
doxa-maps-88655c70
fa0b7d1099 Keep the development-era internal code name out of shipped files
The repo is handed to the Doxa team, and a code name from its build history
means nothing to an inheritor. Two places carried it, both regenerating:

  - public/.build.lock was TRACKED. internal/build-lock.sh stamps it with
    `by=$(whoami)` under flock on every build, so each rebuild committed the
    building machine's username. Scrubbing the string would have been useless;
    the file is pure runtime state nothing reads but the lock, so it is now
    ignored via public/.gitignore instead.
  - check-no-absolute-paths.mjs quoted a real home path in its header to
    explain the rule. The prose works the same with /home/<user>/.

check:no-codename makes it stick. It scans tracked files only (the set a clone
receives), names every offending file:line in one pass, and refuses to pass
vacuously on a short file list.

Two things it has to get right or it becomes noise. "10x" is ordinary English
for a multiplier and library/scripts/research uses it correctly, so only the
full two-part name matches. And the same letters appear as the unix username
inside /home/ paths, which is a portability defect check:no-abs-paths already
reports better — those are listed, not failed, so the two gates cannot
disagree about one string.

It carries no ALLOW entry for itself. A gate must never be its own witness,
and an exemption is a hole a real leak could hide in, so the pattern is
assembled from fragments at run time and the file scans itself on the same
terms as every other.

Negative-tested: planting the name in README.md fails naming README.md:2,
exit 1; removing it passes. All five spellings (LK10X, LK 10X, lk10x, LK-10X,
lk_10x) planted across five different shipped surfaces are reported together
in one run, exit 1; reverting returns exit 0.

Also quotes check:heartbeat into the CLI doc, which check:quoted-scripts
requires and which landed in package.json without it.
2026-09-22 22:18:18 +00:00
doxa-maps-88655c70
2ec6c00e68 Stop the map from swallowing the wheel, and grade the gesture the user makes
syncScrollZoomToHost decided whether Mapbox should keep scrollZoom by sampling
the layout at 0, 1200 and 4000ms and then never asking again. That is a race,
not a sync: the cap-5 dashboard tab lazy-mounts on activation, so at every one
of those instants the scrollport ancestor had not reached its final height. The
map kept scrollZoom enabled and ate the wheel. Wheeling over the map moved the
grid 300px; wheeling below it moved 2611px. The cursor is over the map, so the
people-groups grid under the dashboard was unreachable.

The condition is layout-dependent, so observe layout instead of guessing when it
settles: a ResizeObserver on the map container, the scrollport that owns the page
scroll, and body, plus a resize listener. Every event that can flip the answer now
re-asks it.

verify-shell-scroll already measured this. It computed movedByMap for the gesture
over the map, then asserted only on movedTotal, which sums in a second gesture
below the map -- so it reported PASS at 2611px while the real gesture moved 300px.
It now asserts on the number it was built to measure. Worth remembering as its own
failure family: a gate that measures the right thing and grades a different
variable, which fails honestly but never on the condition it exists for.
2026-09-22 22:06:59 +00:00
doxa-maps-88655c70
d4ab3a8e09 Anchor the gallery's assets to the tree, not to the page directory
Some checks are pending
build-gate / build (push) Waiting to run
The archived gallery resolved ASSETS as BASE + 'assets' — this PAGE's own
directory. That was correct only while the gallery WAS the root page, where the
two coincide. Since it moved to /__gallery it asked for /__gallery/assets/...
Measured on the built tree with the page at /__gallery/:

    /__gallery/assets/vendor/vue/vue.global.prod.js ... 404
    /doxa-maps/assets/vendor/vue/vue.global.prod.js ... 200

Every leaf page already agrees where assets live: a leaf at
/<tree>/<bundle>/<profile>/ bakes assetsBase "../../assets" -> /<tree>/assets.
So anchor to the tree root, which TREE_URL already names.

This degraded QUIETLY rather than blanking, which is why it outlived the move:
loadVue() falls back to the jsDelivr CDN, so the page kept working while
silently losing the portable / offline / no-CDN property the vendored copy
exists to provide. After the fix the built page loads Vue from
/doxa-maps/assets/vendor/vue/ and reports zero 4xx/5xx.

TREE_URL moves above ASSETS because ASSETS now depends on it; both are `var`,
so the old order would have yielded the string "undefined/assets" rather than
an error. Its explanatory comment moves with the declaration.

The dev server passes assetsBaseUrl explicitly, so this is the built-tree half
of the fix its comment already documents for dev.
2026-09-22 22:03:58 +00:00
doxa-maps-88655c70
04eccdfa7a Gate the scrim-covers-viewport rule, and say what was not verified
Some checks are pending
build-gate / build (push) Waiting to run
A `position:fixed; inset:0` scrim is only viewport-relative while nothing
establishes a containing block for fixed descendants. VerticalTabsLayout sets
`contain:layout` on .vtl-content, so inside the cap-5 studio every such scrim
covered the CONTENT PANE instead. Measured at a 1280x900 viewport:

    .dt-scrim / .modal-scrim / .lt-overlay ... 1024x793 @ (256,107)
    captured by div.vtl-content [contain:layout]

256 is the rail width, 107 the header height — left uncovered and CLICKABLE
behind what still looks like a modal. The fix (library/utils/viewportScrim.js)
measures the real origin and subtracts it, rather than removing the shell's
contain, which is load-bearing for a different reason.

This adds the gate that keeps it fixed. A source grep cannot: every one of these
scrims greps as a correct inset:0, because the variable is the ANCESTOR, not the
declaration — and which ancestors exist depends on the HOST, not the component.

check:scrim-viewport:self-test reverts each scrim to plain inset:0 and requires
EVERY case to fail; a gate that cannot fail is worse than no gate, and this one
measures a property that looks fine in a screenshot either way. The self-test
also surfaced that .lt-overlay is captured TWICE — div.lt-root [transform] as
well — collapsing it to 233x30 rather than merely to the pane.

SemanticTreeLegend's .stl-export-backdrop is fixed the same way but marked as
NOT verified through the UI: it sits behind SHOW_EXPORT_BUTTON = false, so the
menu that owns it cannot be opened. Correct-if-enabled, not observed.
2026-09-22 21:54:39 +00:00
doxa-maps-88655c70
ad6da54674 Checkpoint the swarm's in-flight work
Some checks are pending
build-gate / build (push) Waiting to run
Five lanes had 465 insertions across 18 files sitting uncommitted, some for
15 minutes. That is the state that silently lost the shell-scroll fix earlier
today: a verified fix existed only in the working tree, a second agent's edit
to the same file overwrote it, and the reverted version got committed and
described as the fix.

Nothing here is finished and no card is being closed on it. This is a
snapshot so the work survives an overwrite, taken at a moment when every file
had been quiet 15s or more, so no agent was mid-write.

Verified coherent before staging rather than committed blind: template-literal,
mjs-parse, sfc-parse, no-orphan-gates, kpi-unit, composite-reg, script-flags,
quoted-scripts and backlog-status all PASS. check:synced-css fails on
PeopleGroupDetail.vue and doxa-simple-map.vue only -- the same two files and
the same counts as before the swarm started, both untouched by any agent, both
already carded.

Two untracked files included because both are load-bearing: internal/
check-facet-one-field.mjs is wired into package.json with a self-test, and
library/utils/viewportScrim.js is imported by DataTable, SemanticTreeLegend
and PosterDialog -- a shared helper for the modal-scrim fix rather than six
copies of the same workaround.
2026-09-22 21:42:18 +00:00
doxa-maps-88655c70
f00abe1e6c Parse the build scripts before the build runs them
Some checks are pending
build-gate / build (push) Waiting to run
Two different agents broke the build the same way within ten minutes: a
backtick inside a // comment sitting inside a template literal of generated
browser JS. The backtick closes the literal and the parser reads prose as code,
so it fails hundreds of lines from the edit -- one break reported at :386 with
the cause in a comment above it.

check:template-literal did not catch either, and that is not a bug in it: it
scans backticks inside CSS comments, the shadow-CSS shape. A // comment inside
a generated-JS literal is a different grammar. Proven by controlled negative
test -- injecting a backticked term into a // comment in generate-tree.mjs, a
file that gate explicitly lists, left it reporting PASS while node --check
failed immediately. The rule was right; the implementation covered a narrower
case than the rule stated.

Nothing parsed the 70 .mjs build scripts. check:parse covers inline scripts in
built pages and check:sfc-parse covers .vue, so the scripts the build is made
of were the one unchecked surface.

Uses the parser rather than a wider regex. A backtick is legal inside a string,
a regex, a nested template, or a comment inside a template; a pattern precise
enough to catch the bad case would false-positive on the legal ones, and a gate
that cries wolf gets skipped -- worse than the gap. The parser already knows
all of that.

Parses, never imports: these scripts read files, spawn browsers and hit the
network at import time, and a syntax check must have no side effects.

Runs as build step 2, beside check:template-literal, so a broken script fails
in a second rather than after minutes of compilation.

The failure message points at the real cause rather than the reported line, and
tells the reader to grep for every instance in one pass -- node --check reports
only the first, and each rebuild to surface the next costs minutes, which is
how one of today's breaks was being fixed one at a time.

Negative-tested side by side: exit 1 with "SyntaxError: Unexpected identifier
'TREE'" at generate-tree.mjs:369, while check:template-literal reports PASS on
the same file in the same moment.
2026-09-22 21:33:32 +00:00
doxa-maps-88655c70
58ba402f87 Let smoke:gallery report what it can actually see
Some checks are pending
build-gate / build (push) Waiting to run
The gate could not pass on this box and it was not the product's fault. Three
defects, all in the gate:

Its default --url was localhost:5174 while `bun run dev` is `bun x vite`,
whose default is 5173, so the documented invocation failed with "is `bun x
vite` running?" against a dev server that WAS running.

It required a WebGL canvas on every map profile. This VPS has no GPU, so all
34 elements failed from an assertion the environment could never satisfy --
a gate that cannot pass, as useless as one that cannot fail, and it buried the
real finding underneath.

Removing that assertion was not enough: the map library still logs "Failed to
initialize WebGL", and every console error counted as a failure, so the same
environmental limit came back through another door.

Now 31 of 34 pass. GL noise is FILTERED, not suppressed -- the count prints on
every run ("3 WebGL page error(s) FILTERED — this box has no GPU"), because a
silent filter is indistinguishable from a gate that never looked, and this repo
has already shipped one of those. Anything that is not the GL-init message
still fails. EXPECT_CANVAS=1 on a machine with real GL filters nothing and
restores the canvas assertion.

Data 404s are now their own finding rather than a clause appended to the canvas
message, where they were only visible through a failure that was itself
environmental -- and where a map that painted fine would have hidden them.

That surfaced the real defect the gate exists to find: the archived /__gallery
page fetches a bare relative manifest.json, so it resolves against / instead of
/__gallery/. The studio at / is correct; it carries tree-base="/__plugin-gallery"
and smoke:studio passes in ~5s. Fixed separately.

Also corrects a usage comment that still said 5174 after the default changed --
the exact line someone copies verbatim.
2026-09-22 21:10:37 +00:00
doxa-maps-88655c70
b9a0cb040c Require a queryable status on every backlog card
Some checks are pending
build-gate / build (push) Waiting to run
Cards were being written with explanatory statuses -- "done — reconciled: …",
"blocked — Steps 1+3 DONE and re-verified…", "ready". The prose is valuable
and several of those strings carry measurements, but it made status
unqueryable: every count used status !== 'done', which is FALSE for
"done — reconciled: …", so finished cards kept counting as open.

Measured across the three phase backlogs: 130 of 395 cards carried a prose
status and 71 of those were already done. Reported open was 176 against a real
105. The P0 figure quoted every tick was 21; it was 14.

That is worse than an off-by-N because a backlog is a decision instrument. It
answers "what is left" and "is the rest human-gated", and inflating the open
count makes a drained board look stalled, which pushes toward manufacturing
work instead of holding honestly. The error also hides: every card reads
correctly to a human, because the prose says exactly what is true. Only the
aggregate is wrong.

Migration preserved every explanation in fields.status_detail -- 130 of 130,
none lost -- and normalised status to the enum. Six cards whose head-word was
not in the map were left alone and resolved by hand against reality rather than
guessed, since a wrong normalisation hides a card instead of counting it.

The gate does not require status_detail. Most cards need no explanation and
demanding one turns a real check into paperwork satisfied with filler.

Negative-tested: restoring one prose status fails by name and prints it.
2026-09-22 20:46:06 +00:00
doxa-maps-88655c70
e1baca1bbb Assert the cap-5 rail opens on the Big Picture, in the specified order
Some checks are pending
build-gate / build (push) Waiting to run
The Driver named the sequence: Big Picture, UPG-100, Prayer Progress, Gospel
Resources. Tab 1 is the landing surface -- the first thing anyone opening the
product SPA sees, and the opening step of the onboarding ramp. That ordering
was held only by the order of entries in NAV_MODEL and by comments explaining
why, with nothing failing if someone reordered them.

Easy to break silently: NAV_MODEL is also where sections, badges and
narrow-screen flags live, so it gets edited for reasons unrelated to ordering,
and a row moved while adding a badge produces no error and a SPA that still
works. It just opens somewhere else. That file's own comments record Big
Picture once being painted twice in the rail, from a one-row section that
re-declared a row below.

Asserts the RENDERED rail, not the source array. Parsing NAV_MODEL was the
first attempt and returned an empty list -- the entries do not carry the id
shape I assumed, and a regex over a 300-line commented array is guesswork
either way. The rail is what the user sees and what the Driver described, and
a source parse could pass while a rendering bug shows something else.

Order, not membership: checking that four names are present would pass on a
rail that opens on Gospel Resources. Prefix-matched so a row may carry its
subtitle.

Negative-tested: swapping the first two expected entries fails by name and
prints want versus got for every row.
2026-09-22 20:39:05 +00:00
doxa-maps-88655c70
9cae7fe16a Compare every silently-failing side effect, not just strategy keys
Some checks are pending
build-gate / build (push) Waiting to run
The gate caught one of the four things a baked composite must repeat. The
other three were left to luck, and one of them had already gone wrong: the
composite shipped mapbox-gl-rtl-text zero times while both leaf bundles
shipped it once, so Arabic, Hebrew and Persian labels rendered reversed in the
cap-5 shell. Arab World has 99 rows and Persian-Median 49, which makes that a
wrong name rather than a style issue.

Now compares createPinia, createAppI18n and installRtlTextPlugin alongside the
registerStrategies keys: whatever the leaf entry calls, the composite that
bakes its .vue must call too.

provide('profileModules') is deliberately excluded. It is the fifth side
effect and the only one that throws when missing, and it is also the only one
safe to omit, because a baked composite does not use ProfileLoader. Requiring
it would fail a composite that is correct. The rule is "repeat what silently
disappears", not "repeat everything".

Matched as calls -- name followed by "(" with the import line for that name
stripped first -- because an indexOf on the bare name reads the import instead
of the invocation, which is the trap that made the strategy parser return
nothing and report a confident empty result.

Negative-tested twice, and the first case reproduces the real historical bug:
removing installRtlTextPlugin() fails by name and prints both sides' lists;
removing createPinia() does the same. Pairs compared went 1 to 2.
2026-09-22 20:09:46 +00:00
doxa-maps-88655c70
7afbb9187c Assert a filter trigger keeps its width when an option is selected
Some checks are pending
build-gate / build (push) Waiting to run
.filter-dropdown was flex:1 1 160px with min-width:160px and max-width:240px
-- a range, not a width -- so flex-grow renegotiated the moment the selected
label appeared. The row grew 960 to 1294px and triggers reached 240px.

Not cosmetic. The selections row with Clear All sits underneath the filters,
so a filter that grows on selection re-flows the row below it and the band
stops being a fixed block. The stable width is what lets the centred stack
hold still.

Independently reproduced before gating rather than taken from the agent
report: 168px across all six triggers, identical after selecting the longest
option in each. The negative test then reproduced the broken widths
[240,240,240,184,171,219] -- the same array the original report cited, which
confirms that measurement was real.

Asserts the observable outcome, not the CSS. A grep for --dx-filter-w would
pass while a missing min-width:0 on the label still pushed the box open: as a
flex item without it the label is floored at min-content and refuses to
ellipsise. Both halves are needed, so the gate measures the width.

Selecting the LONGEST option is load-bearing. A short option fits inside the
old range too, so a probe that clicks the first option passes on a build that
still has the bug.

Two hosts, and an unmeasurable host fails rather than skips -- three defects
this week were invisible standalone and appeared only in the shell.

Negative-tested: restoring the flex range fails both hosts by name, naming the
before and after arrays and the total growth.
2026-09-22 19:46:49 +00:00
doxa-maps-88655c70
762953a054 Land the swarm's dashboard work: colour, rails, cascade, scroll
Some checks are pending
build-gate / build (push) Waiting to run
Sixteen source files from five agents, all verified against source gates
before commit: template-literal, sfc-parse, kpi-unit, composite-reg,
facet-cascade, one-palette, script-flags, no-public-binds, no-orphan-gates and
quoted-scripts all pass. public-hygiene reports 0 findings.

Colour. The choropleth ramp is now derived by interpolating the product's own
tokens (surface #f3f3f1 to accent #92b195) rather than adding a sixth grey, and
that fixes a pincer the earlier research had not named: on the old grey-to-black
ramp the navy dot and the white ring failed at OPPOSITE ends, so no fixed pin
colour could win. Worst-case dot contrast moves 1.13 to 7.29. Second-order
breaks fixed with it -- the country outline would have measured 1.08-1.82 as
white on the pale fill, so it is #6d766e at 2.59-4.34, and the legend swatch
border.

Religion colours no longer key by array index. colorFor takes no index at all;
colour comes from the registry by facet plus value. All 31 distinct live
religion labels now match the map, 0 mismatches. Tree-rail grey rows 142 to 0
against the live 2,107-row API.

Cascade. Clearing a parent facet now clears its descendants, in the selection
bus rather than in any component, because every clear path already funnels
through setFilter/clearFilters -- a component fix would have fixed one button
and left four. The hierarchy is derived from config.facetRail[].drill, so it
cannot drift from the tiers the rail renders. Place turned out to be three deep
(region, block, country), not two.

Rails. Tree rows wrapping 18/66 to 3/66; the tree rail is now the wider of the
two at 296 vs 264, with the map cell unchanged because both pairs sum to 560.

Also lands the shell scroll fix and the facet-cascade gate with its committed
fixture -- 73 sampled rows, no network, so the assertion that reproduces the
empty map runs on a fresh clone instead of silently skipping.
2026-09-22 18:57:39 +00:00
doxa-maps-88655c70
ca39d27088 Check a composite registers everything the leaf it bakes registers
Some checks are pending
build-gate / build (push) Waiting to run
In the cap-5 shell the Region donut, country bars and Place tree rendered
entirely grey while the identical build was correct standalone. A cap-4
composite imports the sibling component directly
(dashboard-plus-grid.vue:18), which takes the COMPONENT and skips
research-dashboard/index.js -- the file that actually assembles the bundle:
Pinia, i18n, colour strategies, customElements.define. The composite
re-declares all of it by hand, a manual copy nothing keeps in sync, which is
how the strategies leg was lost. That file already documented this bypass for
i18n and fixed only that one leg.

It is silent by construction: getColorStrategy() falls back to language-family
for an unregistered mode instead of throwing, so the result is a plausible
wrong picture -- grey charts that read as a data problem rather than a wiring
one. Found by reading an image; every number passed.

The gate compares the leaf's registerStrategies keys against the composite's.
A composite registering nothing while its leaf registers nothing is fine: the
rule is about drift between two lists meant to agree. big-picture also bakes a
sibling and registers zero strategies, and that is correct -- neither it nor
people-groups-grid references colorFor, getColorStrategy or mapbox. A first
pass called it a second instance; measuring usage showed it is not one.

Four rounds to get working, each a real bug found by testing the wrong half:

- pre-stripping comments deleted 2,953 of 4,102 characters of
  research-dashboard/index.js, including the call being searched for, so the
  gate claimed "no composite bakes a sibling that registers strategies" --
  a confident claim about the repo derived from parsing nothing. Not stripping
  is also correct: the call is located by requiring "(", so a mention in prose
  cannot match.
- indexOf('registerStrategies') found the IMPORT line first and brace-matched
  its own braces.
- a non-greedy brace regex stopped at the first inner "}" and the key pattern
  missed quoted path keys.

Every fix was verified against dashboard-plus-grid, which survives stripping,
and never against research-dashboard, which does not. A comparison needs both
halves asserted before any fix is believed.

Negative-tested: dropping doxa-region from the composite fails by name and
prints both lists.
2026-09-22 18:55:01 +00:00
doxa-maps-88655c70
fd41b4a207 Check that a component's two CSS copies agree
Some checks are pending
build-gate / build (push) Waiting to run
A shared component declares the same selectors twice: once in a scoped <style>
block for when it is imported as a Vue child, and once in a useShadowStyles
literal for when it is mounted inside a shadow root, where scoped CSS does not
reach. Both are load-bearing and the standing rule is that they stay
identical. Nothing enforced it.

library/components/PeopleGroupDetail.vue disagreed with itself on eleven
properties, and the shadow copy is a coherent DARK theme under a light-only
rule: .detail-header background white vs #16213e, .image-container #f5f5f5 vs
#2d2d4e, and .people-group-name color #333 vs #fff -- white text, legible only
on the dark background, so fixing the background alone would make the name
invisible. Six importers; the dark hex reached four shipped bundles.

It was invisible because it is a two-host bug: the component looks correct in
whichever host you tested. Fourth of that shape this week.

Three findings while building this, each of which had the gate reporting
success about something it never compared:

- a loose /<style[^>]*>/ matched the words "<style scoped>" inside a JS
  comment 43 characters before useShadowStyles( in SemanticTreeLegend.vue, then
  ran to the next real </style> and swallowed the whole shadow literal into the
  <style> text. Both maps read the same bytes, so that file could not fail on
  any of its 51 apparent shared selectors. Anchored to line start, since an SFC
  block starts at column 0. That also raised PeopleGroupDetail 8 -> 11.
- assigning m[sel] = props let the last declaration win, discarding every
  property that appeared only in a base rule when a media query redeclared the
  selector. Rules are merged now.
- comparing raw text reported comma-spacing as divergence: 5 findings for
  doxa-simple-map of which 2 were formatting. Compared as property maps, so the
  rule is about meaning.

Negative-tested by change rather than by exit code, which with pre-existing
failures proves nothing: fixing .detail-header takes PeopleGroupDetail 11 -> 10
and restoring it returns 11.

Standalone rather than in build:unlocked, because it fails on live defects
today.
2026-09-22 18:40:55 +00:00