426 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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". |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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
|
||
|
|
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.
|
||
|
|
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
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |