Commit graph

426 commits

Author SHA1 Message Date
doxa-maps-88655c70
b59fb66cc9 Add Regions as the third Dashboards row, completing the seven
Some checks are pending
build-gate / build (push) Waiting to run
The Driver named seven plugins across three sections. Six shipped; Regions was
the gap.

  Dashboards          Big Picture · Simple Research · Regions
  Prayer Mobilization UPG-100 List · Prayer Progress
  Gospel Research     Cities with Internet · Gospel Resources

What ships today is tab 1 of 2. regions-dashboard currently builds a single
profile, regions-list -- the WAGF region list refactored from DX-life's /regions
page, which emits region-selected / country-selected. The scrollable
region/country detail dashboard those events will drive lands as an ADDITIONAL
profile in the same bundle, and this row needs no edit when it does, because the
row names the BUNDLE and the shell resolves the profile from the manifest.

Listed now rather than held back: a working list that reaches the rail is worth
more than a complete feature nobody can open.

Verified in a browser on the built tree at 1440x900, recursive shadow-DOM walk:
four rail sections render as Dashboards / Prayer Mobilization / Gospel Research
/ Experimental; the seven curated rows appear in the Driver's order with Regions
third; 14 tabs total, 14 unique, zero duplicates, zero console errors.
check:sri PASS, check:settled PASS before and after.
2026-09-23 15:43:43 +00:00
doxa-maps-88655c70
10fdda338b Rebuild the tree so every leaf's baked SRI matches its bundle
Some checks are pending
build-gate / build (push) Waiting to run
A single-bundle build regenerates the JS but not the leaf pages, so four
big-picture leaves carried a stale sha384 and the browser refused to execute the
script at all. That is why a nav probe measured 0 sections on a shell whose
source was correct, and it is the likeliest cause of a teammate's 'pggMounted:
false on big-picture everywhere' reading.

check:sri named both the cause and the fix; ran the full build it prescribes on
a settled tree. Now PASS: 44 script references across 45 leaf pages, every
leaf's integrity matching the bundle it loads.
2026-09-23 15:37:18 +00:00
doxa-maps-88655c70
3b08fb5962 Stop Big Picture showing the same total twice
The composite's own header renders "<N> shown" from @groups-filtered, and the
nested grid rendered its own "Total: <N>" line 139px below it. Measured live:
"2107 shown" and "Total: 2,107" — the same number twice, both above the fold.

That is the redundant-header shape the standing rule forbids (the hint lives in
the EXISTING header, never a second one). show-total=false here matches the
pattern already applied in dashboard-plus-grid.vue:279 for the same reason.

The grid keeps its own total when embedded ALONE, where it is the only count on
screen — the prop is host-controlled, not removed.

Work started by a delegated agent that hit its session limit mid-task; verified
the cited sibling pattern exists before committing. check:sfc-parse and
check:template-literal pass.
2026-09-23 15:32:28 +00:00
doxa-maps-88655c70
4c74bd717e Restore three-section tab structure: Dashboards / Prayer Mobilization / Gospel Research
Big Picture now lives in its own Dashboards section alongside a renamed Simple
Research (was Research Map, recategorised as the mobile-default dashboard).
Prayer Mobilization narrows to UPG-100 List + Prayer Progress; the renamed
Gospel Research section takes Cities with Internet + Gospel Resources.

The device rule changes shape to match: every row now stays in the rail on
every width (nothing is hidden any more), Big Picture is disabled rather than
removed on mobile with a "desktop only" badge and an explanatory toast on
click, and the auto-selected tab swaps to Simple Research on mobile / Big
Picture on desktop.

Added a loud, module-load-time assertion that no tab id is declared twice
across the curated sections, on top of the existing silent runtime dedupe --
this is the gate the duplicate-key regression needed and didn't have.
2026-09-23 15:21:33 +00:00
doxa-maps-88655c70
c48d1d401a Make the legend show the colour the map actually paints
Some checks are pending
build-gate / build (push) Waiting to run
The swatch rendered the raw bin hex. The map never paints that colour: the
choropleth composites at fill-opacity 0.78 over Positron's #f2f3f0, so every
swatch showed a colour that appears nowhere on the map.

Measured, chip vs actual fill:

    #f4cec4 -> #f4d6ce   dE  4.07
    #ecaea1 -> #edbdb2   dE  7.53
    #e08678 -> #e49e92   dE 11.93
    #ce544b -> #d6776f   dE 17.13
    #b10010 -> #bf3541   dE 21.14

The error grows with the bin, and at the top it is the size of a whole bin step:
the gap between the top two bins is 25.45 as painted, while the legend's own
error on the heartbeat bin is 21.14. A reader comparing the two darkest swatches
was comparing two colours neither of which was on the map -- a strong candidate
for the report that "60-149 red is the same red as the heartbeat zone",
independent of the ramp itself.

Composited in the legend rather than dropping the map's opacity: 0.78 is
deliberate, it lets country borders and labels read through the choropleth, and
this file's own contrast notes are measured at 0.78. The legend was the half
that was lying.

Verified: all five bins produce the measured target values, and non-hex and null
inputs pass through untouched. check:one-palette, check:synced-css,
check:template-literal, check:sfc-parse all pass.
2026-09-23 15:20:30 +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
b25db86985 Tell the reader the lock does not protect them
The build lock protects a second WRITER. It does not protect a READER, and
nothing in the docs said so. Every build starts with `bun run clean`, which
rm -rf's public/doxa-maps, so anything reading the tree mid-build gets a
confident wrong answer: "that bundle is missing", "the dashboard tab does not
exist", "461 files were deleted". Each of those has actually happened here, and
one nearly produced a bug report against a correct change.

check:settled already existed and was already wired in package.json. It was
mentioned in zero markdown files, which is the whole gap: the primitive was
built and never reached the people who needed it. Documented now in the one
place an inheritor reads, with the instruction to grade it by exit code --
like every gate here it prints teaching prose after its verdict, so `| tail -1`
inverts the signal.

Also recorded why a pgrep check is NOT a substitute, because that is the form
the standing brief prescribes and it is the weaker instrument. A process list
samples an instant; the lock knows the queue. Between one queued build finishing
and the next acquiring the lock there is a real window where zero build
processes exist and the tree is about to be wiped -- and a long quiet period
makes that window look more convincing, not less, since the queue is idle
precisely because it is between runs. Measured today: pgrep reported 0 builders
while the gate reported builders=1 waiters=1, and ps confirmed a build had
started 17 seconds earlier.

Verified by execution: check:settled exits 1 with builders=1 waiters=1
churn(45s)=456 while a build runs; check:doc-scripts passes, so every command
quoted here exists in package.json.
2026-09-23 14:46:11 +00:00
doxa-maps-88655c70
1a782109c3 Give the shell pane a scroll, and drop the engagement filter
TWO CHANGES, both Driver-reported.

1. THE SHELL WOULD NOT SCROLL, so the grid under the Big Picture dashboard was
unreachable. Measured before: window.scrollY stayed 0 and .vtl-content reported
scrollTop 0 against max 4155 -- it HELD 4155px of content and would not move; 0
grid cards reachable. The same composite on its own leaf page scrolled fine
(scrollY 3712, 9 cards), so this was a HOST defect, not broken interop.

Cause: every mounted tab got position:absolute;inset:0;height:100%
unconditionally. That is right for a fixed-viewport map, which fills the pane
exactly. It traps a StackLayout composite -- dashboard-plus-grid and
research-map-plus-grid are natural-height by design, and pinning them to inset:0
forces ~2600px of real content back into one viewport with nowhere to go, while
BOTH the element and its container were absolutely positioned with no scroller.

Fix follows the bounded-middle shape already proven here: the PANE stays bounded
and gains overflow-y:auto (plus overscroll-behavior:contain so a bottomed-out
wheel does not chain to the document); .dms-slot becomes position:relative with
min-height:100% so it can grow past the pane. Removing the pane's bounds instead
was measured previously and produced a worse defect -- the grid landed far below
the fold.

Verified in the shell on the built tree: pane overflow-y auto, scrollTop 0 ->
4155 of a 4155 max, grid cards visible 0 -> 8, .rd-grid present, 0 failed
requests. Read the screenshot as well as the numbers: the people-groups grid
renders with photos, country and affinity-bloc labels, Full-profile buttons and
Load more. Fixed-viewport map tabs are unaffected -- they fill the pane exactly,
so scrollHeight equals clientHeight and overflow:auto paints no scrollbar.

2. ENGAGEMENT AND STATUS FILTERS REMOVED on instruction. engagement_status is
gone from the dropdown row (seven remain, all fitting the first row on the
measured 178px pitch) and the Status section is gone from the facet rail. The
facet accessor, the table column and the FACET_TO_PROP mapping all stay, so the
grid can still filter on it and nothing downstream breaks -- only the two
offered controls are withdrawn.

Gospel Access stays. It is measurably a different question: over 2,108 live rows
the two fields disagree on 25 -- 12 groups are Engaged at gsec rung 1 and 13 are
Unengaged at rung 2.

Gates: sfc-parse, template-literal, one-palette, synced-css, facet-cascade,
facet-one-field all pass.
2026-09-23 14:41:58 +00:00
doxa-maps-88655c70
f7cee9640e Add regions-list element: tab 1 of the Regions Dashboard plugin
Scrollable list of WAGF regions and their countries, refactored from the
DX-life /regions page into a standalone custom element. Clicking a region
or country emits a selection event instead of navigating, so a host shell
can drive a second tab from it.
2026-09-23 14:24:04 +00:00
doxa-maps-88655c70
c27d16cd4b Show the film link the list already had, for 218 languages that showed nothing
Some checks are pending
build-gate / build (push) Waiting to run
resources-list rendered links only from `resources[]`, which holds SCRIPTURE
editions (eBible entries with ot_books/nt_books/redistributable). The JESUS Film
lives in its own fields -- has_film/jesus_film plus film_url -- and was never
read. Measured over all 862 languages in resources.json:

    resources[] non-empty ............ 120
    film_url present ................. 306
    film_url but resources[] EMPTY ... 218   <- a real link the UI never offered

So 218 languages displayed no link at all while carrying a working one. Arabic,
Sudanese (apd, 66 people groups, 18.7M speakers) has
https://www.jesusfilm.org/watch/?languageId=13562 and rendered nothing.

The pill is deliberately left alone. It reads full_bible/has_scripture, so
"none found" is TRUE -- of scripture, which those 218 genuinely lack. Widening
it to mean "no resources at all" would make it say something false about a
language that has a film. The row now says both things: the pill reports the
scripture gap, the links report what exists.

Film links are marked (muted, with a leading marker) rather than styled as
another Bible, because a film is a real resource but not a text and this list is
about text availability first. Guarded on film_url itself, not on the boolean
flags: a truthy flag with no URL would render an anchor to nowhere.

Verified in a browser on the built tree: default "No scripture found" tab now
renders 218 film anchors where it previously rendered zero, all 218 on rows
whose pill reads "none found"; the sample href matches the apd row in the data.
"Has scripture" shows 120 rows with 155 scripture anchors plus 88 film; "All
languages" 862 rows, 306 film -- matching the pre-change data counts exactly.
0 failed requests.
2026-09-23 13:58:18 +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
8b3d638de7 Correct two measured claims in the filter-row note
Both were assumptions written as measurements, and one of them kept a P0 card
shut.

(1) "GSEC is the underlying field ... engagement_status is the label derived
from it." They are independent fields. Over 2,108 live rows they disagree: 12
groups are "Engaged" at gsec rung 1, and 13 are "Unengaged" at rung 2. Because
the note claimed the dropdown already covered "the phases of engagement", the
phases looked built when they were not.

(2) "168px on a 200px pitch, so eight occupy 1600px and MUST wrap." The pitch
was never measured. It is 178px: seven dropdowns fit the first row ending at
x=1250 and the eighth wraps, at both 1440 and 1280, with no horizontal
scrollbar. The conclusion held; the arithmetic behind it did not, which would
have misled the next author sizing this row.

Gospel Access stays out of the dropdown list because the Driver named eight, not
because a ninth would overflow -- the layout would tolerate one. A five-rung
ordinal reads better in the tree rail regardless.

Comments only; no behaviour change.
2026-09-23 13:24:06 +00:00
doxa-maps-88655c70
1449d47573 Add the Gospel Access filter, the ladder the binary flag was flattening
The Driver asked for "the phases of engagement, measured in the live data."
engagement_status cannot express them: over 2,108 live rows it is 2,081
Unengaged / 27 Engaged, so filtering on it separates 1.3% of the corpus and
tells a researcher almost nothing about where a group actually sits.

imb_gsec is the ordinal ladder underneath that flag, present on 2,106/2,108:

    0   No Evangelicals, Churches or Resources ........... 225
    1   Less than 2% Evangelical, No Active CP Activity . 1,853
    2   Less than 2% Evangelical, Concentrated CP ......... 23
    4   2% or Greater but Less than 5% Evangelical ......... 4
    6   10% or Greater Evangelical ......................... 1

Cross-tabbed against the flag, ALL 225 groups with no evangelicals, churches or
resources -- the most lost category in the data -- are merely "Unengaged",
indistinguishable from the 1,841 groups that have some evangelical presence.
That distinction is the one the map exists to show.

Verified safe to filter on before wiring, the same way cluster/people_group
were: 5 distinct {value,label} pairs and NO value-vs-label cross-collision, so
valueMatches() selects the same rows whichever vocabulary arrives. It is a real
row field, unlike the derived language_family/religion_family facets, so it is
mapped in FACET_TO_PROP from the day it lands rather than joining the
dashboard-only class.

Wired through every layer that would otherwise silently drop it: the shared
sources.json defaultFields (the dashboard reads rows through that seam), the
grid's own fetch field list, the prop, the API-field-name filter key (imb_gsec,
not gospelAccess), the re-filter watch list, and the composite's prop binding.

Verified in a browser on the built tree with live data: the section renders all
five rungs with counts matching the API exactly; clicking "No Evangelicals"
takes dashboard scope 2,108 -> 225 and the grid to "Showing 225"; clicking
"Concentrated CP" gives 23 and "Showing 23"; deselecting returns both to
baseline. 0 failed requests.

Gates: facet-one-field, facet-cascade, facet-changes-map, csv-shape,
label-joins, one-palette, sfc-parse, parse all pass. verify:gate1 still 13/13
against 2,108 live records.
2026-09-23 13:19:22 +00:00
doxa-maps-88655c70
9076ec1d61 Mark the two dashboard-only facets, so they stop looking like broken buttons
language_family and religion_family are clickable, they highlight, and they
narrow every chart above them -- and they cannot move the grid below. Measured
2026-09-23: dashboard 2107 -> 677/306/222/151 and 2107 -> 693/649/610/83, grid
unchanged every time. To a user that is indistinguishable from a dead control;
the only way to learn it was to click and squint.

Wiring them is not the fix and would be worse. Both values are DERIVED -- a
language family resolved via langFamilyByIso for ~96% of rows, a religion family
computed from the first letter of a religion code -- so they exist on no row
field. Mapping religion_family onto `religion` would compare "Hinduism" against
a CODE and filter the grid to zero.

So the honest fix is a label, not a wire. A facet now declares
`dashboardOnly: true` in dashboard.config.js; DashboardFacetRail reads that from
config rather than hardcoding facet names, and renders a muted half-circle plus
a title and an extended aria-label saying the row updates the charts only.

These rows stay fully clickable, deliberately unlike `zero` and `other` rows,
which are inert because they would do nothing at all. Dimming them the same way
would understate what they really do.

Verified in a browser on the built tree: 41 of 66 rows marked, 41 glyphs
rendered, marked facets are exactly language_family and religion_family and
nothing else, 40 still clickable (the one that is not is a zero row that
correctly keeps its mark). Behaviour unchanged -- a marked row still takes the
dashboard 2,108 -> 677, an unmarked row still filters at 548.

Gates: facet-cascade, facet-one-field, facet-changes-map, one-palette,
synced-css, composite-srcs, template-literal, sfc-parse all pass;
a11y-button-names passes at 769/769 buttons named.
2026-09-23 13:04:52 +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
bd2a77e82d Resolve the shared asset base per bundle, so nested tabs stop 404ing
Eleven profiles across six bundles each carried the same line:

    profileConfig?.assetsBase || './assets'

That default is right on a standalone leaf page, where the generated host passes
'../../assets' and the tree root is two segments up. It is wrong the moment the
element is nested in the cap-5 shell, because a relative path resolves against
the SHELL's page, not the child's. Measured in a browser on the built tree:

    leaf  /doxa-maps/gospel-resources/resources-map/index.html ... 0 failed
    shell /doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html ...... 404 assets/tables/gospel-by-country.json
                                                                   404 assets/boundaries/world-adm0.geojson

No build-time gate could see it: './assets' is a valid string in both hosts, and
only the host decides what it resolves to. check-composite-srcs proves a nested
src exists on disk, which is a different question.

resolveAssetsBase() in library/utils/embedBase.js now derives an absolute base
from the bundle's own script URL (or the treeBase the shell already forwards),
and deliberately REJECTS a relative assetsBase from config -- honouring it would
reintroduce the defect through the config meant to cure it.

The fix is in the child, not the shell, so a new bundle that fetches assets
works nested on day one with no edit to doxa-maps-spa.vue.

Verified: shell 404s 2 -> 0 on Gospel Resources; Cities & Internet, UPG-100 and
Big Picture also 0 asset 404s; leaf hosts unchanged at 0.
2026-09-23 12:14:52 +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
3a7913df64 Record why the table generator keys by ISO code and never by name
Extending last tick's external-source check from legends to the baked tables
turned up a real discrepancy: gaps-and-opportunities.json has 2,106 rows against
a 2,107-row corpus, with five name mismatches. The table says "Jerba" and
"Tmagourt" where the live API now says "Amazigh of Djerba" and "Amazigh,
Matmata" — the same Tunisian peoples, relabelled upstream since the snapshot.

It is harmless, and the reason is the point. Nothing joins on the name: the
generator keys everything by iso639_3, and the consumer does the same at
resources-map.vue:194, whose comment already states the rule and its stake —
"Never a name match: language names collide and drift ... a fuzzy hit here would
put the wrong scripture in someone's hands."

The generator followed that rule in four places and never said why. So the
decision lived only in the consumer and in my head, and a future author tidying
this file toward a name join would find nothing to stop them. They would
silently drop three groups and orphan two rows: no error, no empty state, just
fewer people on a map about people nobody has reached. That is precisely what
check-label-joins.mjs was written to prevent.

Verified rather than asserted before writing it down: the join key is clean on
every row — heart_iso639_3 present and well-formed on all 2,106, zero blank,
zero malformed, 862 distinct codes; bridge_iso639_3 blank on 461, which is
expected because not every group has a bridge language. gospel-resources/
data-tables renders 50 rows by 21 cells with zero console errors and zero
responses >= 400. Both citations in the new comment were checked against the
files they name.

Comment only; no behaviour change. Five gates pass, including check:label-joins
itself and check:legend-source from last tick.
2026-09-23 11:06: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
546916c41e Say which population the studio's count describes
The Plugin Studio header read "18 bundles · 27 elements" while the manifest
holds 19 and 28. The arithmetic was always right: rowsFromManifest skips the
studio itself, because it cannot be one of its own tabs, and 19-1 / 28-1 is
exactly what the header showed.

The problem was that nothing said so. The golden-path self-test — linked from
the golden-path doc as of this flight — reports 19 bundles and 28 profiles from
the same manifest. Two authoritative surfaces disagreeing by one, neither
explaining why, is how a reader stops trusting both; and the studio is the first
thing an admin agent opens, so a number that looks wrong there discredits
everything beside it.

"listed here" is the whole change. It names the population, so the difference
from the catalog total reads as a deliberate exclusion rather than an off-by-one.
The count stays derived from this.rows, which is the honest source — it is a
statement about what the studio shows, and now it says that.

Verified the exclusion accounts for the gap exactly before touching anything:
manifest 19/28, minus the single plugin-studio bundle and its one profile, is
18/27. Then read the rendered header at 1600px — 220px wide, sitting beside the
language selector without crowding it — and confirmed it in the regenerated
screenshot.

Nine gates pass, including check:synced-css: there are two .ps-count rules in
this file, the synced copies that must stay byte-identical, and this change is
JS rather than CSS so both are untouched.

Same shape as the self-test scoping documented earlier today: a number that is
correct for the question it answers, with nothing saying which question that is.
2026-09-23 10:49:06 +00:00
doxa-maps-88655c70
7547abb109 Keep the poster-wall header pill on one line whatever the count
The wall's header pill reads "<n> UPG · Show on map" and is a fixed 82% of the
card. A three-digit count pushed it over that width and it wrapped, so "116 UPG"
cards rendered a two-line pill beside one-line "92 UPG" cards in the same row.
Measured at 1440px on the shipped wall: the same element came out h=34 for
three-digit counts and h=22 for two-digit ones, a 12px step that reads as two
different components sitting side by side.

One property, and the correct value was already three lines away: the sibling
footer pill (.tape-l) carries white-space:nowrap and .tape-h did not. So this was
an inconsistency inside one component rather than a design question. The
.hcount rule sets font-variant-numeric:tabular-nums, which says the author
already wanted digit width not to move things — wrapping undid that intent.

Now h=22 on every card regardless of count, confirmed in the regenerated
screenshot: the three-digit pills in the India and Africa rows sit on one line
like the rest.

Found by looking at the shipped screenshot while verifying
BUG-UPG100-POSTER-PRINT-DIALOG, which the standing brief asks to check visually
and which had closed with verification_kind "state" and no recorded evidence.
The poster wall itself is sound — real posters, named people groups, PRINT
OPTIONS on every card.

Nine gates pass, including template-literal, which matters because this CSS
lives in a JS string and a backtick in a comment there has broken the build
twice before.

Not fixed, and not a product defect: the "Print poster" labels show a tofu box
on this machine. check:shot-fidelity already names these six bundles — the
markup carries the right codepoint and the shot box has no emoji font. The fix
is a font here, never removing emoji from the product.
2026-09-23 10:35:24 +00:00
doxa-maps-88655c70
bc1b035f6d Point the golden-path doc at the self-test of the golden path
golden-path-check is the capability-1 element that IS the
develop-a-plugin-and-see-it-live walk: authored from the documented 3-file
minimum, built, published, and then reading the manifest it shipped beside. It
was named in two reference docs and NOT in the golden-path document itself, so
the one artifact that proves the path worked was missing from the page that
teaches the path.

Step 5 ended with "Done = the live site shows it, not a green build", which is
the right instruction and leaves the reader with no reference point — they can
only check their own plugin, and if it fails they cannot tell a mistake in their
plugin from a broken platform. The self-test separates those two questions.

Verified on the live catalog before writing it down, as an inheritor would:
HTTP 200, zero console errors, and it reports 19 bundles, 28 profiles, 28 of 28
self-describing, this plugin listed = yes. Cross-checked by two independent
measurements rather than trusting the element — the live bundle is
byte-identical to local at 73,693 bytes, and fetching the live manifest directly
gives the same 19 and 28 from a different path.

The note also states what the self-test does NOT claim, because that is the way
it could mislead. Its four rows are questions about the catalog's SHAPE, so it
stays green even when bundle CONTENT is stale — my own fixes this flight changed
bytes and not counts, and it reports identically on both trees. Freshness is
check:catalog-currency's job, which compares every live bundle's bytes against
the build. The doc now tells the reader to run both and says which answers what.

The URL printed in the doc was fetched before committing: 200. Three separate
gates this flight were failing because they navigated to a URL nobody had
checked, and a doc that sends a reader to a 404 is the same defect with a slower
feedback loop.
2026-09-23 10:19:40 +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
2fdfee37ac Start the wrapped filter line on the same edge as the one above it
The dashboard ships eight filter dropdowns. Seven fit on the first line and
Status wraps to a second — centred, not aligned with Prayer above it. A lone
centred control under a left-aligned row does not read as a layout decision, it
reads as a mistake.

Measured on the shipped dashboard at three viewports, line-2 left edge against
line-1: 1440px 36 vs 636, six hundred pixels adrift; 1280px 16 vs 556; 1024px
33 vs 132. Never aligned at any width.

The cause is `justify-content: space-evenly`, which I kept when wrapping landed
here earlier today. The comment defending it is about a SCROLLER — a single line
that could push its first item to a negative offset, which `safe` rescues — and
that reasoning does not survive wrapping, because space-evenly distributes EACH
LINE independently. Seven items distribute across the width; one item lands in
the middle.

flex-start keeps every line on one edge at any width. The `safe` keyword goes
with it: it exists to rescue centred and space-* distributions from negative
offsets, and flex-start cannot produce one, so keeping it would preserve a
guard against a hazard the change removes.

Now 14 / 14 / 14 at all three viewports, matching the row's own left edge, and
the regenerated screenshot shows Status sitting directly beneath Prayer.

Blast radius checked before editing, since this is the high-bar library zone:
SearchFilterHeader is consumed by research-dashboard only. check:synced-css
passes, so the two CSS copies remain byte-identical. Nine gates pass and
verify:gate1 is unchanged at 13/13 against 2,107 live records.

Separately noted, not fixed here: check:filter-width fails on HEAD too —
verified by stashing this edit and re-running. It reports "UNMEASURED: no
.filter-dropdown__trigger found in the composed tree", and that class DOES exist
(FilterDropdown.vue:278), so the selector is right and the gate is not reaching
the page — the same stale-navigation family as the tab-order gate fixed earlier.
Left for its own commit rather than bundled into a styling change.
2026-09-23 09:52:58 +00:00
doxa-maps-88655c70
94b356d2b3 Tell the user when the data source is down, not that their filter matched nothing
people-groups-grid caught a failed fetch, logged to the console and cleared its
loading flag. The template's `v-else-if="!displayedUUPGs.length"` branch then
rendered "No people groups match this filter." So when pray.doxa.life was
unreachable, the element told the reader their FILTER was wrong.

Measured by blocking that host in a browser: two console errors logged, and the
only thing on screen was the ordinary empty state. A user adjusts filters that
were never the problem; an operator glancing at the page sees normal operation.
The console held the truth and nobody opens it — least of all on a partner site
or inside the cap-4/cap-5 composites, which is exactly what this element is for.

Now distinguishes the two. A `loadFailed` ref drives its own branch: "People-group
data could not be loaded. This is a connection problem, not your filter."

Also checks response.ok, which was a second silent path. fetch() only rejects on
a NETWORK failure, so a 404, a 500 or an HTML error page all resolve — and
response.json() on them either throws into the same silent catch or yields an
object with no `posts`, which sets an empty grid and reports success. Both were
indistinguishable from "nothing matched".

Styling adds no colour: the palette is material-light only and --mut is what
this component already uses, so the failure line is separated by weight and a
hairline rather than an alarm state the design language does not have.

Verified both directions. Host blocked: the new message renders, no cards.
Host reachable: no message, the grid renders. And `bun run verify:gate1` still
passes 13/13 against 2,107 live records, including its "nonsense query -> 0
(empty-state path)" case — so genuine zero-match still works and is now a
different thing from a failed load.

Found by reading EXPLORE-dxlife-people-groups-refactor.md (2026-09-18) in the
staleness scan. Its PLAN is superseded — steps 1-5 describe editing DX-life's
own UupgsList.vue, which the standing law forbids and which this flight solved
by cloning instead. But its RISK register was still testable, and risk 3 named
this exactly: "external data dep on pray.doxa.life/api/people-groups/list". Half
of that was already mitigated — prayBaseUrl is injected three ways — and this
was the unmitigated half. Risk 6 ("FIVE dropdowns not four, confirm with the
Driver") is also settled: four are rendered.

A doc can be stale as a plan and still carry a live finding.
2026-09-23 09:31:10 +00:00
doxa-maps-88655c70
159acac940 Point the app-tables guard at pages that exist, and let it count rows it can see
verify:full runs two browser guards that `bun run verify` skips, and both were
failing. Neither was a product defect; both had rotted unnoticed precisely
because verify-all.mjs marks them `browser: true` and skips them unless --full is
passed, so the build chain has never run them.

verify-app-tables was broken four ways, each of which alone made it useless:

Its default port was 8917, which nothing in this repo has ever bound. Now 4173,
the port `bun run serve` binds, so it runs with the same server as every other
browser gate. APP_BASE still overrides.

It navigated to `index.html#key-city-posters` and two sibling fragments,
resolved against the PROFILES object in layouts/app-wrapper-material. That shell
is not what `/` serves — as of 2026-09-22 the root page mounts <plugin-studio>,
and the built root contains ZERO of those three keys. The list is now three real
profile paths taken from manifest.json. key-city-posters is dropped rather than
renamed to something that merely looks similar: no such profile ships.

Its "are we on our page" check matched the shell title "Doxa Maps". A leaf
titles itself "<profile> · <bundle>". My first replacement matched an absolute
script path and was equally wrong, because a leaf references its bundle
relatively (src="../gospel-resources.js") — measured, on a page that was fine.
It now matches the SRI integrity attribute, which this build guarantees by
construction and no other server emits.

Its row counter only counted rows ONE level inside a shadow host, with no seen
set. A row in the light DOM, or one shadow level deeper, counted zero. Now walks
every depth.

The last defect is the one worth reading twice. The app header printed near the
END of the loop body, after the row check, so every verdict appeared ABOVE the
app it described and the whole report was offset by one. That made a PASSING app
(gospel-resources/data-tables, 40 rows) look like the failure and the real
failure look like it had rendered rows. I chased the wrong app through three
rounds of instrumentation before reading the output in order. A report that is
correct but misordered costs a wrong diagnosis, not just time.

Read correctly, the guard now says: cities-internet/data-tables 40 rows and 1
table fetched; gospel-resources/data-tables 40 rows and 1 table fetched; and
gospel-resources/resources-list fetches NOTHING and renders nothing. That last
one is the already-carded gospel-resources data gap, waiting on the three Driver
decisions — the component is built and correctly showing an empty result for
data it was never given. So the guard is reporting one real, known, Driver-gated
absence, which is what it is for.

Everything else in it was already sound and is untouched: the recursive
shadow-root walk for row counting, fail-closed on zero fetches, and third-party
request detection, which the file rightly calls a persecution risk rather than a
performance note.
2026-09-23 09:19:20 +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
4c8f6b4a6d Share the mobile composite too, and make the url absolute so the embed works
research-map-plus-grid had the same defect fixed yesterday on its desktop
sibling: sharing the composite emitted a link to doxa-research-map, the child it
nests. The gate said it exactly — "share URL reopened <doxa-research-map>, not
<research-map-plus-grid>" — and a recipient got the map alone, losing the grid.

Checked all four cap-4 composites rather than assuming the fix generalised.
my-upg-100-list owns its Share button directly and is fine. big-picture composes
a header plus the grid, owns no map and ships no Share button at all; the gate
reports "window.mapboxgl UNDEFINED" against it, which is its map precondition,
not a share failure — that bundle is out of this gate's scope and I put it in
the probe by mistake. So one real instance, now fixed.

Tier 1 again, for the same reason: this child also hardcodes a prop
(research-map.vue:2149 `:embed-path="'doxa-research-map/research-map/index.html'"`)
and the ladder is `embedPath || cfg?.shareEmbedPath`, so a prop outranks host
config and tier 2 is unreachable. Delivered through the profile-config attribute
rather than Vue provide, because this composite mounts its children as real
custom elements via src — there is no provide chain to reach them.

The url had to be ABSOLUTE, and the first version was not. resolveShareUrl
returns a tier-1 shareUrl verbatim (embedBase.js:95, `return whole.trim()`, no
resolution), and ShareButton feeds the same string into its embed snippet:
<iframe src="{shareUrl}">. A relative '../../…' would have resolved correctly in
the share LINK, because the browser resolves it against this page, and then
404'd in the EMBED, because it resolves against the partner's page instead. That
would have passed the gate — the gate navigates the link — while breaking the
deployment method the link exists for. Tier 1 is documented as "a WHOLE url" for
precisely this reason. Now computed with resolveEmbedBase() so it stays correct
on doxa.life, on nuxtbase.app and in a dropped tree.

Verified the desktop sibling did not have the same latent flaw: it produces a
fully absolute url, confirmed in the live gate output.

smoke:share-all now carries eleven targets. Full suite 11/11 pass · 0 fail.
Eleven gates pass, including sri, composite, composite-srcs and synced-css.
2026-09-23 08:32:50 +00:00
doxa-maps-88655c70
fe8e785c96 Share the composite, not the child it nests
Sharing from dashboard-plus-grid produced a link to
research-dashboard/research-dashboard/index.html — its own nested child. A
recipient opened the inner dashboard alone and silently lost the grid half of
the composite. The gate said it precisely: "share URL reopened
<research-dashboard-map>, not <dashboard-plus-grid>".

The nested dashboard's DashboardTopBar owns the Share button, and inside the
composite that button had no idea it was a passenger, so it shared its own
address.

Two attempts, and the first one is worth keeping in the record because it built
and shipped and changed nothing. I set profileConfig.shareEmbedPath — tier 2 of
ShareButton's documented ladder, and the natural-looking channel. The ladder is
`embedPath || cfg?.shareEmbedPath`, and DashboardTopBar passes an explicit
prop (`<ShareButton :embed-path="SHARE_EMBED_PATH" />`), so a child that has
already made up its mind always wins. The value was baked into the bundle,
resolveShareUrl reads that key, and the rebuilt bundle failed the gate
identically — a fix that is present in the artifact and unreachable at runtime.
Tier 1, `shareUrl`, is the only rung above a prop, and its docs say exactly what
it is for: "a WHOLE url the HOST supplies wins".

Tier 1 takes a whole url, so it is computed rather than hardcoded, via the same
resolveEmbedBase() helper ShareButton itself uses: profileConfig.embedBaseUrl →
window.MAP_EMBED_BASE_URL → the root the built bundle derives from its own
script url. The link therefore stays correct on doxa.life, on nuxtbase.app and
in a dropped tree with no configuration. An absolute url would have pinned every
share to whichever host happened to build it.

Also adds the two uncovered bundles to smoke:share-all. Nine bundles ship a
Share affordance and the hardcoded --url list covered seven; research-dashboard
and dashboard-plus-grid were absent, which is why this survived. The list is now
ten targets over nine bundles. Note the structural difference from
smoke:embeds, which enumerates from manifest.json and therefore cannot omit a
bundle that ships later — a hardcoded list silently omits whatever arrives after
it was written, and that is what happened here.

This is the two-host defect class exactly: research-dashboard standalone PASSES
this gate, and the identical component inside the composite FAILED. No
single-host gate can tell those apart.

Verified: the composite now reopens <dashboard-plus-grid> with a canvas, and the
full suite is 10/10 pass · 0 fail, so nothing regressed. Eleven gates pass,
including sri, composite, composite-srcs, synced-css and one-palette. The share
gate aborts rather than reporting a false failure when handed Firefox, which has
no WebGL here — it told me to re-run with chromium, and it was right.
2026-09-23 08:16:18 +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
8550e58fbe Stop the browser-gate aggregator's description from counting
package.json described verify:browser-all as running "ALL 7 browser gates". The
GATES array in internal/verify-browser-all.mjs holds nine. Two were appended
below the count — check:fixed-anchoring and check:shell-scroll, both added the
same week — and the prose was never touched, so a reader who ran the command and
tallied seven would have concluded two gates had not run.

This is the same shape as the runbook's "all 6 DX guards PASS" corrected earlier
today, and the fix is the same: stop stating a number that lives in another
file. A prose count of a list that grows elsewhere rots on the next append, and
it rots silently, because nothing compares the two. The description now says it
runs every gate in the array and explains why it refuses to give a figure. The
script already prints its own GATES.length at runtime, which is the number that
cannot be wrong.

Also repairs a garbled sentence in the same string — "Only smoke:share the
`verify:browser-all:plugin-gallery` script mode is out of scope" appears to be
two edits collided. It now says plainly that the plugin-gallery mode is separate
because it needs the vite dev server.

One line changed; check:quoted-scripts and check:doc-scripts both pass, which
matters because those gates compare this file against the docs that quote it.
2026-09-23 07:42:58 +00:00
doxa-maps-88655c70
174300c211 Make the shipped publish block abort, because the version that could not took the catalog down
The golden-path doc's Step 4 gave the reader a copy-pasteable publish sequence
with no `set -e`, and its verification step was a COMMENT — "# 3. VERIFY every
manifest bundle's <name>/<name>.js resolves BEFORE push". A comment does not
verify anything. Pasted as written, the block clones, rsyncs with --delete, does
nothing, and pushes.

That is both halves of an incident this project already paid for, recorded as
AP-publish-verify-not-gating-plus-midchurn-rsync in the deploy-cycle notes: the
verify was chained into a compound command so a failing check still fell through
to the push, and rsync --delete ran from a source a concurrent build had just
emptied, faithfully mirroring that emptiness into the catalog. The live maps
catalog went to 404.

Step 3 is now three real commands — check:sri, check:embed-targets, and a git
diff that aborts if anything outside app/doxa-maps changed — and `set -e` is the
first line so any of them aborts before the push. Added `bun run check:settled`
as step 0, because `clean` empties public/doxa-maps at the START of every build,
which is what makes --delete catastrophic rather than merely wrong: it deletes
the live tree to match your momentarily-empty one.

The doc was already right to stay narrow ("the fuller runbook with every gotcha
lives in the project's deploy-cycle notes"), and it already carried postBuffer
with a good explanation. `set -e` is not a gotcha to defer — it is what makes
the four-step block safe at all, so it belongs with the block it protects.

Verified rather than assumed: all three commands exist in package.json, the
block extracted on its own passes `bash -n`, and the exclude-pathspec abort was
run against a real diff — it correctly passes when the diff is scoped and would
fire otherwise. Five gates pass.

Also corrected the runbook's own first instruction while checking it, in the
_qa-cycle notes (not a tracked file). It read "EXIT 0 + all 6 DX guards PASS".
No subset called "DX guards" exists anywhere in the repo, and the count was
wrong besides — build:unlocked chains 32 steps including 25 distinct check:*
gates, and that set grew by five in one flight. A hardcoded gate count is a
snapshot of a moving target. The chain is &&-joined under set -e, so EXIT 0
already means every step passed; that is the assertion that stays true as gates
are added, and the note now says so and gives a one-liner to list the current
set rather than copying it.
2026-09-23 07:37:03 +00:00
doxa-maps-88655c70
83ba336d8c Say what the Tauri build actually produced, because it produced something
Two docs understated deploy method 5. README-SECTION-3 headed the section "built
+ contract-proven; no binary emitted", and develop-a-plugin closed its table
with "2, 4 and 5 are scaffolded but no binary/install has been shot".

Measured: `cargo tauri build` has run and its outputs are on disk.
deployment/src-tauri/target/release/doxa-maps-desktop is a 67,379,880-byte ELF
64-bit LSB pie executable, and target/release/bundle/deb/ holds a 60,554,898-byte
.deb. Eight source files are git-tracked under deployment/src-tauri/, and
check:tauri exits 0.

So "no binary emitted" is false. What is true is narrower and worth stating
precisely: no window has been opened, because this build box is headless with no
Xvfb, so nothing has confirmed the app paints rather than launching to a blank
frame — the exact soft failure the section below already warns about. "Not shot"
means nobody has watched it run; it does not mean nothing was produced. The
closing sentence now separates the three built-not-shot methods, because what is
missing differs for each: no APK for mobile, no captured install for WordPress,
and for Tauri artifacts that exist but have never been launched.

The bodies of both sections were already accurate and are untouched. Only the
status labels were wrong, which is the part a reader skims.

Found while reconciling the ideation sources. The 5-cap spec
(01-ideation-research/coolest-ideation/5-cap-5-deploy.md, 2026-09-22) is CURRENT
and correct on all five rows — verified each: 43 embed.iframe entries in the
manifest, 19 SRI hashes, capacitor.config.json with check:mobile green, the
WordPress PHP with check:wp-embed green, and the Tauri artifacts above.

The /plugin-designer skill is now the stale one, and the direction of the error
has reversed. It says method 5 is "ASPIRATIONAL — not scaffolded. No Tauri
config/dir exists in the repo", and instructs the reader not to repeat the
spec's claim. That catch was CORRECT when written: the spec then said "proven
POC", which was unbacked. The spec has since self-corrected, carrying a dated
2026-09-22 block reading "BUILT, not shot ... only the window LAUNCH is
unverified", which matches the files. The skill's correction froze at the old
reading and over-swung from overclaimed to nonexistent.

That is a hazard worth naming: a correction record — "document X is wrong about
Y" — becomes wrong itself the moment X is fixed, and nothing re-reads it.
Correction notes need the same staleness discipline as the claims they correct.
The skill lives in the session config directory with no git repo, so the fix is
surfaced on BUILD-PLUGIN-DESIGNER-SKILL-MEANS-REAL-WORK as a single precise edit
rather than made here.

Seven gates pass, including check:tauri, check:capability-citations,
check:capability-exemplars, check:doc-scripts and check:quoted-scripts.
2026-09-23 07:29:27 +00:00
doxa-maps-88655c70
4b39abf126 Regenerate the library index during the build, so it cannot fall behind
check:library-index existed, was correct, and had been failing for about 19.6
hours. Nothing noticed because it runs only when a person types it:
build:unlocked chains 24 gates and this was not one of them.

check:no-orphan-gates passes on it, which is worth understanding rather than
treating as a gap in that gate. It asks whether every gate is REACHABLE — "named
in package.json or imported by something that is" — and check:library-index is
named there. Reachable and wired are different properties. 64 gate-shaped files
exist; 24 run on every build; the rest only run on request.

The fix is to regenerate rather than to check harder. check:composite is also
mtime-based and is in the chain, but it guards build output whose freshness
depends on build ORDER — a real invariant only a human can resolve.
reference-library-index.md is DERIVED from library/, so a staleness failure has
exactly one remedy: run the generator. Wiring the check alone would fail builds
and hand the author a chore; wiring the generator makes the staleness
impossible, which is the only way the file's own header — "DERIVED — cannot
drift from the code" — can be true.

Both are now in the pre-build group, generator then check, beside the other
source-only gates and before `clean`. Safe to run unconditionally: measured
deterministic (byte-identical on re-run) and 237ms. The check is kept after it
so a generator that silently writes nothing still fails the build rather than
passing quietly.

Updating package.json made check:quoted-scripts fail, which is the gate doing
its job, and it surfaced that the quoted copy in README-SECTION-3 was ALREADY
stale before this change: it omitted check:tab-radius, check:synced-css,
check:wp-embed and check:doc-scripts. An omitted step in a quoted list is
invisible — there is no wrong character to notice, only a check the reader
believes is not in the chain. The doc now matches what runs.

Verified by a full build: the generator ran, the check passed, vite built every
bundle, exit 0. The index is not dirty afterwards, which is the determinism
claim holding inside the real chain rather than in an isolated test. Five lines
in the build log match /error|FAIL/ and all five are gate DESCRIPTIONS
containing those words, not results.
2026-09-23 07:25:01 +00:00
doxa-maps-88655c70
18b3366c2b Regenerate the library index, which had fallen a day behind
check:library-index was FAILING: the derived menu was 70,718 seconds — about
19.6 hours — older than library/composables/useMapFly.js, which this flight
edited to give fitBounds an explicit duration. The file's own header says
"DERIVED — cannot drift from the code", which is only true of a file that gets
regenerated, and nothing had regenerated it.

The regeneration added one entry, and it is not cosmetic: viewportScrim.js was
missing from the index entirely while being imported by six files —
PosterDialog, SemanticTreeLegend, PosterWall, DataTable, LegendTools and
my-upg-100-list/poster-wall. A real, widely-used utility that an inheritor
reading the library menu could not discover. 85 reusable items now catalogued.

Measured the wider hygiene question while here, since the standing work list
asks for non-reusable components to be demoted out of the shared zone: of the 82
items the index advertises, exactly ONE has zero code importers —
useMapSelection.js. The other 81 are live. So the shared library is in good
shape and there is no demotion sweep to run.

The first measurement said fifteen components were unused, and it was wrong in a
way worth recording. It counted importers among BUNDLES only, so anything
imported by a sibling library component read as dead — PosterPreview.vue has
five importers and would have been demoted. Counting importers anywhere, not
just in plugin-designs/, is what reduced fifteen candidates to one.

useMapSelection is left in place deliberately, because removing a file from the
shared zone is a different kind of change from regenerating a derived doc and
deserves its own commit. It is carded instead. Worth knowing before that
decision: it is 192 lines, unchanged since the initial commit d5ba21f, so it was
never adopted rather than orphaned by a refactor; its one dependency
(getCircleRadiusInterpolation) still resolves, so it is dead rather than broken;
and it appears in zero of the nineteen built bundles, which is the decisive
evidence since Vite would tree-shake exactly this.
2026-09-23 07:16:13 +00:00
doxa-maps-88655c70
34ef223619 Name the UPG-100 legend's tabs, instead of shipping the placeholder
The legend read "GENERATION 1 / 2 / 3" above a list of world regions. A UUPG
list has no notion of a generation, so the label described nothing and invited
the reading that the tabs filter the map by some cohort. They do not.

The cause is a fallback, not a typo. SemanticTreeLegend derives its tab strip
from the `tabs` prop and, where an entry is missing, substitutes
`{ id: 'gen'+(i+1), label: 'Generation '+(i+1) }` so the strip has something to
render. This bundle passed no `tabs` at all, so all three were placeholders.
country-posters and doxa-research-map both pass real tab sets; doxa-simple-map
passes an explicit null. This was the only bundle relying on the fallback.

What a tab actually is: a DEPTH LEVEL of the tree, which is why selecting a node
jumps the strip to that node's depth. The three levels were read out of
data/legend-tree.js rather than guessed — regions are assembled by grouping
leaves under regionFor(), zones are the middle nodes (Frontier Asia (uncovered),
India, Deaf (affinity bloc A017)), and the leaves are the individual groups each
dot belongs to. Hence Region / Zone / People Group, each with an `info` note in
the shape AFFINITY_TABS and REGIONS_TABS already use.

The ids are bundle-scoped, and that was measured rather than chosen on style.
tabLabel() resolves `tabs.<id>` against the shared i18n catalog FIRST and only
falls back to the English label when no entry exists, so a tab id is a global
namespace. The first version used id:'region' and the legend rendered "WAGF
Region" — library/i18n/locales/en/tabs.json already maps that key to the
country-posters taxonomy. id:'group' collides the same way and would have been
right here only by coincidence, since the catalog means the ROP-2.5 sense. The
prefixed ids own no catalog entry, so the labels in this file are what render.

Desktop only, deliberately. LegendMobile computes
`hideTabs = nodes ? !tabs : _isFlatTabMobile`, so with nodes supplied and no
tabs the mobile sheet hides the strip entirely — it never showed the
placeholder. Passing tabs there would reveal a strip that is currently hidden,
which is a design change rather than a bug fix.

Verified by driving the built bundle in Chromium and reading the three chips
back: Region / Zone / People Group, each carrying its info tooltip, where the
same probe returned "WAGF Regioni / Zonei / People Groupi" before the id fix.
Then the README shot was regenerated and read as an image — the strip renders
REGION / ZONE / PEOPLE GROUP and the column header now says REGION where it said
GENERATION 1. Element count moved 311 -> 314, the three info buttons.

Eleven gates pass, including sfc-parse, template-literal, sri, composite,
one-palette and synced-css.

Found while verifying BUG-UPG100-DOTS-HARD-TO-SEE, which is genuinely fixed: the
dots carry a solid dark stroke that frames them against both the pale zone fills
and the white basemap, confirmed by reading the image rather than the card field.
2026-09-23 06:52:18 +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
7311434a71 Commit the built bundles for the ten fixes that are already serving live
The working tree carried a rebuild of all 19 bundles that was never committed.
Ten source commits had landed since the last build output went in: the
page-scroll layout that makes the grid reachable, the eighth filter and its
wrapping row, the tab radius token, the studio's remembered tab, the poster
print window, the fitBounds duration, the configurable pin colour, and the
synced-CSS gate repair.

These are not unshipped fixes. check:catalog-currency fetches all 19 bundles
from the live origin and reports 19 current / 0 stale, and research-dashboard.js
compares byte-identical at 413021 bytes against what nuxtbase.app serves. The
publish happened; only the git record of public/ lagged. Committing it closes
the gap so the next reader of this history does not conclude the tree is ahead
of production when it is level with it.

Worth recording how the verification nearly went wrong. Checking the live bundle
by hand with LIVE=$(curl ...) gave a hash that shared not one character with the
local file, which reads as a clear mismatch. Command substitution strips trailing
newlines, so the fetched copy was 413020 bytes against 413021 on disk, and one
absent byte changes every bit of a sha384. The single-byte size difference was
the only visible clue. Fetching to a file instead makes them identical.

The gate and the ad-hoc check disagreed, and this time the gate was right. The
rule that has been serving here is to suspect a gate when it contradicts
plausible reality; the sharper form is to suspect the newer, less-exercised
instrument. The gate fetches through a Node buffer and had just run that path
nineteen times. The curl pipeline was a minute old.

Nineteen gates pass on exit code, including composite freshness, SRI
consistency, catalog identity and catalog currency. Grading them by their last
printed line does not work and briefly showed ten false failures: these gates
print a verdict and then several lines explaining what they do not cover, so the
last line is teaching prose, not a result. Exit status is the contract the build
chain already uses.

Also drops two untracked scratch probes from 2026-09-22 that have served their
purpose.
2026-09-23 05:41:29 +00:00
doxa-maps-88655c70
239a983471 Say which embed targets we actually verified, and which we only expect to work
The hand-off doc said "Verified places a pasted embed works" and listed seven
platforms. The repo holds an artifact for exactly one of them: check:wp-embed
renders the real WordPress shortcode against the real built manifest, asserts it
emits the same SRI hash the catalog ships, and carries a negative case for a
malformed integrity value. Squarespace, Wix, Google Sites, Notion and Trello have
no artifact anywhere -- they are third-party platforms nobody here has an account
on.

The platforms are plausible and the mechanism is identical everywhere: each
accepts an HTML snippet or a URL embed over https, which is all the embed code
needs. So the list stays, split into what we proved and what we expect. The word
"verified" was the part that overreached, and in a doc an inheritor reads as
ground truth it means "someone tried this" when nobody did.

Worth recording how nearly this hid: grepping the repo for "Squarespace" returns
nineteen files and "Wix" twenty-two, which reads like evidence. They are bundled
copies of this very paragraph inside built .js files. The claim was confirming
itself -- the same shape as a pgrep that matches its own command line.
2026-09-23 05:33:51 +00:00
doxa-maps-88655c70
ad223d3b1a Read the pin colour and stroke from config instead of hardcoding them
The selection ring beside these already read cfg.map.selectedColor while the pin
fill and its stroke were literals, so half the pin styling was parameterised and
half was not, for no reason. Both now follow the same shape: a config read with
the current value as the fallback.

This matters beyond tidiness. The heartbeat threshold was made a variable so the
dashboard can point at a dataset that is not these 2107 people groups, and a
different corpus can want a different pin -- the orange was chosen against THIS
choropleth, and its legibility is a property of the pair, not of the colour.
Leaving the pin fixed would have made the dashboard reusable in its data and
fixed in its palette.

Verified: with no config the built map reports circle-color #ef6c00 and
circle-stroke-color #ffffff, byte-identical to the literals they replaced.

Not verified: that an override takes effect. Two runtime injections left the
defaults in place, which is expected rather than broken -- like SEL_COLOR, this
is read once when the element upgrades, and I could not prove my injection beat
that moment. The config channel is proven by check:heartbeat, but statically,
never through a live attribute. The comment says so rather than implying a
capability nobody has exercised.

The white stroke is noted as load-bearing: it carries the pin over the dark bins
where the orange falls to about 1.03 contrast, and it is what stopped the pins
reading as eyes. A host that sets it to the fill colour brings that back.
2026-09-23 05:19:44 +00:00
doxa-maps-88655c70
c07098bd77 Give fitBounds a duration, because FLY_CONFIG alone leaves it instant
Selecting a row jumped the camera instead of easing to it. The shared helper
spread FLY_CONFIG into the fitBounds call, which looks like it animates and does
not: FLY_CONFIG carries speed and curve, which flyTo understands and fitBounds
ignores. Without an explicit duration, fitBounds is an instant cut. The flyTo
paths in the same file were always eased, which is why the tree rows animated and
the map did not -- different mechanisms, not one broken setting.

Measured before: the camera moved 1.45 to 6 with two distinct zoom values across
fourteen samples. After: it interpolates through 1.453, 1.806, 3.429.

options still spreads last, so a caller passing duration 0 still jumps. That is
why the default lives here rather than in FLY_CONFIG, which is shared with
callers that must not inherit it.

One thing this box cannot settle: whether 900ms FEELS right. Sampling with
requestAnimationFrame returned six frames in 1.6 seconds -- SwiftShader software
rendering paints at roughly 4fps here, so perceived smoothness is not measurable
on this machine and my earlier "hard cut" reading at 90ms intervals was aliasing
against a starved renderer. The easing is proven present; the duration is a
judgement for a real browser.
2026-09-23 05:01:53 +00:00
doxa-maps-88655c70
9fff7c4af2 Let the poster print-preview window actually open
The preview branch was unreachable for every user. window.open with `noopener`
returns null by specification -- severing the opener relationship is precisely
what the flag does, so there is no handle to hand back. The document.write that
builds the print sheet ran against that null, so every click fell through to the
fallback: the raw .webp in a tab, no print dialog. The card claimed it opened an
in-browser print preview with a dialog.

Measured in a default browser, no headless flags: window.open('','_blank',
'noopener') returns null, the identical call without the flag returns a usable
handle. I had attributed the null to this headless box last tick, and that was
wrong -- it is what the flag means, everywhere.

noopener stays on the fallback, which only navigates to a URL and never needs a
handle, so the protection is kept where it costs nothing. The window we write
into carries same-origin markup we generate, so the opener reference it regains
is our own.

Verified after rebuilding: the click now opens ONE window titled "Doxa UPG-100
poster — Northwest India" carrying the @page rule, where it previously opened two
and wrote into neither. That window then shows "Poster not available yet." from
the img onerror, which is correct and documented: the full-size posters are 42MB
and deliberately not in the default tree, so the preview degrades honestly
instead of hanging. Set bundlePosters or point assetsBase at a host serving
posters/ to see the image.
2026-09-23 04:31:43 +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
34d4d0efdf Fix the same broken token command in reference.md, and say only what was measured
Last tick I fixed the golden path's token fetch and left the identical
403-fetching command in reference.md twice. Fixing the instance rather than the
class is the same mistake as scrubbing a string whose generator still writes it.
Both are corrected, along with a stale python3 -m http.server line that predates
serve-strict.

The doc had already MEASURED the restriction and kept prescribing its cause: it
recorded that tiles 403 from 127.0.0.1 and treated a blank basemap as the cost of
working here, while the command above it fetched exactly that token.

My first rewrite then overstated the fix, and the correction is in the doc rather
than hidden in a commit. Measured with the doxa.life token: curl gets 403 with no
Referer, 403 with a 127.0.0.1 Referer, 200 with a doxa.life Referer -- so the
restriction is real and does block the curl path. But a browser gate still
fetched 9 /v4/ tiles with zero failures, because the page resolves its token
through a ladder and the built bundle carries the working baked one. The fix is
to the shell commands; the browser gates were never affected.

Two things worth inheriting came out of checking it. The basemap is Mapbox Light,
pale by design -- I looked at a screenshot and briefly called it "not painting"
because I expected terrain and roads, when the labels and borders ARE the
basemap. And verify:map-paint passes under both tokens, since it asserts a live
GL context rather than loaded tiles, so it is not the signal to consult about
whether tiles arrived.
2026-09-23 04:13:45 +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
9d6b0ecd0f Run the WordPress contract test as part of the build
check:wp-embed existed as a script and nothing invoked it -- the same orphan
shape as check:synced-css, which had been red and unrun for weeks. This one is
green and unrun, which is quieter but no better: a gate the build does not
consult is documentation.

It is worth running. It renders the real shortcode against stubbed WordPress
functions and asserts the output against the real built manifest, so it needs
neither MySQL nor Docker. Its load-bearing assertion is that the wrapper emits
the same SRI integrity hash the catalog ships, which is what gives the WordPress
path the same tamper protection as the direct-script embed. It also carries a
negative case: a malformed integrity value must be rejected.

Full build passes with it in the chain.
2026-09-23 03:58:31 +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