cap5-plugin-studio (Rudder's button now reads its label), prod-gospel-resources and its phone
shot. Derived from check:shots on this tree, tokenless; check:shots passes.
82 lines, one importer (useDashboardShell), and every import already @map/ — so it moves as-is with
no adapter and no shim. Its inject('profileConfig') stays inside its try/catch: it can be called from
outside setup, and that is the property the move had to preserve.
Source only. research-dashboard and dashboard-plus-grid need a rebuild.
The numerals policy is HELD — whether counts follow the reader's locale is the Driver's decision — so
this changes nothing anyone can see. Every caller renders exactly the digits and separators it
rendered before. What changes is how many places that decision is written down: thirteen call sites
across four bundles becomes one, library/utils/formatCount.js, and eight of the thirteen (mine) now
read it.
That is the whole value while the policy is held. If the answer becomes "follow the locale" it is a
one-line edit in formatCount.js instead of thirteen edits by four owners, each free to pick a
different locale source — and the first divergence in that world would be invisible: two panels in
one dashboard, same number, different separators.
★ IT FORMATS. IT DOES NOT DECIDE WHAT EMPTY MEANS, and that is the bug this fold would otherwise have
shipped. I told dx-shepherd these three helpers were "byte-identical". They are not:
DashboardDataTable null/undefined -> '' and '' falls through to Number('') === 0, so "0"
DashboardDetailDrawer null/undefined/'' -> '—'
useDashboardShell null/undefined -> '—' and '' likewise becomes "0"
Three different answers to "what does a missing count look like", inside one dashboard. Folding them
onto one empty policy would have silently changed two of the three — a table cell going from blank to
an em dash, or an em dash where a zero used to be. So each caller KEEPS its own guard and hands
formatCount a value it has already established is real. The locale decision folds; the empty policy
does not, and the difference is written into the helper's header so the next person does not re-merge
them.
Folded: DashboardDataTable, DashboardDetailDrawer, useDashboardShell (its exported fmt keeps working
for the KPI strip), DashboardFacetRail's three inline template calls, people-groups-grid's two.
NOT folded, because they are other owners': regions-dashboard (3) and pplr-data-maps (2). The helper
is theirs to import when they want it; nothing forces them, and no gate demands it — a gate requiring
the helper everywhere would fail on their files today, which is not a thing to land on someone else.
Source only. research-dashboard, dashboard-plus-grid and people-groups-grid need a rebuild.
THE GROUP WAS SCHEDULED AS ONE LANDING AND DELIVERED AS TWO. Section 8 of the spec puts the facet
rail, the chip bar and the BREADCRUMB in a single landing. The rail moved in 4c-3 and the chips in
4c-2; the breadcrumb was left behind, and nothing failed, because it is mounted by nobody. A leftover
like that is invisible unless someone re-reads the spec against the tree, which is what this landing
came out of.
The breadcrumb gets exactly the inversion the chip bar got: no useTreeLegendLabels import, the
resolver read from dashboardConfig.legend, and the SAME unconfigured default — identity, joining an
array. Identical on purpose rather than by copy-paste: the breadcrumb and the chips render the same
filterSet in the same band, so if their defaults differed a half-configured dashboard would show one
value resolved and the other raw, side by side. Its dead `dataStore` / `searchSourceId` injects go
too; they existed only to build the composable that is no longer imported.
★ AND THREE ONE-CYCLE SHIMS FROM LANDINGS 2 AND 3 WERE NEVER RETIRED. plugin-designs/research-
dashboard/src/composables/ still held useDashboardLayout.js (11 lines), useSelectionCamera.js (11) and
useCsvExport.js (14), each a pure re-export of the library module. MEASURED before deleting: ZERO
importers. Every consumer — including DashboardMapPanel, DashboardDataTable, DashboardDetailDrawer,
useDashboardShell, gospel-internet's board and research-dashboard.vue — already reads
'@map/composables/…' directly, and a tree-wide grep for a relative import of any of the three returns
nothing. So they were dead code that made the plugin look like it still owned three library modules.
I wrote "a one-cycle shim that nobody retires is permanent" into the previous landing's gate comment
while three older ones were sitting in the tree unnoticed. They were not found by a gate; they were
found by listing what is still in plugin-designs and comparing it against the spec.
check:legend-mirrors arm 5 now guards three moved names instead of two, so a re-export of the
breadcrumb creeping back into the plugin barrel is caught the same way.
Source only. research-dashboard and dashboard-plus-grid need a rebuild.
generate-library-index took the first line of every file as its description, which for a .json file is data:
the Data section read '"_meta": {', '"value": "AFG",' and a run of ISO codes (flagged by simpleresearch).
A .json file now uses _description, then _meta.description, then _meta.purpose, and otherwise says
'(no description)'. iso3-to-alpha2 now reads as what it is.
The three goal cards, the stat row and their bars were the regions
dashboard's own markup. They are library components now, and the plugin
renders them instead of holding a copy:
GoalCard title + figure + a goal line pinned to the card's bottom,
with a slot for whatever sits between
StatCard one figure and its caption
ProgressBar a single-value track, or a segmented one with a legend
useCardSurface the card surface both cards share, injected once per
root under a stable label instead of copied into each
The bar and its legend now read from ONE list of segments, so a width and
the number printed beside it come from the same figure and cannot drift
apart; before, each was computed separately. A segment's colour is a prop
carrying the same token its .rd-seg--full/--partial/--none class used to
set, so a shared bar needs no vocabulary about prayer and those three
rules are deleted rather than left unreachable.
ProgressBar carries its own reduced-motion guard. The plugin's reset is
scoped to .rd-root and would not have followed the component anywhere
else, which would have reintroduced the one-second sliding bar their own
site suppresses.
The rendered DOM is unchanged for all four instances, measured by
server-rendering the real components and comparing against the markup
that was deleted, including that the bar widths equal the percentOfSubject()
they replace.
check:regions-uses-library grows to cover all four, asserting both halves
again: the plugin imports and renders them, and no longer ships their
markup or CSS. The .rd-root strong rule STAYS in the plugin -- UupgsList
renders a <strong> in the same shadow root and depends on it, which a
file-scoped read would have called dead.
Our data speaks ISO alpha-3. Intl.DisplayNames('region') takes alpha-2 or
UN M49 and NOT alpha-3, so every country name was stuck in English for want
of the join.
Vendored CLDR cldr-core 48.2.2 (Unicode-3.0, 36KB, sha256 and source URL
recorded in library/vendor/cldr/README.md). No npm dependency: a package
would pull the whole supplemental tree and make a shipped data file depend
on an install step, where a vendored copy lets the generator reproduce the
map offline -- which is the point of vendoring rather than merely having a
copy.
generate-iso3-to-alpha2.mjs joins it against the 242 codes in
countries.json and GRADES ITS OWN OUTPUT, writing nothing on failure: 242
coverage, injectivity, two-letter well-formedness, and that each documented
exception is still needed. KOS -> XK is the only exception -- Kosovo has no
ISO 3166-1 assignment, so no refresh will ever supply it, and XK is the
user-assigned code CLDR itself uses in locale ids. It is recorded with its
reason instead of patched into the vendored bytes.
countryName(iso3, locale) falls back localized name -> canonical English
name -> the code. Intl.DisplayNames THROWS on a missing or malformed code
rather than returning null, so the guard is checked before asking; the
probe demonstrates that throw rather than asserting it in a comment. An
unassigned-but-well-formed region returns the input unchanged, so that case
falls back instead of rendering "DE" as a name.
THE RESEARCH SPINE MAP IS NOT TOUCHED. iso3-to-iso2.json is a different
file with a different job -- the 176-country spine that drives the data
fetches -- and expanding it is a Driver decision, not a side effect of
adding country names. The generator cross-checks it and REPORTS
disagreements without rewriting. There are none: CLDR independently agrees
with all 176 values, including the two corrected this session.
Also fixes a duplicate "check:religion-label-not-value" key in
package.json, left by a merge. JSON keeps the last, so one description was
silently dead and check:doc-scripts could not see it; bun's parser warned.
The surviving entry absorbs the only fact the dead one carried, the two
library sites by name.
The rail's rendering is unchanged. What changed is where its last two plugin imports point. 4b had
already removed the research COLOUR and LABEL tables -- those stay in plugin-designs and arrive on
dashboardConfig.legend -- which left exactly two, and both now read the module that OWNS the
function rather than a re-export:
useDashboardAggregations -> @map/composables/useFacetAggregation.js (extracted by 4c-1)
useDashboardShell -> @map/composables/useDashboardSearch.js (extracted by 4a)
Pointing at the shim instead would have been invisible and wrong in a way this tree has already
measured: check:filter-multivalue carries the note from landing 3 that a gate left at the plugin path
read the shim, found no predicate to object to, and PASSED VACUOUSLY.
The dead injects go too. `dataStore` and `searchSourceId` were here only to construct
useTreeLegendColors and useTreeLegendLabels, and 4b removed both constructions. An unused inject is a
claim about the host: a kit component that appears to need a research dataStore is one a new
dashboard will dutifully provide before finding out it was never read. The chip bar shed the same
pair in 4c-2.
check:filter-multivalue and check:legend-mirrors follow the file rather than being left pointing at a
path that no longer exists -- the multivalue gate fails loudly on a missing panel, which is what it
is for, and it is how I would have caught forgetting.
UNLIKE THE CHIP BAR, THIS ONE IS ON SCREEN. 4c-2's component is mounted by nobody, so its gate was
the whole story. The rail is mounted by research-dashboard.vue and is 770 lines of tree, search and
drag behaviour: "the imports resolve" says nothing about whether it still opens four trees by
default or whether its search still narrows. It needs eyes.
The barrel re-exports from the library for one cycle, so no importer's line changes in the same
landing that moves the file. documentation/reference-library-index.md regenerated.
DashboardFilterChips.vue moves to library/components/dashboards/. Nothing about the rendering
changed. What made the move possible was 4b, which inverted the one dependency that blocked it: this
file imported useTreeLegendLabels — the People CODE -> NAME tables, research-specific and staying in
plugin-designs — and a library component importing a plugin composable is what
check:no-cross-plugin-imports refuses. The resolver now arrives on dashboardConfig.legend.
THREE THINGS BEYOND THE MOVE, all found by assessing the file rather than by moving it:
1. THE UNCONFIGURED DEFAULT NOW JOINS A LIST. 4b's fallback returned the value untouched, so a
multi-value filter (spec D1) printed String(['A012','A013']) = "A012,A013", comma-jammed, while a
dashboard that DID supply a resolver got "South Asian Peoples, Bengali Peoples". Configured and
unconfigured should differ in whether CODES RESOLVE, never in punctuation — the second difference
reads as a rendering bug rather than as missing configuration, and go-int is the consumer who will
see the unconfigured state first.
2. THE DEAD INJECTS ARE GONE. `dataStore` and `searchSourceId` existed only to construct
useTreeLegendLabels and have been unused since 4b. Removed rather than left in place: an unused
inject is a claim about the host, and a kit component that appears to need a research dataStore is
one a new dashboard will dutifully provide before discovering it is never read.
3. AN EXPLICIT EXCEPTION TO library/components/WARNING.md, recorded in the file's own header. That
file says a component graduates into the library only once MORE THAN ONE map uses it; this one is
used by ZERO, because the topbar dedup pass unmounted it when SearchFilterHeader became the single
source of truth for showing and clearing filters. It is here because the kit's promise runs the
other way round — a NEW dashboard mounts kit components, so waiting for a second consumer before
providing the first one is a deadlock. The honest consequence is written down too: a passing gate
means "it mounts and degrades", not "it looks right", and nobody has SEEN this component since the
dedup. Whoever wires it in is its first real reader, and must not mount it beside a
SearchFilterHeader that is already showing chips.
check:legend-mirrors grows arm 5, and it asserts the two things a "move" gets wrong that reading the
new file cannot show: that the OLD copy is actually gone (a move that leaves the original behind is a
copy, and two chip bars over one filterSet drift the moment one is edited — this tree has already
paid that with useNeighborT/usePplrT at Jaccard 0.82), and that the UNCONFIGURED behaviour is right,
which no dashboard here would reveal because they all supply a resolver. The default is LIFTED out of
the source and RUN, the way arm 3 drives the rail's fallbacks — not mounted, because a no-op renderer
cannot show a chip and the rendered result is rega's. Five mutants, all caught: old copy left behind,
the join reverted to 4b's bare value, a dead inject re-added, the supplied resolver ignored, and a
relative import restored.
The barrel re-exports from the library for one cycle, so no consumer's import line changes in the
same landing that moves the file. documentation/reference-library-index.md regenerated, per landing.
PARENTED ON a285e06c (4b), not on main, because 4b edits this same file — landing them the other way
round would revert 4b's inversion. Source only: research-dashboard and dashboard-plus-grid need a
rebuild.
useDashboardAggregations has a split personality, and only half of it could ever
move. `lbl` and `aggregate` are pure counting — eleven lines, a Map, a sort, a
topN rollup, no facet name and no colour. Everything else in that file is
research: `colorFor` with its hardcoded religion / wagf_region / affinity_bloc
branches, the religion palettes, the donut and bar builders. Those stay, and
DashboardChartsRail keeps importing them.
The generic pair was what blocked kit landing 4c: the facet rail needs only that
half, and while it lived beside the palettes the rail could not follow it into the
library. Same split, same one-cycle re-export as 4a. Bodies verbatim.
★ AND THE ROLLUP BUCKET SAID "Other" IN EVERY LOCALE. aggregate() pushes
`{ key: 'Other', other: true }` for the tail, and that English word was rendered
directly by the facet rail and the charts rail. check:i18n-keys-exist could not
see it, because there was no T() call to inspect — the string was never a lookup,
it was a KEY being used as a LABEL. I found it while ASSESSING this landing rather
than while doing it, and it is fixed here rather than later for one reason: the
moment this module becomes kit code, an unfixed bug in it is exported to every
dashboard built from the kit.
THE TWO HALVES PULL IN OPPOSITE DIRECTIONS, which is the whole difficulty. The KEY
must stay English: the rail tests it to make the row inert, and the charts rail
refuses to filter on it, so a localized key would make the bucket CLICKABLE in
Arabic and filter on a value no row carries — an empty board, reading as a data
problem. The LABEL must not stay English. So OTHER_KEY is the sentinel,
otherLabel() reads dashboard.other, and they live next to the function that
produces the bucket so they cannot drift. Eleven locales, catalogue first.
This is the value/label trap for the third time — religion families, the language
tiers, and now a bucket in generic code. The third is the worst place for it,
which is why the rule now travels WITH the code that creates the value rather than
being remembered at each render site.
check:legend-mirrors grows arm 4, asserting both directions: translate the
sentinel and it fails; render the key instead of the label and it fails. Both
mutants run.
check-one-palette now scans the new module as well. Its subject — private palette
declarations — stayed in the plugin half, so nothing moved; adding the library
path means a palette smuggled into kit code later is caught by the same rule
instead of escaping into the library.
Library index regenerated in-commit. Source only. STUDIO: needs a rebuild of
research-dashboard and dashboard-plus-grid. The internal/ hunk is four lines in
check-one-palette.mjs.
The Driver's ask, and his negotiating point with the DX-life author depends on it
being true: the components cloned from their /regions page must be REUSABLE,
PARAMETERIZED components in the shared library, not private to one plugin. He named
the failure mode himself — someone opens the regions plugin folder and finds the
components are not reusable after all. I checked before building, and for the UI he
named he was right: only the DATA and LOGIC had been promoted.
So library/components/regions/RegionSectionCard.vue takes title, summary, countries
and footerLabel, emits what was clicked, and knows nothing about where regions come
from. The plugin version fetched its own data and owned the layout, which is exactly
what made it unusable anywhere else.
THE DESIGN TRAVELS WITH IT. The card carries the region-card, countries-grid and
country-link CSS lifted unchanged — including the RTL chevron rules, where a
"forward" chevron must mirror in Arabic while a disclosure caret must not, and where
[dir="rtl"] .region-card__icon at 0-2-0 would otherwise beat the 0-1-0 --open
modifier. A consumer gets the look, not a lookalike. It needs dir on an ancestor for
that mirroring, which is documented in the file as a requirement.
★ THE CARD OWNS THE DISCLOSURE ROW, after the first cut got it wrong. I put the
"View country posters" toggle in the consumer's slot — and immediately had to copy
the footer-link styling and the rotating caret back into the plugin, re-creating the
duplication this extraction exists to remove. A disclosure inside a card is a card
feature; only the revealed CONTENT is the consumer's, so PosterWall stays in the
slot with its fourteen props and does not become every consumer's API.
★ THE CHEVRON IS INLINE, NOT A <use href="#id">. The plugin declared the symbol once
and every card referenced it. A shadow-root id is scoped to that root, so the same
card mounted elsewhere would reference a symbol that does not exist — and an
unresolved <use> renders NOTHING, silently. The plugin's own comment warns about
that class for external hrefs; a cross-root id fails the same way.
check:regions-uses-library is registered and asserts BOTH halves: the plugin imports
from @map/ and renders the component, AND no longer holds the markup or the CSS it
delegated. A reusability claim only a human can audit is one that rots; this one
fails the day someone re-inlines a copy. Two negative controls keep it honest — the
plugin still uses its own .rl-root classes, and the other tab is asserted NOT to use
the card yet, so the check describes what is extracted rather than what I hope for.
Two of my own mechanical edits produced valid JS and broken content on the way here,
both caught by checking rather than by the parser: a line-numbered splice that
dropped PosterWall's closing /> (fixed by locating boundaries by CONTENT), and a
line-filtered CSS deletion that left 9 orphaned declarations and braces at 12/21
(fixed by removing rules brace-aware, then re-validating the concatenated stylesheet
rather than trusting check:sfc-parse, which only proves the JS parses).
?codes= filters by LANGUAGE, which is what a partner's list usually is and the wrong
filter for a list of peoples. The reach table measured the gap in its own rows: its
Mandarin row counts 36 groups, those 36 speak 30 codes, and 55 Doxa groups speak those
30 — so a link from a 36-group row drew 55 pins. Akha's 33 drew 57; SUN's 93 drew 116.
The extra groups are real and also have nothing; they belong to a different row.
So the map takes ?groups=<slug list> and filters by people group. The slug is
doxa.life's own — carried from the roster into the pin rows, and read in the builder
off the prayer URL their API built from it, never constructed from a name, because a
slug this build invented could name somebody else's people. All 369 of those rows
carry a prayer URL, measured before relying on it.
Row and map now agree by construction: 0 of 35 rows disagree, and the Mandarin link
renders "36 / 2,107" on the map against a row claiming 36. The gate asserts exact
equality for a three-slug link and 0 for an unknown slug — the failure that would
quietly turn a partner's 40-group embed into the whole roster.
The longest link is 1,577 characters, SUN's 93 slugs; well inside what browsers carry,
but worth knowing before anyone adds a row with a thousand groups.
★ LANDING 4 AS I SCOPED IT IS NOT POSSIBLE, AND THE SPEC WAS WRONG ABOUT THAT.
go-int asked for the facet rail next and I agreed. Then the first step of a
landing — look before moving — found that DashboardFacetRail imports FOUR
plugin-local modules: useDashboardAggregations, useDashboardShell,
useTreeLegendColors and useTreeLegendLabels. Moving the rail into library/ would
have made a library component import plugin composables, which
check:no-cross-plugin-imports rightly refuses. And two of those four are
research-specific by my own spec's reckoning — useTreeLegendColors carries the
DOXA colour strategies and is supposed to STAY in the plugin — so the rail cannot
become library code by moving alone. Its colour and label dependencies have to be
inverted into injection first, which section 8 named as a risk and then ordered as
if it were free.
So landing 4 is three commits, not one, and this is the first: the piece the rail
stands on.
THE SEARCH WAS NEVER A SHELL CONCERN. buildSearchAccessors, makeSearchMatcher and
filterRowsBySearch were exported from useDashboardShell, and EIGHT files reached
into the shell to get them — the table, the map panel, the charts rail, the facet
rail, the research filters, the top bar, the profile and the config. A module that
eight things import is a module, not a corner of another one. Nothing in it is
domain-specific: the accessors come from config.facets / config.columns /
config.searchKeys, and the matching comes from the already-shared fuzzy.js.
Bodies are verbatim; this is a move. The shell re-exports the three names for one
cycle, so none of the eight importers changes in this commit — a barrel line
rather than a shim file, so there is nothing to delete later, only a line to
remove when the eight point at the library directly.
The now-dead fuzzy.js import is removed from the shell rather than left. An import
nothing uses is the kind of thing that survives three more landings because it
looks load-bearing.
WHAT THIS DELIBERATELY DOES NOT TOUCH: the shell keeps rowMatchesFilters and the
URL-hash escape helpers, which are what check:filter-multivalue reads out of it by
path. I checked that before slicing, so no gate path moves in this commit.
Library index regenerated in-commit. Source only; no rebuild needed beyond the
usual, since every importer still resolves through the shell.
Both are zero-domain in the sense the spec meant: they import only `vue` and read
rows through accessors. Bodies are what research-dashboard shipped apart from the
two parameters the spec named and one policy object, and the old paths re-export
for one cycle.
★ THE FIRST STEP FOUND TWO GATES THAT READ THESE FILES BY PATH, which is why it is
now the first step. Landing 1 nearly disarmed check-facet-cascade the same way.
internal/check-csv-export-shape.mjs does not import useCsvExport; it READS the
source and extracts esc() out of it. Left at the plugin path it would have read
the one-line shim, found no esc(), and failed with "could not extract esc()" — a
gate accusing a working exporter because its subject moved.
check-filter-multivalue.mjs — MINE — lists the same file in its PANELS array.
Left at the plugin path it would have read the shim, found no predicate to
object to, and PASSED VACUOUSLY. That is worse: a gate going quiet about the
thing it guards. Both move in this commit.
THE COORDINATE IS A PARAMETER; WHAT MAKES A COORDINATE VALID IS NOT. `coords`
replaces only the READ of row.longitude / row.latitude, which is pray-tools'
spelling and not a universal one. Every guard stays shared: Number() on API
strings, 0 accepted per axis, the exact 0,0 null-island sentinel rejected, the
out-of-range check. A kit that let each caller re-implement that validation would
hand the next dashboard the same four bugs to rediscover.
★ AND THE OPTION WAS DEAD WHEN I FIRST WROTE IT. `coords` was accepted in the
signature and never passed to selectionBounds, so a caller could set it,
type-check clean, build clean, and watch it do nothing. This tree has paid for
that shape before — a defineProps on a component the custom element never binds —
and it is worse than a missing feature because it READS as supported. Nobody files
a bug against a knob they assume they misused. check:kit-options-reachable now
asserts, across all four shared composables, that every option accepted is used.
THE CAMERA'S EIGHT CONSTANTS ARE ONE DECISION, so CAMERA_POLICY exports them
together. The failure that prevents is a second dashboard re-picking them one at a
time and arriving at a camera that feels different from Big Picture's for no reason
anybody wrote down. It is read-only and deliberately not yet overridable: making
each a per-caller option means threading eight of them through every use, and no
consumer has asked for a different camera. Exporting the decision so it can be
cited is the half with a use today; saying so is better than shipping eight
options nobody passes.
THE EXPORTER GAINS exportLabel and sources. "Research Dashboard" was this
dashboard's name, and the provenance line said nothing about where the data came
from — which is what a provenance line is for. Both default to today's output, to
the byte.
★ AND THE NEW GATE HAD THE COMMENT HOLE TWICE IN ONE SITTING. First it searched
the raw body, and the comment beside the fixed call site says "`coords` IS PASSED
HERE" — so the mutant that removed the argument left it green. Then, stripping
comments only per-part, it split the signature's comment PROSE on commas and
reported that useCsvExport accepts `and`. Comments are text; every text search has
to say so, in both directions. That is the fourth variant of one mistake this
week, and all four were caught by running the mutant rather than by reading.
Library index regenerated in the same commit, 103 items. Source only.
STUDIO: needs a rebuild of research-dashboard and dashboard-plus-grid. The
internal/ hunk is one line in check-csv-export-shape.mjs.
Dispatch work item (5) asked for useTextHighlight in library/. RULE 2 wants a named
SECOND consumer before anything moves there, and it exists: regions-dashboard's
UupgsList and people-groups-grid both consume the same Fuse-compatible shim, both
read match.indices, both wrap hits in <span class="search-highlight">, and both
append the tail only on the last range. Line for line the same construction, apart
from variable names.
So library/composables/useTextHighlight.js exports highlightRanges(value, indices),
and UupgsList calls it. Its local escapeHtml goes too: every caller was inside the
highlight body that moved.
★ ESCAPING NOW COMES FROM library/utils/html.js, AND THAT IS A CHANGE FOR BOTH
CALLERS, stated rather than smuggled. Three implementations existed and differed:
library/utils/html.js escapes & < > " ' AND backtick; returns '' for null
UupgsList no backtick; guarded null via String(v ?? '')
people-groups-grid no backtick; NO NULL GUARD — s.replace(null) THROWS
Both deltas move in the safe direction — one more character escaped, one crash
removed — and neither changes the output of any value that renders today. The gate
pins both, with controls proving each delta is a real difference rather than a
no-op: the old copy really does leave a backtick unescaped, and their escaper
really does throw on null.
people-groups-grid is NOT migrated here: it is another owner's file, so it is
handed over as source. The gate asserts it still builds its own highlighter, so
this check states the truth rather than implying the job is finished.
check:text-highlight is registered (script line, gates:post token, hand-inserted)
and negative-tested: an implementation that escaped nothing fails it, and the
output is asserted to depend on the indices.
gospel-plugin's decision, in their words: "most partners will drop a custom
element or an iframe and never install vue-i18n ... an Arabic ministry site
embedding this map serves its readers English while the bundle already carries all
11 catalogues. That is a bug, not a decision."
So all four gospel translators drop `{ fallback: enLookup }` and take the
helper's catalogue default, which resolves through tStatic: the reader's locale
first, English as the last resort. Their three conditions are met and each is
asserted, not assumed:
1. resources-map passes NO namespace — its keys span gospel.* and pins.*;
2. the fallback walks the catalogue ROOT, which tStatic does by construction;
3. params survive, via the null-safe fillParams.
★ THE ROW THEY ASKED FOR, and it is the only thing here that could catch the
regression this migration risked: a BARE host under ar must render
pins.approximate in ARABIC — not the key, not gospel.pins.approximate, not the
English. It reads the SHIPPED ar/ and en/ pins.json and rebuilds tStatic's own
resolution order over them, so it measures the real catalogue rather than a stub.
Three negatives beside it, plus a control that both catalogues really carry the
key, so the comparison is measuring a difference that exists.
enLookup is deleted from all four, and the EN_MESSAGES import from three —
data-tables keeps it, because it still builds a map from EN_MESSAGES.gospel.res at
:182. Checked before deleting rather than after: check:no-free-identifiers would
have caught a wrong removal, since removing something still used creates a name
that is valid JS until it runs.
Two corrections that came from other agents reading this work:
- studio: the JSDoc carried <area>.<key>-shaped examples, which
check:i18n-keys-exist reads as real keys and reports missing. They reworded it
twice because my file copy restored it. Reworded, with a standing note in the
file so a third copy cannot bring them back.
- the register said each site "takes the CATALOGUE fallback, as it always did".
True of the two composables, FALSE of gospel's four, which were moved here by
their owner. A false statement inside a gate message is worse than no message,
because a reader has no reason to doubt it. Now accurate per row.
check:i18n-keys-exist is red on messages.engineNotLoadedDetail. NOT mine and not
touched here: it is in cities-internet/connectivity-map.vue, whose own comment says
the key "arrives with upg100's ade12e4c", and the call already guards for its
absence.
C4, first wave. Twenty places each stored the same fact — how to translate when
the host never installed vue-i18n — and two of them, usePplrT.js and
useNeighborT.js, were whole-file near-duplicates at Jaccard 0.82. That pair was
the curation audit's evidence that the guard was being COPIED rather than shared.
Both now call library/composables/useOptionalT.js and keep their public names, so
no call site changes.
★ THE SPEC ASKED FOR (namespace, fallbackCatalogue) AND THE CATALOGUE IS
UNNECESSARY. Every copy imported its own English JSON and hand-walked it after
stripping the namespace prefix. But library/i18n builds `messages[locale][area]`
keyed by the JSON's own filename, and tStatic() already walks that tree and
already falls back to English. So `import enPplr from '.../en/pplr.json'` was a
second copy of bytes the bundle already had, and the helper takes one argument.
★ AND IT IS NOT A SIZE REGRESSION, which was the one good reason the narrow
import might have existed. The build NARROWS library/i18n's catalogue glob per
bundle to the areas internal/i18n-areas.mjs finds in that bundle's source graph
(check:i18n-treeshake). A bundle that says 'pplr.' gets the pplr area, not the
other thirty. Verified before writing the helper, because "the obvious dedup
makes every bundle carry every locale" would have killed it.
Resolution order, each step earning its place: the host's own vue-i18n when it
installed one, so a host managing locale keeps control; then tStatic, which
honours the runtime locale and falls back to English; then the key itself, so a
missing string is visible rather than blank. The middle step also covers i18n
being present but empty — i18n.t() returns the key unchanged, which every copy
of this pattern already treated as "not found".
Verified by _qa-cycle/02-builder-cycle/_chk-c4-optional-t-equivalence.mjs: 18
assertions across all three host states, with a branch-for-branch equivalence
check against the old hand-written implementation and two negative controls. It
extracts and executes the module source, because useOptionalT imports vue-i18n
and an i18n index that calls import.meta.glob, which plain Node cannot run.
useDashboardLayout is the second zero-domain piece: it imports nothing but `vue`
and reads no row. The four mentions of a people-groups grid in it are comments
naming an example of what a host stacks underneath. Body verbatim apart from the
one change the spec required before it could be shared, and the same one-cycle
re-export at the old path.
THE KEY WAS THE REASON THIS COULD NOT JUST BE MOVED. The engine persists the four
divider positions to localStorage under a single constant, so every dashboard
wrote to the same slot. That was invisible while there was exactly one dashboard
— and SHARING THE ENGINE IS PRECISELY WHAT MAKES A SECOND ONE POSSIBLE. Moving it
without fixing this would have shipped a collision into the kit's first reuse:
drag a divider in one dashboard, reload, and the other has moved. Nothing errors,
neither is wrong on its own, and the reader has no reason to suspect the other
element on the page. SPEC-DASHBOARD-KIT section 8 named it as this landing's risk;
the fix belongs in the commit that shares the engine, not in the one that first
trips over it. The key now carries the profile's own id, so a second dashboard
gets its own slot without its author knowing this key exists.
AND NAMESPACING MUST NOT SILENTLY DISCARD A LAYOUT. Every reader who has dragged
a divider has a value under the old shared key; a new key would simply orphan it
and reset them for no reason they could see. The engine reads the old key once
when the new one is absent. It never writes or deletes it: a migration that also
deletes cannot be rolled back, and leaving the old value costs one localStorage
entry.
check:layout-key-namespaced guards all three. It EVALUATES the storageKey
expression the shell actually passes, with two different dashboard ids, rather
than retyping the rule — a gate carrying its own copy of the thing it checks
passes while the product is broken, which is the lesson from
check:linked-vocabulary. Negative-tested: a bare literal fails A, an expression
that ignores the id fails B, and writing or removing the legacy key fails C.
★ THE BUS_SRC LESSON WAS APPLIED BEFORE MOVING, NOT AFTER. Landing 1 nearly
disarmed check:facet-cascade because that gate reads its subject from a path. I
checked first this time: no gate reads useDashboardLayout by path, and the
storage-key literal appears in exactly two places, both of them in this diff.
The library index is regenerated here, per landing, for the same reason as
landing 1 — 101 items now.
SOURCE ONLY. Stacked on D1. STUDIO: needs a rebuild of research-dashboard and
dashboard-plus-grid. package.json is the parent's copy plus two lines beside
check:leaf-engine-per-provider, so D1's registration is preserved.
The brief's third requirement at the grain the province work added: clicking a place
filters the table to it. Same split as the country and language links — a filter when
the tables are already beneath the map, a link when they are not.
The province key is a NAME, not a code: geoBoundaries' shapeName is what the table
joins on. So it is matched with diacritics folded, because a link that only works for
whoever copied it off the map is not an address — ?province=Arunachal Pradesh finds
"Arunāchal Pradesh". An unknown province matches nothing and says 0, rather than
falling back to every row.
The embed gate grew two arms for it and the first one failed for a reason that was
mine: it sliced the page text to 300 characters and then asked whether the province
name was in it. The row was on screen, 400 characters down, and the gate called the
product broken. A slice belongs in the message, never in the predicate.
The first piece of Big Picture to be shared, and it went first for one reason:
useSelectionBus has ZERO domain references and imports nothing but `vue`, so it
is the only piece that could move with no adapter and no behaviour change. The
body is byte-for-byte what main shipped — verified by diffing everything from the
first import onward — and only the file's header block is new. A move that also
edits is a move nobody can verify, and nine of these have to be verifiable in a
row.
THE OLD PATH STILL WORKS, as a re-export, for one cycle. That is not politeness;
it is what keeps this landing small enough to check. A landing that moved the
file AND rewrote every import would be two changes whose failures look identical
from the outside. The shim re-exports and does not duplicate: a second copy would
drift silently while both halves kept passing their gates, which is the failure
check:no-cross-plugin-imports and check:shared-reached exist to stop. It is
deleted in the next landing.
★ THE GATE HAD TO MOVE WITH IT, and this is the part a move like this hides.
check-facet-cascade reads the bus from a PATH (BUS_SRC) and, for its self-test,
copies that file and poisons the copy to prove it can still catch a reverted
cascade. Pointed at the shim, it would have copied and poisoned a RE-EXPORT: the
mutation would match nothing, the "broken" copy would behave correctly, and the
self-test would report that the gate no longer catches anything. One line, and
without it landing 1 would have silently disarmed the only check that proves the
hierarchy contract. Verified after the move: the gate PASSES at the new path and
the self-test still catches a reverted cascade (13 assertions).
The library index is regenerated in the same commit, per SPEC-LIBRARY-CURATION
section 4: check:library-index compares REGENERATED BYTES, so a new file under
composables/ turns it red until `bun run library-index` is re-run. Per-landing
regeneration rather than one pass at the end, because nine landings with a
knowingly-red staleness gate would train nine readings of "ignore that one".
useSelectionBus now appears in the generated menu — which was the whole argument
for the existing taxonomy over a new top-level area.
NOTHING TO REGISTER IN package.json: the gate that guards this file,
check:facet-cascade, was already wired, and check:no-orphan-gates confirms it.
This commit does not touch package.json at all, so it cannot conflict with the
three landings of mine that do.
STILL COMING, deliberately not here: SPEC-DASHBOARD-KIT D1 makes filterSet
multi-value. That changes this file's CONTENTS; this changes only its LOCATION.
Keeping them apart is what makes "Big Picture is unchanged" provable.
SOURCE ONLY — not built, by instruction. Green here on main's tree:
check:facet-cascade and its self-test, check:library-index, check:no-orphan-gates,
check:no-cross-plugin-imports, check:shared-reached, check:no-free-identifiers,
check:mjs-parse, check:sfc-parse, check:descriptions.
Comments and prose only, no code change (DECOUPLE-go-base-mentions.md sections B and C, outside
library/ and README.md): stack-demo's page-flow note becomes 'a normal host page', the share-link
and CORS comments say 'the live test host', embeddability.md's Traefik note says 'a self-hosted
reverse proxy' and drops the internal proxy file path, and how-to's hosting option becomes 'a Git
host you run (for example Forgejo)'. stack-demo.vue's @description feeds the generated tree, so the
landing regenerates the tree. Section A (QA widget), library/ and package.json are left to studio.
?codes, ?country, ?code and ?view=zero are what a partner puts in an iframe and
never touches again, so they are documented where the other embed material lives —
including the two things that decide whether a link is right: the denominator is
Doxa's 2,107, not the 12,379-group corpus, and a link matching nothing shows an
empty map reading "0 / 2,107" rather than pretending a broken list works.
Also says what the map does NOT promise: no zoom or centre parameter, placement is
approximate, and an unknown parameter is ignored rather than fatal.
From exp-shepherd's web-boundaries report, checked against main (afb34488): no file in dx-maps/ is
content-hashed today, so everything takes public, max-age=86400, stale-while-revalidate=604800
with an ETag, and immutable only once names are hashed. The boundary file is 1,866 KB raw / 597 KB
gzipped (measured); the smaller -web copies are not on main yet, so not cited. Script embeds need
ACAO * because every embed.script carries integrity + crossorigin=anonymous. The first-paint
figures are rega's cold loads of the UPG-100 list (9.5-16.5 s) and studio's bundle/engine split.
First palette migration from SPEC-LIBRARY-CURATION.md. Source only; studio rebuilds
(rule 5B — library/ plus four other owners' plugins).
WHY THIS ONE FIRST: the duplication was not idle. doxa-research-map's
affinity-block.js carried A018 'Tibetan Peoples' and A019 'Himalayan Peoples' —
live on the pray-tools API — with colours measured to WCAG 1.4.11's 3.0:1 non-text
floor (A013 5.28 unchanged, A018 7.69, A019 3.53; sibling separations 2.61/1.46/1.79).
country-posters' copy covered only A001–A017, so 134 rows (6.4% of 2,107) fell to the
grey fallback #95a5a6 — the "bunch of grey colours" report. exp-shepherd confirmed
that code path headlessly. So the correct copy wins with no judgement call, and the
move FIXES country-posters rather than changing it.
FOUR CONSUMERS, NOT THE TWO THE SPEC COUNTED, and finding that is the sharpest
argument for the migration: research-dashboard/index.js and dashboard-plus-grid/index.js
each imported `../doxa-research-map/src/colors/affinity-block.js` — a cross-plugin
RELATIVE import into another plugin's PRIVATE folder. Both now import @map/colors/.
Only the build found this; no gate names cross-plugin reach.
THE GLOB STAYS in both plugins' index.js, deliberately, and is documented there:
resource.js is still plugin-local pending C1b, so deleting the glob would unregister
it and colour those pins by the soft fallback — the same silent wrong-colour class
being fixed here. Deleting the two STALE FILES is what stops the stale copy winning.
THE REJECTED-CANDIDATE NOTE MOVED ABOVE `export const PALETTE`, and this is a real
constraint rather than tidying: check:one-palette slices from that export to END OF
FILE and matches every hex, despite its own comment claiming it reads "only the
PALETTE export". #AF7AC5 was rejected for A019 at 2.95 — under the accessibility
floor — and mentioned below the export it would have been DEMANDED of every consumer
bundle. The note keeps the reasoning without creating the obligation; the gate bug
itself is reported separately since internal/ is not mine to edit.
The legend needed no change and that is by design: useAffinityBlockLegendData resolves
via getColorStrategy(COLOR_MODES.AFFINITY_BLOCK) — the same strategy the pins use — and
reads labels from the data, so map and legend gain A018/A019 together. Its comments
claiming the strategy lives in a plugin folder were corrected; they now describe where
it actually is.
GATES: check:one-palette PASS — 8 strategies, 16 bundle/palette pairs (baselined at 6
and 12 before the move, so a red would have been mine). check:library-index PASS after
regenerating the derived menu. check:no-free-identifiers PASS. All four consumer
bundles build; their artifacts reverted for studio.
library/lists/lists.json carried one English label per list, so every surface
that showed a list's name printed English in every locale (reported by upg100,
which worked around it inside the packet's own strings).
- A `lists` catalogue area, library/i18n/locales/<lc>/lists.json: a label and a
definition for each of the five lists, in all 11 locales.
- listLabel(id, t) / listDefinition(id, t) in peopleGroupList.js resolve
lists.<id>.label through the caller's translator (useI18n's t, tStatic, or any
key -> string), accept ids and aliases ('700'), and fall back to the
registry's English. The module still imports no i18n runtime, so it stays pure
and testable in Node.
- The registry's labels are now documented as the English fallback only, and the
doxa list gains the definition its catalogue entry has.
- check:list-prop-unit (24 cases, was 17): the Arabic and German catalogues come
through, a missing translator or a raw key falls back to English, the English
catalogue must equal the registry word for word (a drifted registry label fails
it, naming the list), and every locale must carry both fields for every list.
- documentation/reference-universal-props.md: read names through listLabel.
Headless: list-prop-unit, i18n-parity, i18n-never-translated,
no-free-identifiers and universal-props pass. No consumer calls listLabel yet
(the packet has its own strings), so nothing renders differently until one does.
25 stale shots (derived from check-shot-freshness on this tree), shot with internal/readme-shots.mjs
in its tokenless default ('token: none, by design (engine auto -> MapLibre)'). 24 images changed,
1 byte-identical (cap4-upg100-poster-wall). check:shots PASS (25 of 26 compared; deploy1-iframe
skipped as not a /dx-maps/ leaf).
The list table is generated from library/lists/lists.json on main (ids, aliases, labels, default
doxa), and the behaviour from peopleGroupList.js: Tier A shows the DOXA groups in the list, an
unknown list falls back with a warning, a membership file that cannot load shows no groups. The
header toggle is from plugin-studio.vue (setHeaderHidden, remembered in localStorage). The CSP
section was already accurate: check:no-eval and check:cdn-pins exist, and cdn.js pins exactly
cdn.jsdelivr.net and api.mapbox.com.
vue-i18n's default message compiler builds every message with `new Function`, so 20 of 25 bundles needed
'unsafe-eval' and a partner with a strict Content-Security-Policy got no translated text (CRITIC-4).
vite.config.js defines __INTLIFY_JIT_COMPILATION__ (vue-i18n 9.14: messages compile to an AST, no eval),
and the bundler drops the eval path: rebuilt doxa-simple-map carries 0 `new Function` (1 before).
check:no-eval (gates:post): no built bundle contains new Function / eval, and vue-i18n in JIT mode renders
real en/ar catalogue messages with Function and eval blocked. documentation/how-to.md lists the CSP an
embedding page needs (style-src 'unsafe-inline' for the shadow <style>, worker-src blob:, tile hosts).
Every map showed DOXA's people groups, and only those. The same maps must serve other projects'
lists: Project 7000 (unreached), 4700 (frontier), 700 (frontier of 500,000 or more), 2000 (DOXA).
The list is now chosen the way the engine is:
profile-config `list` > ?list= > window.MAP_LIST > 'doxa' (exactly today's behaviour)
with ids or project aliases ('700', 'project-7000').
- library/lists/lists.json: each list is a FILTER over the IMB / peoplegroups.org dataset already
in the repo (Lostness Priority, Population, Engagement Status), never a hand-kept roster.
- library/scripts/people-groups/build-lists.mjs (`bun run lists`): writes each list's DOXA members
to data/people-groups/lists/<id>.json, joined the way the Vision Card funnel joins, so the
counts agree with it: unreached 7,341 (2,082 DOXA), frontier 4,754 (1,745), frontier-500k 737
(71), unengaged 2,982 (1,929).
- library/lists/peopleGroupList.js: resolvePeopleGroupList and applyPeopleGroupList(rows, cfg).
The default list returns the same array, unfetched. A list whose membership cannot load shows no
groups (fails closed) rather than DOXA's whole list under another list's name.
- Tier A, every production map: DataSourceManager applies it (and the maps that build one pass
their profileConfig), and so does every direct people-group fetch: people-groups-grid, the
Big Picture header, research-dashboard (data, CSV export; the DOXA-wide /statistics baselines
are skipped under another list), Regions (list, dashboards, their table), UPG-100 (4 fetches +
the poster wall), Gospel's roster, and the Experimental upg-neighbor and Wall. The composites
thread `list` to their children like `provider`.
Gates: check:universal-props gains a `list` column (LIST-FETCH, LIST-DSM, LIST-KEYS; its
self-test now plants 9 bypasses) and passes across 18 bundles, with all-people-groups allowed:
it draws the IMB dataset, so its list is Tier B. New check:list-prop-unit (Node, 17 cases, in
gates:post); poisoning the ladder order or deleting a membership file fails it. Documented in
documentation/reference-universal-props.md section 5.
Headless: all 14 touched bundles build; check:composites-mount 20/20 and check:composite-interop
17 links pass on this tree; no-orphan-gates, script-flags, quoted-scripts, doc-scripts pass. Not
rendered in a browser.
One page that says, for each prop the Plugin Studio header sets for every plugin, which channels
can carry it (header, profile-config, URL, window, attribute), which one wins, and the default;
how the studio hands it to a pane; and the rule a plugin follows to honour it. Ends with the open
points found while writing it down (tokenUrl has no reader, the token-clear bridge, raw maps on
window.DOXA_TOKEN, ladders that disagree), each routed as a defect. Scope is the four props the
header sets today; anything wider is DECIDE-UNIVERSAL-PROPS-SCOPE.
From the critic's SRI check. how-to's embed section had no word on it; the CLI section said the
hash changes on every build, which is only true when the bundle's bytes change.
Under the Playwright policy only rega runs a browser here, so this
check's result has to come from someone else's eyes. But the partner
page only ever existed INSIDE check-embedded-rtl — two createServer calls
on ephemeral ports — so "open a partner page at a foreign origin" was not
a thing anyone but the gate could do. NEEDS-EYES rows 3, 4 and 6 name a
runnable probe or an exact URL; row 7 was mine, and it named a construct
that lived in my own process.
--emit writes the same pages to disk, with the two-terminal recipe and
what to look at, so the item is reproducible by whoever has the browser.
⚠ THEY GO IN documentation/, NOT _qa-cycle/. I wrote them there first and
then checked: _qa-cycle is gitignored (.gitignore:113), so the files
could never have travelled to the person who needs them — which is the
entire point. documentation/ is tracked and already carries
README-embed-example.md.
⚠ AND THE ?lang=ar FIXTURE CARRIES NO locale IN ITS profile-config —
1100 bytes against 1114. The two channels reach the document-direction
code by different routes and detectLocale() reads the URL FIRST, so a
config that also supplied the locale would let a reviewer believe the URL
had done it when it had not. The first emit produced all three files
BYTE-IDENTICAL, which would have handed someone three pages they could
not tell apart; I only caught it by checking the emitted sizes rather
than trusting that the flag had taken effect.
The CASES array now carries the ?lang= row itself, so this stands alone:
the emit produces every fixture committed beside it. It previously
depended on the library/ commit for that row, which left the repo with a
fixture the tool would not regenerate — worse than either half.
Also: --emit was being consumed as the baseUrl positional ("SKIP —
nothing served at --emit"). Flags are filtered out of it now. That is the
same bug, in the same shape, that check-leaf-english-leaks had three days
ago — worth knowing it recurs in any gate that takes both a positional
and flags.
- Deployment is now organised around what the build makes: (1) one web
folder (536 files, 76 MiB), embedded by iframe and JavaScript (proven) and
WordPress (just for fun); (2) the mobile app (Capacitor, built); (3) the
desktop app (Tauri, proof of concept). A hosting table says where the folder
can go: dx-life's public/, Cloudflare Pages drag-and-drop (fits its 1,000-file
/ 25 MiB limits), GitHub + jsDelivr (JS embeds only: measured, jsDelivr serves
HTML as text/plain), or any web server, with the one _headers line JS embeds
need. No Docker. The diagram and the map-cruncher picture say three outputs.
- Languages: 11 locales via ?lang=, Arabic right to left, with an Arabic shot of
UPG-100 (Big Picture still leaks English in Arabic; reported). The partner
profile-config locale is deliberately not documented until it is fixed.
- check-shot-freshness and check-shot-distinctness parsed only '...' paths, so
after 44ef31b2 turned them into `/${TREE_NAME}/...` templates they saw 2 of 26
shots: distinctness failed, freshness passed vacuously and hid 22 stale shots.
Both now accept either form and resolve TREE_NAME, and exit 75 (NOT MEASURED)
unless every `await shot(` call is parsed (the one dynamic dx-life call is
marked). Negative-tested. The 22 shots are retaken and checked by eye.
- how-to: the shell no longer overwrites the Studio (it now lives in the output
folder); README serve line points at doxa-maps/index.html.
- Docs follow the rename to dx-maps (09014f8a): every output-folder path and URL in
README, how-to, reference, CONTRIBUTING, the CLI section and the develop guide now says
public/dx-maps/. The repo name, the config file name, the WordPress plugin folder and
the doxa-* tag names are unchanged (tags are a separate landing).
- Hosting: a live example, the dx-life branch research-gallery, which drops the folder
into dx-life public/ and fills /research with the Research Gallery.
Driver decision: no embeddable URL carries "doxa" in its folder. The tree's name is one line in
vite.doxa-maps-build-config.json ("name": "dx-maps"); everything else follows it.
- __DOXA_TREE_NAME__ (the vite define rega landed, from MFE_NAME = internal/_tree-name.mjs) bakes the name into the
bundles for the runtime code that must recognise the tree in a URL. StackLayout.vue's
resolveBundleSrc anchored on a literal /doxa-maps/ and would have broken every composite's nested
bundles on a renamed tree; it now anchors on the define. The Research Gallery's dev fallback uses
it (one reader: rega's define, not a second one).
- The native shells follow via internal/sync-app-shells.mjs: capacitor webDir public/dx-maps, Tauri
frontendDist ../../public/dx-maps (its window url is tree-relative since the one-folder landing).
- The WordPress contract test reads the name from config instead of carrying three literals.
- check:locale-reachable extracted detectLocale alone and died with "localeFromUrl is not defined"
after 09d03c60 factored a helper out; it now follows the calls it makes (already red on main).
- generate-tree removes its temp asset pool even when it crashes (a failed build leaked ~50 MB into
/tmp each time; measured on a full disk).
- Capability exemplars regenerated for the new paths.
Proof: rm -rf public && bun run build -> public/ = README.md + dx-maps/; the 25 built bundles carry
zero 'doxa-maps/' paths (scanned); check:sri, check:drop, check:parse, check:public-hygiene PASS.
Tag names (doxa-* -> dx-*) are the next, separate landing.
Dragging the side menu's pin-dim slider changed nothing: it wrote a store field
that did not exist, the same defect as the clustering picker beside it and found
while fixing that one.
The obvious fix would have created a worse bug. The pin layer's circle-opacity
already has an owner — legend dimming writes a ['case', …, 1, 0] expression — so
a flat slider value from a second writer would silently erase dimming, or be
erased by it, depending on which control the reader touched last. Nothing would
have looked broken; the map would just have stopped answering one of them.
So there is now exactly ONE writer and the value is the PRODUCT:
opacity = slider × (1 for a matched pin, 0 for a dimmed one)
The slider scales the whole layer, dimming still dims relative to it, neither
wins. Seven call sites that each wrote opacity by hand now go through it.
MEASURED, reading the live paint property rather than the store:
clustering off 1
clustering on, 0.5 0.5
+ a dimming selection ['case', <religion is B>, 0.5, 0]
slider back to 1.0 ['case', <religion is B>, 1, 0]
One thing the gate had to learn, and it is written down in it: a REGION
selection does not dim at all — it sets a layer filter, so non-matching pins are
removed rather than faded. Testing that path would have reported "the slider
erased the dimming" about code that never writes opacity.
★ ALSO: the Globe/Flat control's 24px touch target now applies at EVERY width.
The first fix scoped it to `@media (pointer: coarse)`, reasoning that a mouse
needs no touch floor. Wrong twice: WCAG 2.5.8 is a TARGET SIZE criterion, not a
touch one, and the query meant phones passed at 34px while desktop stayed at
73.5×23 — the same one-pixel failure, now invisible to a phone sweep. Measured
after: 73.5×34 at 390, 430 and 1440.
Driver decisions (cards BUILD-OUTPUT-IS-ONE-FOLDER, BUILD-PLUGIN-STUDIO-IS-THE-OUTPUT-FRONT-PAGE).
- public/ holds only <tree>/ and a generated README.md. The build no longer writes public/index.html,
public/assets/ or public/__gallery/; `rm -rf public && bun run build` recreates exactly these two.
- The Plugin Studio is <tree>/index.html (tree-base "."), replacing the staging-forge chooser; nothing
is written above the tree. check:staging-lang, which tested that chooser, is retired with it.
- Assets are gathered in a temp directory and the fetched allowlist staged into <tree>/assets/ as
before. fonts/, images/, brand/ and legacy/ existed ONLY in public/assets: they are sources and move
to data/ (git mv). Poster previews keep their tracked sources in plugin-designs/*/assets/previews
(all 378 verified present).
- __gallery/ is archived out of public/ (a copy kept in the umbrella's _qa-cycle/archive) and out of
the build; generate-gallery renders only on request, into a directory you name, never public/.
- Native shells load the output folder: capacitor webDir public/<tree> (cap copy needs an index.html
there), Tauri frontendDist + window url relative to it. internal/sync-app-shells.mjs keeps both in
step with the tree's name on every build, so the coming rename stays one config line; the Capacitor
start page resolves its leaf tree-relative.
- dropin.include now lists only extras (README.md): the tree is always shipped and never named twice;
excludes are tree-relative ({tree}/...). clean.mjs removes the old root entries from existing
checkouts; the build lock and public/.gitignore's rules move out of public/.
- check:wp-embed read the BUILT manifest from gates:pre, before the build, so a from-scratch build
could never pass; it runs in gates:post and reads the tree name from config.
- Stale bundles refreshed by the clean build (key-cities, template-bundle, wall-of-people-groups);
the CLI doc's quoted scripts re-synced; capability exemplars regenerated; the '#' note leaves
check:stale-sweep-claim's command (it was already in //scripts).
Proof: rm -rf public && bun run build -> public/ = README.md + doxa-maps/; check:sri, check:drop,
check:parse, check:public-hygiene, check:quoted-scripts, check:capability-exemplars PASS; all 57 pages
(the front page + 56 leaves) mount under /doxa-maps/ on this tree. check:facet-cascade is red as on main.
Choosing "Native" in the research map's side menu drew nothing. So did MST, and
so did Network. A control that does nothing is worse than one that is absent:
it tells the reader the map cannot do what it can.
TWO CAUSES, AND FIXING EITHER ALONE WOULD HAVE LOOKED LIKE A FIX.
1. mapStore never declared `clusteringMode`. The picker wrote to a property no
reactive system was watching, so useMapClustering's `mode` kept its own
internal default and the click changed nothing.
2. The legend's Clusters checkbox force-set 'mst' "regardless of what's in the
store", with the mode watcher "kept as a no-op for forwards compat" — its
own words. Even with (1) fixed, one tick of that checkbox would have snapped
Native back to MST: a control that works until you touch the other one.
So the two controls are now two views of one state. clusteringMode IS the state
('off' means disabled); the picker sets it; the checkbox maps onto it, promoting
only 'off' to 'mst' so the checkbox alone behaves exactly as it always has. They
can no longer show different things. The dim-slider's clusterPinOpacity got a
real home in the same pass, for the same reason it was missing.
★ THE GATE ASSERTS ON WHAT IS DRAWN, NOT ON WHAT IS SET. `clusteringMode ===
'mapbox'` would have passed the moment (1) was fixed, while the map still drew
MST lines — state was exactly the evidence that let an inert control ship. The
map now reports, per clustering layer, whether it exists, whether it is visible
and how many features are rendered; the gate clicks the real buttons and reads
that. MEASURED, before and after, on the same tree:
before off 0/0/0 native 0/0/0 mst 0/0/0 network 0/0/0
after off 0/0/0 native 37/37/0 mst 0/0/1700 network 0/0/23896
(circles/counts/lines)
It also asserts no orphan layers between modes, and that MST and Network differ
— two labels for one behaviour is the same defect as a dead option.
One measurement bug caught on the way, worth recording: a fixed 6-second pause
reported MST as drawing nothing while Network drew thousands of lines. MST over
~2,100 pins is quadratic and had simply not finished. The gate now waits for the
drawing to settle rather than guessing a duration — a gate that calls a slow
feature broken is worse than no gate.
A reader on an Arabic page saw a handful of English words among the Arabic:
the table's "People Groups" title, its count line, the Engagement column, the
Gospel Access chart title, and the side drawer's close button. Every one of them
had a correct Arabic entry sitting in the catalogue; nothing looked it up.
THE CHECK CAME FIRST, AND IT REPLACES A SWEEP THAT PASSED A BROKEN TREE.
The old method sorted every rendered string into third-party / data / chrome and
counted only chrome. That split is a guess, and it guessed wrong exactly where
the defect lived: single words like "Region", "Prayer" and "Country" are as
plausible as values as they are as labels, so they scored as data and the sweep
reported zero on a page that was visibly leaking.
check:composite-locale-leaks asks a question with an answer instead:
the English catalogue holds this exact string as a value, AND
this locale holds a DIFFERENT value for that key, AND
the page still renders the English one.
No judgement about what kind of word it is — if the translation exists and the
reader is not getting it, that is the defect. The two surfaces a lookup cannot
judge (vendor map chrome, and table/facet cells carrying API values) are muted
by NAME with a stated reason, never by word, so muting one can never hide the
next real leak that happens to reuse it. The gate failed on this tree naming 9
strings before any of them were fixed.
Also threaded here: both composites now accept `lang` / `locale` in
profile-config and pass it to the children they mount, alongside `tk` and
`provider`. A partner writing {"lang":"ar"} reached nothing before — the knob
was documented and read by nobody.
The prayer facet's label in DashboardResearchFilters is now a catalogue getter
too. Measured before touching it: dashboard.config.js does define prayer_level
and FACETS spreads the config last, so that literal never rendered for this
config — it is a default for a config that omits the facet, and it stays, with
the merge order written down so the next reader need not re-derive it.
Four strings remain English with no catalogue entry at all — "Top countries",
"Select all {n}", the table dock's title, and the singular "group". They need
wording in ten languages that cannot be composed from existing entries, so they
are a DECIDE card for the Driver rather than translations invented here.
A new "Experimental shelf" section pictures the two newest shelf plugins, each
shot from its own page after it finished loading: the Wall of People Groups
(all 2,107 groups as photo cards on one zoomable prayer wall, 7fec7426) and the
Prayer Walk Planner (full screen from any embed and a sharp HD print,
30e6e569). The Research Gallery tree now lists all seven shelf plugins
(adds pplr-data-maps and wall-of-people-groups). Shots staled by 92a81ed2 and
later rebuilds are retaken.
A reader who picks a religion family on the map now sees the grid narrow to the
same peoples, and a language selection travels the same way. Region already
linked; these two did not, because the two halves do not name a religion the
same way and nothing yet proved what the honest translation was.
The map filters religion through its legend's flat tab, keyed by the FIRST
LETTER of the religion code ('B'); the grid filters through a derived axis that
runs library/colors/religion.js forward per row ('BLA' -> 'Buddhism'). One
shared table, read in opposite directions, copied by neither half — so the wire
carries the family NAME and each side resolves it against its own data.
Two measurements changed the design on the way:
- mapStore.filters.religions, which the map's filter panel writes, is read by
NOTHING: the mapLayers.applyFilters() it was meant to reach is called and
never implemented anywhere in the tree. A link routed through it would have
set state, passed a smoke test and dimmed no pin.
- the map's legend selections are mutually exclusive by design, so it can
light exactly one of region/religion/language. The composite mirrors that
rather than letting the grid hold three facets the map cannot show.
Religion links at the FAMILY and not at the code, deliberately: a map cannot
distinguish 'BLA' from 'BMA' — both dim as Buddhism — so linking at the code
would promise a precision one half cannot keep.
check:linked-vocabulary proves it by measurement rather than by reading: it
compares the total reached over the composite's wire against the total the grid
gives for the same facet on its own (83 of 2,107 both ways), and runs the MUTANT
first — the legend key 'B' instead of 'Buddhism' — failing as NO-TEETH if the
wrong mapping scores the same as the right one. Measured: mutant 0, real 83.
The CLI doc's apk:debug quote was realigned in passing; package.json had gained
app:start-page and the doc still described the older chain.
simpleresearch fixed the phone toast (2221d11b): at 390x844 the "Tap a
coloured dot" prompt now sits above the Progress sheet and the legend reads in
full (checked by eye). The phone row now shows seven maps in two rows of four;
Big Picture keeps its note (desktop by design; on a phone the Research Gallery
opens on Simple Research). Gospel Resources shots retaken after its bundle was
repaired on main (57fecfd0 replaced a bundle committed with merge-conflict
markers in 52ab6089). Other stale shots refreshed from the latest builds.
A row of phone screenshots (390x844, taken after each map finished loading)
under the production gallery: Regions, UPG-100 List, Prayer Progress, Cities
with Internet and Gospel Resources. Big Picture is desktop-only by ruling, and
Simple Research is left out until its phone layout is finished: its "Tap a
coloured dot" prompt still covers the Progress legend at 390px. The README
says both plainly.
- A gallery of the seven production maps (Big Picture, Simple Research,
Regions, UPG-100 List, Prayer Progress, Cities with Internet, Gospel
Resources) plus the Research Gallery, each shot from its own standalone page
after its map finished loading. Five new shot definitions in readme-shots.
- One 5 x 5 table: every capability ships every way (iframe, mobile and JS
embed today; WordPress and desktop proven, not yet in production).
- The "map cruncher" converge/diverge picture, folded.
- Repository layout shows the plugin-designs and shared library trees, and a
folded "plugin or library?" section states the rule: write your app in its
own plugin; put code in the library only if it is reusable and parameterized.
- Stale shots retaken from the latest builds.
Closes the README parts of DOCS-README-FOR-A-VISIONARY and BUILD-README-COMPLETE-HANDOFF.
Shot from the rebuilt bundle at 89bd9c5d, after its owner marked the resize
done, with the wait-for-loading fix: the map shows every region, all 27 zones
and all 2,107 pins; the composite and phone shots show the new banner cards.
The mobile dropdown no longer claims "one poster per row", which the phone
layout no longer is.
The derived table drifted from the manifest when main's bundles arrived — two
rows moved and one fell off the end. It is generated, so the fix is to regenerate
it rather than to hand-edit: 33 rows now match what the build computed.
readme-shots captured on a fixed timer once the page's elements existed, so
maps were caught mid-load: the Big Picture globe came out as a bare white ball
and the UPG-100 map without its resources. Every request is now tracked, and
the capture waits until nothing has been in flight for 4 s (capped at 60 s,
with a warning if the cap is hit), then 1.5 s for the last tiles to paint.
Retaken with the new wait and checked by eye: Big Picture (country shading and
people-group dots on the globe), the research map, the three prayer /
engagement / adoption presets, all people groups, the Plugin Studio and the
iframe host. The UPG-100 shots are deliberately NOT retaken yet: its owner is
still resizing that map.
- The Goal and Isaiah 40:3 lead the README, byte-for-byte as written on the
public repo (driver-web-edits.patch), so the next export keeps them.
- Each of the 5 capabilities and 5 deployment methods is now a one-line
summary, its picture, and a collapsed "More about…" dropdown. 16 dropdowns,
none open by default, so a reader can scroll the pictures and summaries.
- WordPress / Disciple Tools and desktop say what is true in plain words: we got
a JAMstack map running there; proven, not yet a production feature; the
Research Gallery could become a strong desktop app. Their cards match.
- The agent-swarm section explains the problem in plain words (one shared
library and one build means one bad change breaks every map), with a git
glossary and the commands folded away.
- Get it running now includes your own free Mapbox token (the public copy
ships without one), and a dropdown on changing the studio itself.
- The diagram has eight plugin nodes, main at the centre behind a padlocked
merge-queue ring, and a plain-language "How main is protected" strip.
Moved out (kept outside the repo, in the team's internal tracker):
- AGENT-BRIEF.md: operating rules for the team's own agents
- documentation/slide-deck-outline.md, documentation/README-longform.md:
internal pitch material; the README and why.md cover the same ground
- plugin-designs/gospel-resources/SOURCES-CHECKED-AND-REJECTED.md: research log
- .claude/ (an editor status line), .forgejo/ (a CI workflow that never ran),
_archive/ (a retired staging page; already gitignored but still tracked)
Seven code comments that named these files now describe the rule instead.
Fixed five dead links left from the docs/ -> documentation/ move, including
the one in template-bundle that every new plugin inherits.
The delegation diagram now shows the whole model: the Plugin Studio with the
shared library and the merge queue into main, seven production plugins with
capability badges and the Experimental shelf, one agent per plugin (a roaming
agent for the shelf), the shepherd's oversight ring, a legend, and the five
ways a build ships.
- Methods 4 (WordPress / Disciple Tools) and 5 (Tauri desktop) get a card with
the platform's logo and a PROOF OF CONCEPT badge; logos are the CC0 Simple
Icons marks, credited under the cards. SVG sources beside the PNGs.
- The Plugin Studio screenshot is retaken from the 03:43 studio rebuild, and six
other shots that rebuilds had made stale are retaken; all checked by eye.
- The quick "I want to…" table moved from the bottom to the top, with
BUILD_ALLOW=1 and the no-key MapLibre option.
- Wrong facts corrected: `bun run dev` serves the Plugin Studio at `/`, not
the gallery; the build-config `token` row said "leave empty", the reverse of
the baked public-token decision; `check:shots` now compares bundle hashes.
- Two table rows stranded after a code block rendered as stray pipes; they
now sit in the preconditions table they belong to.
- Removed a `cd` into a retired branch folder, platform host names and an
internal catalog repo name.
- The dated measurements moved to a "Notes and history" log. 7,255 -> 3,719
words. Every link, anchor and script name checked.
The per-method sections now lead with install + commands + the one thing that
matters, and the dated measurements move to a "Notes and history" log at the
end. Statuses match the README: iframe and JS embed production, mobile built,
WordPress/Disciple Tools and Tauri proofs of concept. The Tauri artifact sizes
disagreed within the doc (67/60 MB vs 65/58 MB); the log records both runs.
The package.json scripts block that check:quoted-scripts compares is untouched.
2,590 -> 1,922 words.
The first doc a DX developer follows now opens with one table (what you do,
the command, what "done" looks like), then one short section per step with
its command. The nine dated measurement notes move, unabridged in substance,
to a "Notes and history" log at the end. 2,612 -> 1,501 words.
Also corrected two stale facts: the deployment status table now matches the
README (WordPress and Tauri are proofs of concept; mobile is built), and the
Experimental shelf is described as it works, not by a 2026-09-21 row list
that no longer matches.
- The clone line pointed at the private source repo. It now clones the public
giveaway, doxa-open/doxa-maps, which clones anonymously (checked with
ls-remote and no credentials), so a DX newcomer can onboard from the README.
- The README linked AGENT-BRIEF.md, which the giveaway export removes; it now
points to documentation/CONTRIBUTING.md.
- README-longform's two SPA images were written relative to the wrong folder
and rendered broken; fixed.
The build refuses (exit 75) without BUILD_ALLOW=1, so a DX developer
following the tutorial, the golden path or the CLI matrix hit a refusal on
the first build. The eight runnable lines now include the flag, and the
golden path says why it exists and how to build one plugin without it.
Also: the golden path said "From doxa-maps-plugin-studio/", a retired branch
name; a clone lands in the repo root.
The Research Gallery tree said only "every other bundle". It now lists the
five the shell actually shows today, in its own order: prayer-walk-planner,
upg-neighbor, all-people-groups, country-posters, doxa-vision-card
(22 bundles - 7 curated tabs - 10 in NOT_EXPERIMENTAL).
Eight screenshots were shot from bundles that have since been rebuilt;
retaken from the current build and checked by eye.
Deployment method 2 now shows the UPG-100 prayer lists and posters at phone
size: one poster per row, print options wrapping in full. Shot from the
rebuilt bundle at 88a566f, not from source. The capability-4 shots are
retaken from the same build, so they show the print-option fix too.
A retake that came out byte-identical left the PNG unchanged, git dated it by
its old commit, and the gate called a correct, just-taken picture stale. No
clock can see that case. readme-shots now writes shot-bundles.json (shot ->
sha256 of the bundle it was taken from), and check:shots compares that hash
with the bundle built now; dates are only the fallback for unrecorded shots.
Negative-tested: one byte appended to the doxa-simple-map bundle fails exactly
the three cap2 shots; restoring it passes.
Also retook the eight shots a rebuild had made stale since e2beec9.
readme-shots now clicks the rail's first plugin row (.st-row[role=treeitem],
not a section heading), keeping .vtl-tab as a fallback. Without that, the walk
fell through to the nested gallery's own tabs.
Shot from the rebuilt studio bundle the studio agent marked stable (0740a94),
not from source: the redesign was on main for half an hour before its bundle was.
- The diagram is redrawn as concentric rings of uniform circles, not filled
bands: the Plugin Studio ring labels itself, the seven plugins sit on the
plugin ring, one agent per plugin sits outside it joined by a line and an
arrow ("each plugin has one agent responsible for building it"), and one
shepherd sits outside the agent ring. The race framing is gone. SVG source
updated with the PNG.
- Deployment method 2: Simple Research was never built for phones, so it is
no longer the mobile example. UPG-100 is; its screenshot lands when its
phone layout is finished. The shot is removed from readme-shots, with the
reason and the planned replacement in a comment.
- Retook nine screenshots that the latest bundle rebuilds had made stale.
Four elements shipped with this broken and nothing could see it, because every
existing gate loads a composite on its OWN generated leaf — and that leaf bakes a
token and assigns window.MAP_TOKEN, supplying the very thing under test. The
failure exists only where there is no leaf: the partner page the published embed
snippet is written for.
The gate serves the built tree on one free port and a partner page on another, so
CORS is exercised rather than assumed, and asserts per composite:
tk given only {"tk": <the token this tree bakes>}, a canvas appears and
api.mapbox.com actually serves it
provider given only {"provider":"maplibre"} and NO token, a canvas appears and
there are ZERO requests to api.mapbox.com
Zero is the assertion, not "it rendered": a build that quietly fell back to
mapbox with a baked key renders perfectly and is exactly the regression this
exists to catch.
★ IT FAILS ONLY WHERE THE EVIDENCE POINTS AT THE KNOB. The first draft failed
NINE composites for "no map canvas under a minimal config" — an accusation it had
not measured, since several of those elements simply need a `profile` key to
choose which profile to show. It now distinguishes: a token WAS supplied and the
element still says there is none, or maplibre was asked for and mapbox tiles
still loaded — those are findings. No canvas and no complaint is UNMEASURED, and
says so with the reason. A gate that red-lights other people's plugins over a
claim it cannot support is worse than no gate.
COST, because a slow gate stops being run: a flat 14s-per-case wait became a poll
for the canvas, and the provider case short-circuits when that element's tk case
drew no map — there is nothing to compare against, so a second page load was pure
waste. 5m38 -> 3m15.
GREEN on the landed tree: dashboard-plus-grid, research-map-plus-grid, stack-demo
and plugin-studio all pass both knobs, every provider case at ZERO api.mapbox.com
requests. plugin-studio only reached zero once the cap-5 shell started setting
window.MAP_PROVIDER — profile-config alone reaches the child a composite writes
config for, while the global rung reaches a map nested at any depth, which the
studio-inside-SPA-inside-composite chain needed.
gates:post is 44 steps; the quoted CLI doc is updated to match.
- The five capabilities each lead with a current screenshot; the Research
Gallery's composition tree folds into a <details> block.
- Deployment: (1) the prayer map in a host page's iframe, (2) the Simple
Research dashboard as the phone app, (3) the whole Research Gallery
JS-embedded on dx-life's research page. WordPress/Disciple Tools and Tauri
are stated as proven proofs of concept, not production-ready.
- The Plugin Studio closes the README with its current screenshot, the
four-command start and the command table.
- 4,151 -> 1,330 words. Every image retaken against the current build.
- readme-shots: deploy2 shoots research-map-plus-grid at phone size; deploy3
shoots a running dx-life (DXLIFE_URL=...), skipped when it is not set;
an http(s) path is used as-is. Retired four images nothing shows any more.
The repo-hygiene agent found this in a fresh 1440px screenshot, which is the
whole lesson: .pfs already had width:82%, so reading the CSS said it was fine.
Flex items default to min-width:auto and refuse to shrink below their content
width, so four controls — Prayer List, Card Deck, the stock select, Poster Map —
pushed the row open regardless of what the container declared. A constrained box
does not constrain flex children, and only geometry shows that.
Measured at 1440px on cap4-upg100-mfe: 27 of 27 cards overflowing, worst 110px
past a 212px card, 36 intrusions into a neighbouring card's box. After
flex-wrap:wrap plus min-width:0 on the children: 0 and 0, with all 108 print
controls still a usable size.
That second number is asserted deliberately. "Nothing overflows" is trivially
satisfied by a row whose buttons collapsed to zero width, which would be a worse
product than the overflow it replaced. check:poster-card-fits holds both, and is
mutation-tested: reverting the fix reproduces the reporter's exact figures.
Also adds check:backlog-refs — a card may not depend on a card that does not
exist, because a dangling depends_on can never be done, so the card can never
become ready and nothing says so.
Its first run disproved the finding that motivated it. I had censused three
dangling references, including the PRINCIPLE card the loop prompt lists as open
work. The gate came back PASS, and chasing the discrepancy rather than assuming
the gate was broken showed my census was wrong: it read only the builder
backlog, and all three cards exist in 01-ideation-research. Cross-phase
references are legitimate, and a single-backlog census structurally cannot see
them. Reporting three dangling refs would have sent someone hunting for cards
that were never lost.
It fails only for depends_on on an OPEN card; a dangling parent is reported and
passes, because failing on filing debt teaches people to delete the reference
rather than restore the card. Both severities mutation-tested.
The gallery is going onto the DX-life research page, and nothing had tried
it on a page it didn't generate. From a second origin, a bare
<script> + <doxa-maps-spa> drew over the host's header, and 8 of 13 tabs
showed an error card.
Three causes, each fixed in the shell:
- No :host box. The layout root is position:absolute, so it anchored to the
viewport instead of the element. :host is now a positioned block with a
default height the host can override.
- A foreign page sets none of the globals a leaf page bakes. Every data call
hit the host's own origin and got HTML back. The manifest now publishes a
`runtime` block (apiBaseUrl + the public token every leaf already carries).
The shell fills window.MAP_APP_API_URL / window.MAP_TOKEN only when the page
hasn't set them, and forwards tk in profile-config.
- Nested elements resolved assets to <tree>/../assets, a sibling pool the
publisher doesn't ship (404 live). The shell now forwards an absolute
<tree>/assets. This also fixes the iframe mode, where Cities with Internet
and Gospel Resources were failing the same way.
Also adds a `tab` attribute (plus ?tab=) to deep-link into one map, and a
`tab-change` event for the host.
check:gallery-embed drives every tab in both modes from a second origin. It
passes here and fails on the previous tree (7 findings).
EMBED.md documents the contract. The Traefik template now sends CORS for the
whole tree: live manifest.json answers without Access-Control-Allow-Origin.
check:shots compared mtimes, and a checkout writes files in arbitrary order:
every clone failed, pristine main included (11 shots "2s behind"). Committed,
unmodified files are now dated by their last commit; files with local edits,
or no git at all, still use mtime.
With the honest clock the same 11 shots really were stale (3h to 31h behind
their bundles), so they are retaken here with ONLY=. Emoji now render: the
shot machine has an emoji font. Negative-tested: a byte appended to the
doxa-simple-map bundle fails exactly the three cap2 shots.
- Opens with what dx-maps is for: seven objectives, each paired with the gate
or section that proves it is met.
- New Research Gallery section: the cap-5 app shell and the tree of cap-4..1
plugins it nests, each still independently embeddable.
- Plugin Studio: clone main (the old branch was 198 commits behind), a command
table, and a fresh studio screenshot (the old one showed a retired nav and
tofu icons).
- Agent swarm: renamed to industry terms (trunk-based development, merge queue,
maintainer), a laptop recipe (worktrees, strict ports, one builder, flock as
the queue), and a redrawn diagram whose inner ring is an actual queue with
one gate. SVG source committed next to the PNG.
- MapLibre is now described as it is: a one-prop, token-free engine.
- Removed internal credential/push instructions (AGENT-BRIEF.md covers agents).
- documentation/: fixed 19 dead links left by the docs/ move, removed internal
references, and listed the working docs in the index.
- readme-shots: ONLY=<name> retakes a single image.
The repo is built by several people at once against a single branch,
and nothing in the README said so. The new section carries the
concentric-rings diagram and states the rule that makes it work: a
builder captures main before pushing, resolves every conflict while
holding it, then releases.
Placed before "Repository layout" — it answers the question a reader
has after seeing the studio and before reading a folder list.
Censused the 67 gate scripts that are in no wave. The framing I had been using —
"coverage we are not getting" — was wrong in the more serious direction. Two of
them were FAILING on main right now. The gates exist, work, and had found
something; because nothing ran them the findings sat silently.
check:legend-source, a real product finding: the live corpus moved 2108 -> 2107
and one group crossed Unengaged -> Engaged, so the legend baseline was
describing a dataset that no longer exists. Confirmed 2107 independently against
the live endpoint rather than trusting the gate's own reading.
Updated the baseline following the procedure its header requires, repeated
rather than assumed: grepping the six figures across plugin-designs/ and
library/ finds them only in explanatory comments, so no shipping code hardcodes
a corpus total and only the baseline lagged. Mutation-tested — an off-by-8
baseline fails. Recorded that this is the third reading, that it drifts in both
directions, and that it must NOT be widened into a range: the noise is the
signal that the numbers on the page are live rather than asserted.
check:verification-tags asks whether a human ever looked at a done card. 98
could not answer. 24 of those were mine, and they are now tagged agent/state —
honestly agent rather than human, and state rather than visual, because every
one was verified by measuring rendered DOM, PDF bytes and request tallies, never
by a human looking at pixels. The other 74 belong to other agents and the
Driver; guessing their provenance would defeat the gate, so it stays unwired.
Six further gates were green and self-contained and are now wired: locale
reachability, url resolution, manifest coverage, the palette registry, and the
legend/source comparison. gates:post 37 -> 42; wave membership 43 -> 50 of 106.
One I nearly wired wrong. check:dxlife passes standalone but exits 2 by
documented contract — 0 unchanged, 1 drift, 2 clone not found — and the wave
runner grades any non-zero as failure, so a checkout without the sibling clone
would go red on a precondition. Caught in the fresh-checkout run, unwired, with
the reason recorded on the script so the next census does not re-add it. "Can it
run" and "is it wave-safe" are different questions.
42/42 with servers up; 31/42 passed and 7 SKIPPED on a fresh checkout, every
skip named.
check:provider-switch is a good gate — it counts requests BY HOST rather than
reading window globals (which stay defined after a swap), and it allows exactly
the api.mapbox.com URLs the generated leaf declares in its own head, so a
half-fix at 18 requests cannot pass.
It had a package.json entry and was in no wave, because it REQUIRED a URL
argument and exited 2 without one. So it only ever ran when somebody typed a
URL — the same present-but-never-executed shape check:packets-all had before it
was given a fixture.
Wiring it as-is would have turned every server-less build red rather than
silently green, which is the less common half of that failure. It now defaults
to the upg-100 leaf and SKIPs in the form the wave runner recognises, so the
skip is counted and named instead of grading as a pass or an error.
gates:post 36 -> 37, README re-synced. Verified both ways: 37/37 with a server,
and 27/37 passed / 6 SKIPPED on a fresh checkout with none.
Found while re-verifying BUG-UPG100-IGNORES-THE-PROVIDER-SWITCH from scratch
rather than trusting its done status. The card holds: ?provider=maplibre drops
Mapbox traffic 19 -> 5 on the upg-100 leaf, and all 5 are the page's own
declared preloads, so zero runtime calls remain. The same run independently
reproduced the four residual URLs in BUG-LEAF-PAGES-PRELOAD-MAPBOX-EVEN-UNDER-
MAPLIBRE, which stays untaken — its fix is in generate-tree, the tree verb
bundler-1 owns. When those preloads go, this gate's allowance drops to 0 and it
holds the new floor with no edit.
The legend's Export > Prayer packet action is the path a reader clicks. The
build-time file is the one nobody clicks. The reader's packet was the less
verified of the two.
check:packet-render gained six assertion families over recent ticks — the 12
prayer points, the wheel's 12 LABELS rather than just its 12 segments, page 1's
geography, the print layer, the image census, the build stamp. Each was found by
mutation and each catches a real "looks fine, is empty" shape. None existed
here.
It also printed "Mark 5 in full : undefined" beside a PASS for as long as it has
existed: the measurement is r.mark5Len and the print read r.mark5, which is not
a key on r. The assertion always read the right key, so the requirement was
protected — but undefined next to a PASS reads as a gate that does not know what
it is doing. That is the inverse of this tree's usual failure, where a number is
printed and never asserted on.
A mistake caught before it became a false report. The new geography assertion
went red immediately with "no map svg on page 1", which would have read as a
serious product defect. It was the harness: this gate called buildPacket with an
EMPTY mapSvg, while the real export path fetches posters/<zone>.html and lifts
its <svg>. The shortcut was not just skipping an assertion — it meant the gate
never exercised mapCss() in a browser at all, which is the one thing it exists
to prove. It now fetches the map the way the product does and reports 13
geography paths, the same number check:packet-render gives for laos. ISO3 and
ZONE are also different strings ("lao" vs "laos.html"), so even a naive fetch
would have missed; PACKET_ZONE names it.
Mutation-tested: blanking the wheel labels gives 0/12 labelled and fails; hiding
content at print time gives 0 embedded fonts and fails.
i18n verified on the browser path for the first time. A partial locale with 3 of
12 points renders 12/12 points — the per-key merge holds here too — with the
points heading translated, the wheel heading falling back to English, Mark 5 at
2220 chars, and nothing blank.
Also made wave-safe: without a server it threw an unhandled navigation error and
dumped a stack trace, which is why it could never join a wave. It now SKIPs
cleanly naming its precondition. gates:post 35 -> 36, README re-synced. Verified
both ways — 36/36 with a server, and "0/1 passed, 1 SKIPPED" without one, which
is visible only because of the runner change in the previous commit.
check:packets-all is in gates:post. CI runs it on every build. It has SKIPped
every single time, because library/scripts/posters/out/ is gitignored — both the
records cache and every generated packet — so a clean checkout has none.
Every packet assertion in this tree has therefore never once been evaluated
there: the 12 prayer points, the wheel's 12 labels, page 1's geography paths,
the print layer, the build stamp. Several ticks of work accumulating on a gate
that had only ever graded files in my own working tree.
A SKIP that is always taken is indistinguishable from an unwired gate — and the
wave-membership census cannot tell them apart, because membership measures
invocation, not evaluation. That census is how check:packets-all was created in
the first place, which makes this the same class recurring one level up.
I raised this at the end of the last tick and deferred it on the premise that
closing it "adds a live-API call to every build". The premise was wrong.
packet.mjs already had an OFFLINE mode, and a fixture needs no network at all.
The fixture is the smallest zone — Tibet, 43 slugs, 20KB — with image_url
stripped so CI can never fetch 43 photos from someone else's CDN. It is DERIVED,
not hand-maintained: membership comes from the committed clusters.json, so
check:packet-fixture regenerates and content-compares it. Re-adding an image_url
fails; dropping a record fails naming the slug.
A fixture packet stamps itself "fixture", and "fixture nomap" in CI, which has
no poster sources either. `fixture` implies nophotos in the gate — without that
it reported "every source record carries an image_url", a true sentence about
real records and a false one about this packet.
A real bug in my own change, caught only by running the script through its REAL
invocation path: packet.mjs fails closed on a missing VITE_API_BASE_URL BEFORE
loadRecords() ever looks at PACKET_RECORDS, so `bun run packet:ci` exited 1 on a
tree that had everything it needed. Every hand-test had passed because I was
supplying a dummy host. The guard now exempts fixture builds and still fails
closed otherwise.
From a genuinely clean state — the whole out/ directory moved away — the sweep
now reports "1 packet(s) checked, 1 passed". i18n verified through that same
path: a partial block (1 string, 5/12 points, 3/12 rhythms) renders 12 points,
12/12 wheel labels, wheel 3 translated and 9 English, 0 blanks.
check:packet-fixture joins gates:post (34 -> 35), README quote re-synced in the
same commit. Wave 35/35, packets-all 11/11 here and 2/2 in the studio.
The merge surfaced what main's new script-flags gate was written for: two
gospel-resources entries advertised --poison inside an inline # comment, where
the shell drops it — a self-test that never runs is a gate certifying itself.
Each flag now has a runnable sibling entry (check:snapshot-guard:poison,
check:coverage-consistent:poison) and the prose points at the entry instead of
the raw flag. Both siblings executed: the injected defects are caught (12/12
clauses). capability-exemplars regenerated; full build green, 34/34 post gates.
packet-render.js resolved the prayer points with `L.points || EN.points`.
That is a whole-block fallback when the key is ABSENT, and TRUNCATING when a
translator has done some of the 12 — a 3-element array is truthy. Measured at
the rendered layer with a deliberately partial locale block: a page headed
"12 Prayer Points" containing THREE, points 4..12 silently gone, including the
Mark 5:1-20 passage point 4 prints in full. Nothing blank, no error raised.
Invisible in all 11 shipping locales because every one of them is complete.
It would have appeared on the next language added — and "a new language is a
NEW BLOCK, never a code change" only holds if a PARTIAL block degrades per key,
since half-finished is the normal state of a language in progress.
Now merges per point and per field against English: English fixes the LENGTH
and supplies every missing field, so a point translated for `text` but not
`sub` keeps the English `sub` — and keeps its NUMBER, which the merge supplies.
The sheet-sum invariant could not see this: a 3-point page is still exactly one
sheet, so every number check-packet-render printed stayed correct while nine
points were missing. It now measures and asserts the count. Mutation-tested —
reverting the fix prints "prayer points : 2 / 12" and fails.
All 11 locales regenerated with photos and swept: check:packets-all 11/11, each
12/12 points, including RTL ar and Devanagari hi. Renderer, generator and gate
synced to doxa-maps-plugin-studio, which also lacked the .points-title and
.wheel-title test hooks the new assertion needs.
Also wires check:capability-exemplars into gates:post (33 -> 34 steps). It reads
public/doxa-maps/manifest.json, a build artifact, so ordering mattered: `tree`
writes the manifest and is &&-chained immediately before gates:post, and the
gate SKIPs rather than fails when the manifest is absent. Full wave: 34/34.
The quoted gates:post line in the README is re-synced in the same commit,
because check:quoted-scripts goes red on the drift this commit creates.
This gate keeps the AGGREGATE honest — a prose status makes every open-count wrong
while each card still READS correctly, so the error hides in the total. That matters
to this loop directly: every tick starts by reading status counts to pick a card.
MEASURED across 633 cards: EIGHT distinct status values are in use and EVERY ONE IS
A SINGLE WORD. No prose remains anywhere in the field — which is exactly what the
original 130-card migration achieved. The nine cards this gate was failing carried
legitimate TERMINAL states the enum simply predated: superseded (5), deferred (2),
decided, verified-already-true.
★ REWRITING THEM TO "done" WOULD HAVE DESTROYED REAL DISTINCTIONS to satisfy a list.
A SUPERSEDED card was replaced by another; a DEFERRED one was consciously postponed;
neither was finished. Losing that information to make a checker green is the
opposite of what this gate is for.
OPEN vs CLOSED is the property that matters, and all four additions are CLOSED:
open = todo + doing + in-progress (66), closed = done + these four (507),
blocked (60).
MUTATION-TESTED BOTH WAYS, so widening did not weaken it: a prose status
("done — reconciled against main, see notes") still FAILS by name — the original
defect — AND an unagreed one-word status ("wontfix") ALSO fails, so the set stays
CLOSED rather than becoming "any single word".
Wired into gates:post (32 -> 33) after confirming it reads only backlog JSON. The
doc that quotes gates:post is re-transcribed in the same commit.
Build: 7/7 pre, 23/23 bundles, 33/33 post, exit 0.
A --self-test IS the proof that a gate can fail. verify:interop and
check:fits-screen each advertised extra regression modes that nobody could reach,
so their non-default regressions were never demonstrated. The gate that found this
puts it exactly: "a --self-test that never runs is a gate certifying itself."
THE MECHANISM, PROVEN WITH AN ARGV PROBE rather than argued. Both parent script
values end in an inline # comment, so a mode appended by the caller lands INSIDE
that comment:
dedicated entry (mode hardcoded) -> ARGV ["--self-test","--self-test-clause=b"]
parent-style, caller appends -> ARGV ["--self-test"] clause (none)
The run then exits 0 in DEFAULT mode, indistinguishable from the mode being
selected. Unreachable: camera-freeze (interop) and clauses b-e (fits-screen).
Five dedicated entries now hardcode each mode, and the parent comments point at
them instead of at an append that cannot work. Confirmed end to end:
check:fits-screen:self-test:b fires a real clause-named failure
("[A] FAIL dashboard-plus-grid -> band=2401px vs viewport=900px").
★ MY FIRST ATTEMPT MADE IT WORSE — 2 findings became 6. I appended an explanatory
# comment to each new entry, which re-created the swallowing shape AND named the
flag in prose; the gate scans script VALUES, so describing a flag is
indistinguishable from advertising one. Rewrote the notes naming no flag. Then a
blanket string-replace mangled the fits-screen comment's clause list into
unreadable prose, repaired separately. The gate was right at every step; I was the
one adding the defect.
check:script-flags goes FAIL(2) -> PASS(148 scripts, 0 unreachable), and is wired
into gates:post (31 -> 32) after confirming it reads only package.json.
MUTATION-TESTED: reintroducing an advertised-but-swallowed flag fails by script
name. The doc that quotes gates:post is re-transcribed in the same commit.
Build: 7/7 pre, 23/23 bundles, 32/32 post, exit 0.
A done card with no record is the reporting failure this loop exists to avoid — so a
gate that mis-identifies WHICH cards lack a record is worse than none: it teaches the
next agent to pad the field the checker reads.
★ THE THREE FLAGGED CARDS WERE AMONG THE BEST-EVIDENCED THERE ARE. Each carried
one_line, why, and a `verify` block naming the probe, its assertion count and its
negative tests — "74 files match byte for byte, all three drift classes exit 1",
"9 assertions, all pass", "two new assertions, both negative-tested".
The gate looked only for `bluf` or `fields`. MEASURED across 443 done cards: 40 carry
`verify`, 41 carry `one_line`, and ALL THREE flagged cards had one or both. `verify`
is if anything the STRONGER signal — it names how the claim was checked, which is
what evidence means here.
Fixing the CARDS by adding a redundant bluf would have been gaming the check. Fixing
the GATE to recognise the evidence already there is the honest repair.
★ THE BASELINE IS NOW EMPTY. All ten exemptions reported STALE — every one had since
gained evidence — so the list exempted nothing while implying ten cards still owed a
record. An EMPTY set is not an unset one: every done card must now carry evidence
with NO exemptions, and the gate fails outright for a single card that does not.
Keeping ten satisfied ids would mean the same today and quietly permit a regression
the moment someone reused an entry. Same reasoning as emptying the i18n DEBT map.
MUTATION-TESTED both directions: a done card with NOTHING in any of the four fields
FAILS by name; the same card with ONLY a `verify` entry PASSES.
Wired into gates:post (30 -> 31) after confirming it reads only backlog JSON, which
the build does not mutate. The doc quotes gates:post, so it is re-transcribed in the
same commit — the self-referential update noted last tick, handled in one pass this
time.
Build: 7/7 pre, 23/23 bundles, 31/31 post, exit 0.
Last tick left six documentation-drift gates failing and NAMED on every build. This
is the smallest — one finding — and it guards something real: a doc that quotes a
config is claiming to be ground truth.
THE DRIFT WAS ENTIRELY MINE. Every one of the eleven missing steps is a gate added
over the past ticks, all four i18n gates among them.
★ AND IT DRIFTED IN THE UNUSUAL DIRECTION. The doc did not OVERSTATE coverage, it
UNDERSTATED it: a reader consulting README-SECTION-3 would believe eleven checks
were not in the chain. That is precisely what the gate's own header warns about —
"there is no wrong character to notice, just a check the reader believes is not in
the chain."
★ THE JUDGEMENT CALL. The surrounding prose said "the 29 gates cost 84.5s" and
"31 in, 31 out" — figures from the 2026-09-23 phase refactor, now 36. I DATED them
rather than restating them as current. Those numbers are the EVIDENCE that the
phase change preserved the step set; rewriting them to 36 would destroy that
evidence while looking like an update. The gate compares quoted SCRIPT LINES only,
so this prose drift was invisible to it either way.
Wired into gates:post (29 -> 30) after confirming it reads only package.json and a
markdown file, neither of which the build mutates — the precondition test I got
wrong with check:settled last tick, where "passes twice standalone" proved
repeatability rather than compatibility with running mid-build.
★ WIRING IT IS SELF-REFERENTIAL and takes two passes: adding the gate to gates:post
CHANGES the line the gate quotes, so it failed immediately after being wired and
needed one more doc sync. That is the mechanism working, and it is now noted in the
doc so the next editor is not surprised by it.
MUTATION-TESTED: removing a single step from the DOC only makes it FAIL by name —
the invisible-omission case it exists for.
Build: 7/7 pre, 23/23 bundles, 30/30 post, exit 0.
Six conflicts, all in generated artifacts under public/, including this
bundle's own — another builder's tree regeneration had rebuilt it.
Regenerated rather than hand-merged: the three PosterWall consumers
rebuilt, then the tree and the hashes.
PosterWall.vue itself auto-merged with both sides intact — this
branch's embedded/sectionHeads props and main's usePosterPrints
refactor — and the four suites that exercise it through the regions
poster dropdown were re-run green after the merge, since their change
lands inside the component this feature renders.
The nine conflicts were all in generated artifacts under public/. Built
files are not hand-merged: every bundle whose source either side touched
was rebuilt, the tree regenerated against main's new generate-tree, and
the hashes re-baked. Source merged clean — main never touched
PosterWall.vue or en/regions.json, they only appeared in a two-dot diff
because this branch changed them.
Driver request: "a toggle or carrot added to the bottom of the region
section that says view country posters, which will let you view country
posters for each of the countries in the regions" — with View-on-map and
Print, "the same reusable code from upg 100 list". This is a third
sanctioned addition alongside the tab strip and the no-selection chooser.
Almost nothing was built. library/components/poster/PosterWall.vue was
already the shared, fully parameterised grid, and it already owned the
card layout, the "Show on map" header button, the PRINT OPTIONS footer
and the client-side raster. regions-list.vue supplies only data and a
toggle — the same division country-poster-wall.vue states in its own
header.
- A "View country posters" toggle at the bottom of every region section,
carrying aria-expanded and aria-controls, using the same caret SVG the
header and footer links already use, rotated when open.
- Opening a region renders that region's countries. Measured: 41 cards
for Africa and all 41 previews LOAD (naturalWidth>0 — a 404 still
yields an <img>, so the load is the real check). No assets were
copied: public/assets/previews already holds 174 country-*.webp and
generate-tree already stages them.
- "Show Chad on the map" reaches the Region Dashboards tab with title
"Chad" and hash "#chad", by mapping PosterWall's CustomEvent listId
back through the country-selected the tabs already exchange.
- India prints its own reason with the exact number from the data; China
renders a real poster. Only India is unsupported — 855 groups and no
sheet, against China's 154 on a 3x4.
Two defects the screenshot caught and no assertion did:
- PosterWall's root is position:absolute;inset:0, because in its own
bundles it IS a pane. Embedded in a region card it painted 1440x1100
over the region's country list. Fixed by parameterising the shared
component rather than overriding its CSS locally: new `embedded` and
`sectionHeads` props, both defaulting to today's behaviour, so
country-posters and key-cities are unchanged by construction. Both
re-checked. Re-measured: root static, 1200px wide, panel below the
grid, no overlap.
- With one unnamed group the wall printed a section head reading the
literal "Other" plus a count duplicating the region card's own
summary. Suppressed by sectionHeads=false.
The dataset moved to library/data/country-posters.json now that it has a
second consumer, one import line changed in country-posters, and
emit-countries.mjs was retargeted so the next generator run cannot
recreate the duplicate.
The three new strings do not exist on DX-life, so there was nothing to
copy and nothing was invented in another language: en only, with the
other seven locales falling back to the key.
Regression: 15 suites green, including region-vs-live 73/73,
country-vs-live 28/28, mobile 44/44 and 26/26, and a new
_probe-region-posters.mjs. check:library-index drifted for real this
time (3 new library files) and is regenerated here.
check:public-hygiene went from PASS to FAIL across a merge, with two
findings -- "documentation in a served folder" for
public/assets/fonts/noto-serif-devanagari/README.md and for the copy
generate-tree stages into the tree. It arrived with 467b7f0, "Hindi
unblocked with a vendored Devanagari face". public-hygiene sits inside
build:unlocked, so main was red-gating every builder, not just this branch.
NOT deleted, because the obvious move would have been the wrong one. The
folder also carries LICENSE (SIL OFL 1.1). That file is a DISTRIBUTION
OBLIGATION and has to stay beside the woff2 -- removing it to satisfy a
hygiene gate would trade a lint finding for a licence breach. The README is
developer rationale, and it answers something the files cannot: why the font
is vendored at all, and why the 400 and 700 faces resolve to one variable
file. So it moved to documentation/, verbatim, with a header recording where
it came from and why.
check:public-hygiene 0 after. gates:pre 0, gates:post 17/18 -- the remaining
failure is the pre-existing verify/tables-complete break from the visa-leak
fix, still filed and still not ours.
Also in this tick, after main moved 7 commits including "Regions: one
rollup, ported from theirs" (the same file carrying the MapLibre boundary
branch): merged, rebuilt, re-verified. The branch is intact and the
boundaries still load -- 400 features on mapbox, 467 on maplibre, 0 console
errors -- and the full provider sweep still reads 14/16 with no regression.
check:library-index went red when wagf-region.js landed, and this time the
failure was REAL rather than the mtime artifact it was earlier today —
regenerating produced a genuine content diff, where the earlier run produced a
byte-identical file.
The diff carries three additions, only one of them mine: wagf-region.js, plus
printInPlace.js and retryableAsyncComponent.js, which arrived on main without
the index being regenerated. So the menu was already describing a library that
had moved on. Regenerated whole, since it is derived and partial is meaningless.
Worth keeping the distinction: "the gate is red" and "the gate is red because of
me" are different claims, and the way to tell them apart is to regenerate and
diff rather than to reason about mtimes.
The three hand-off README items the definition-of-done still listed:
(1) the map-cruncher hero -- an honest ASCII flow of the product's
whole argument (many maps -> one library -> one build -> five
targets); (2) the two 2026-09-24 certification screenshots showing the
ENTIRE cap-5 app rendering on a foreign host page via iframe and via
JS embed, with the host-page contract note (window.MAP_TOKEN + CORS);
(3) an annotated repo tree naming the free zone, the shared @map zone
and its high bar, and what the build output is.
Driver: 'showcase the capabilities from capability five down to
capability one using screenshots from real maps' -- ordered by
ADVANCEMENT, not complexity ('we don't want to scare people'). The
section now opens on the cap-5 app shell and walks down; the ladder's
point (every rung below stays independently embeddable) is stated up
front.
Four flagship images refreshed to today's product: cap-5 shell now
shows the galaxy globe + Vertical Legend + current nav; cap-4 UPG-100
opens on the Prayer Resources poster wall (the Driver's tab-one
ruling); cap-3 research map and cap-2 prayer preset re-shot current.
The old shots predated the rename, the greyscale ramp and the globe.
The 29 gates in build:unlocked cost 84.5s chained with &&, but 21 of them
finish in under 700ms — they were queueing behind check:mjs-parse (26.9s)
and check:parse (18.7s) on an otherwise idle 8-core box. The 22 vite builds
cost 129.0s and were never ordered at all: vite.config.js gives each bundle
its own outDir with emptyOutDir:false and emits nothing outside it, so they
only ever needed to not overwrite each other.
internal/run-parallel.mjs runs a set of independent steps concurrently and
grades the set by exit code. build:unlocked now reads:
gates:pre -> library-index -> clean -> token -> build:bundles -> tree
-> gates:post -> gallery
Measured end-to-end, both chains green, two rounds each, clean+token
excluded as common to both: 165.7s -> 65.3s mean (170.7/160.8 vs 60.2/70.5).
No gate was removed, weakened, sampled, or made conditional on what changed.
The step set is identical — 31 in, 31 out, asserted at edit time — and each
step runs as the same command graded by the same exit code. Only start times
moved. The && BETWEEN phases is load-bearing and stays: a source gate still
blocks the build, and the build still blocks every gate reading its output.
Within a phase the runner reports ALL failures rather than stopping at the
first, so three broken gates cost one fix-and-rerun cycle instead of three.
It imposes no timeout of its own, because a command killed by a short
timeout is not a failure.
Negative-tested three ways, each required to exit 1: a failing gate inside a
phase, a red phase halting the chain, and a failing vite build inside
build:bundles. The bundle case also confirmed the runner enumerates
plugin-designs live (it picked up 23 dirs, not a cached 22).
GALAXY + MAPLIBRE. New 'galaxy' style in the shared registry -- deep-space
ground, blue oceans -- with both a mapbox and a keyless MapLibre URL, and a
paint override that is applied only if the loaded style actually has the
layer, so a miss is a silent no-op rather than a break. The dashboard now
runs provider 'maplibre' and mapStyle 'galaxy'. Reversible by two config
keys; the Mapbox path is untouched.
BOTH RESIZES RESTORED. My seven-row change had hard-capped the table's
keyhole at 7 x 26px, which read like a default and was not: nothing writes
--rd-table-rows, while the drag handles write --rd-shell-h and --rd-map-h.
So the cap was unreachable by any gesture and the Driver lost both resizes
at once, plus a grey gap where the freed pixels had no consumer.
Fixed by restoring the derivation and moving the default instead: the
keyhole is once again shell - map - bar - foot, and DEFAULTS.shellH drops
720 -> 602 (map 360 + bar 32 + 7 rows x 26 + foot 28) so the table OPENS at
seven rows while staying fully draggable. CSS fallback matched to 602.
Measured before the revert: shell handle 849 -> 731 with rows 7, table
divider map 360 -> 280 with rows 10, gap 1px after each.
Also carries the charts' frozen count column (sticky right:0 with an opaque
background, so a scrolled label cannot slide under the number) and the
ViewSelect library-index entry.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The repo is handed to the Doxa team, and a code name from its build history
means nothing to an inheritor. Two places carried it, both regenerating:
- public/.build.lock was TRACKED. internal/build-lock.sh stamps it with
`by=$(whoami)` under flock on every build, so each rebuild committed the
building machine's username. Scrubbing the string would have been useless;
the file is pure runtime state nothing reads but the lock, so it is now
ignored via public/.gitignore instead.
- check-no-absolute-paths.mjs quoted a real home path in its header to
explain the rule. The prose works the same with /home/<user>/.
check:no-codename makes it stick. It scans tracked files only (the set a clone
receives), names every offending file:line in one pass, and refuses to pass
vacuously on a short file list.
Two things it has to get right or it becomes noise. "10x" is ordinary English
for a multiplier and library/scripts/research uses it correctly, so only the
full two-part name matches. And the same letters appear as the unix username
inside /home/ paths, which is a portability defect check:no-abs-paths already
reports better — those are listed, not failed, so the two gates cannot
disagree about one string.
It carries no ALLOW entry for itself. A gate must never be its own witness,
and an exemption is a hole a real leak could hide in, so the pattern is
assembled from fragments at run time and the file scans itself on the same
terms as every other.
Negative-tested: planting the name in README.md fails naming README.md:2,
exit 1; removing it passes. All five spellings (LK10X, LK 10X, lk10x, LK-10X,
lk_10x) planted across five different shipped surfaces are reported together
in one run, exit 1; reverting returns exit 0.
Also quotes check:heartbeat into the CLI doc, which check:quoted-scripts
requires and which landed in package.json without it.
Five lanes had 465 insertions across 18 files sitting uncommitted, some for
15 minutes. That is the state that silently lost the shell-scroll fix earlier
today: a verified fix existed only in the working tree, a second agent's edit
to the same file overwrote it, and the reverted version got committed and
described as the fix.
Nothing here is finished and no card is being closed on it. This is a
snapshot so the work survives an overwrite, taken at a moment when every file
had been quiet 15s or more, so no agent was mid-write.
Verified coherent before staging rather than committed blind: template-literal,
mjs-parse, sfc-parse, no-orphan-gates, kpi-unit, composite-reg, script-flags,
quoted-scripts and backlog-status all PASS. check:synced-css fails on
PeopleGroupDetail.vue and doxa-simple-map.vue only -- the same two files and
the same counts as before the swarm started, both untouched by any agent, both
already carded.
Two untracked files included because both are load-bearing: internal/
check-facet-one-field.mjs is wired into package.json with a self-test, and
library/utils/viewportScrim.js is imported by DataTable, SemanticTreeLegend
and PosterDialog -- a shared helper for the modal-scrim fix rather than six
copies of the same workaround.
Two different agents broke the build the same way within ten minutes: a
backtick inside a // comment sitting inside a template literal of generated
browser JS. The backtick closes the literal and the parser reads prose as code,
so it fails hundreds of lines from the edit -- one break reported at :386 with
the cause in a comment above it.
check:template-literal did not catch either, and that is not a bug in it: it
scans backticks inside CSS comments, the shadow-CSS shape. A // comment inside
a generated-JS literal is a different grammar. Proven by controlled negative
test -- injecting a backticked term into a // comment in generate-tree.mjs, a
file that gate explicitly lists, left it reporting PASS while node --check
failed immediately. The rule was right; the implementation covered a narrower
case than the rule stated.
Nothing parsed the 70 .mjs build scripts. check:parse covers inline scripts in
built pages and check:sfc-parse covers .vue, so the scripts the build is made
of were the one unchecked surface.
Uses the parser rather than a wider regex. A backtick is legal inside a string,
a regex, a nested template, or a comment inside a template; a pattern precise
enough to catch the bad case would false-positive on the legal ones, and a gate
that cries wolf gets skipped -- worse than the gap. The parser already knows
all of that.
Parses, never imports: these scripts read files, spawn browsers and hit the
network at import time, and a syntax check must have no side effects.
Runs as build step 2, beside check:template-literal, so a broken script fails
in a second rather than after minutes of compilation.
The failure message points at the real cause rather than the reported line, and
tells the reader to grep for every instance in one pass -- node --check reports
only the first, and each rebuild to surface the next costs minutes, which is
how one of today's breaks was being fixed one at a time.
Negative-tested side by side: exit 1 with "SyntaxError: Unexpected identifier
'TREE'" at generate-tree.mjs:369, while check:template-literal reports PASS on
the same file in the same moment.
develop-a-plugin-and-see-it-live.md is the path an inheriting agent follows
end to end, and it mentioned the plugin studio once while sending every
iteration through a full build. Step 3b already argues well that a green build
proves nothing about mounting -- it just pointed only at smoke:embeds, which
needs the build first.
Adds Step 3a: bun run dev, then bun run smoke:studio. Measured 4.7s for 29
tabs with 0 failed requests, against a full build of 43s to 4min on the same
box -- 19 sequential bundles, queued behind any other build, so the spread is
real. An order of magnitude either way, which is the point.
A first draft said "roughly fifty times faster", derived by dividing by a
remembered 4-minute constant rather than by what the build log actually showed
(43s). Replaced with the measured range. That is the same defect this session
flagged on the Forgejo card an hour earlier: a stored number that rots, quoted
as if it were evidence.
Also states why both steps exist rather than one replacing the other: the dev
manifest hands rooted refs and the built manifest hands relative ones, so a
base-join bug is invisible in one surface and fatal in the other.
And names the single staging environment explicitly, pointing at /__gallery as
the archived index kept only because the dev server imports its renderer --
so a reader who stumbles on it does not mistake it for a place to review work.
In the cap-5 shell the Region donut, country bars and Place tree rendered
entirely grey while the identical build was correct standalone. A cap-4
composite imports the sibling component directly
(dashboard-plus-grid.vue:18), which takes the COMPONENT and skips
research-dashboard/index.js -- the file that actually assembles the bundle:
Pinia, i18n, colour strategies, customElements.define. The composite
re-declares all of it by hand, a manual copy nothing keeps in sync, which is
how the strategies leg was lost. That file already documented this bypass for
i18n and fixed only that one leg.
It is silent by construction: getColorStrategy() falls back to language-family
for an unregistered mode instead of throwing, so the result is a plausible
wrong picture -- grey charts that read as a data problem rather than a wiring
one. Found by reading an image; every number passed.
The gate compares the leaf's registerStrategies keys against the composite's.
A composite registering nothing while its leaf registers nothing is fine: the
rule is about drift between two lists meant to agree. big-picture also bakes a
sibling and registers zero strategies, and that is correct -- neither it nor
people-groups-grid references colorFor, getColorStrategy or mapbox. A first
pass called it a second instance; measuring usage showed it is not one.
Four rounds to get working, each a real bug found by testing the wrong half:
- pre-stripping comments deleted 2,953 of 4,102 characters of
research-dashboard/index.js, including the call being searched for, so the
gate claimed "no composite bakes a sibling that registers strategies" --
a confident claim about the repo derived from parsing nothing. Not stripping
is also correct: the call is located by requiring "(", so a mention in prose
cannot match.
- indexOf('registerStrategies') found the IMPORT line first and brace-matched
its own braces.
- a non-greedy brace regex stopped at the first inner "}" and the key pattern
missed quoted path keys.
Every fix was verified against dashboard-plus-grid, which survives stripping,
and never against research-dashboard, which does not. A comparison needs both
halves asserted before any fix is believed.
Negative-tested: dropping doxa-region from the composite fails by name and
prints both lists.
The dashboard rendered "99% % UNREACHED" and "1% ADOPTED %". kpiText returns
k.value unchanged for kind:'pct' and that value already carries its percent
sign, while the labels in dashboard.config.js carried a second one.
Each half is correct alone. A label reading '% unreached' is a reasonable
label; a formatter returning a ready-formatted percentage is a reasonable
formatter. Only the composition is wrong, and neither file mentions the other,
which is why it was found by reading the rendered screenshot rather than by
reading either source.
check:kpi-unit asserts no kind:'pct' label contains a '%'. The value owns the
unit because it is produced by the formatter that knows the number; the label
says what is being measured.
Parses the config as text rather than importing it -- the research-dashboard
module graph reaches library/colors/_registry.js, which uses import.meta.glob
and throws under plain node. Text parsing keeps it hermetic enough for
build:unlocked. Comments are stripped first, because the config now documents
this defect right above the fixed line and a naive scan fails on the
explanation of the fix.
It also fails when it finds no pct entries at all, rather than printing a
green PASS: if the config is refactored so the parse stops matching, silence
would read as success forever.
Negative-tested: restoring either label fails by name with exit 1.
Three docs still told a reader to start a server on every interface: the
reference recipe for the share round-trip, the quoted scripts block, and
app-wrappers.md. Agents followed those recipes faithfully, which is how twenty
publicly bound http.server processes accumulated on this box. All three now
bind 127.0.0.1. check:quoted-scripts caught the second one on its own.
Also adds the gate-authoring constraint that just broke a build. Plugin and
library source is written for Vite, not Node, and fails two ways: the @map
alias is unresolvable (hit first), and import.meta.glob is undefined in 18
files including 11 of the 20 plugin index.js. A new check:facet-cascade
imported the dashboard config, reached library/colors/_registry.js
transitively, and threw -- while already wired into build:unlocked, so the
build failed for a reason unrelated to the code being built.
The counts in that section were measured, not estimated. A first draft said
"every plugin index.js" and the real number is 11 of 20; it also blamed the
glob when the first error is actually the alias.
States the two rules that follow: build:unlocked is only for gates that run
clean in plain Node with no browser and no network, and a gate gets run before
it gets wired in -- a broken gate in the build chain is worse than no gate,
because it goes red for the wrong reason and trains people to skip the step.
Records the other box constraints that have each produced a wrong answer:
Node 20 (no fs.glob), bfs find rejecting relative -newermt, no hardware WebGL,
and bunx not existing.
Checkpointing thirteen files of agent work that were sitting uncommitted while three
agents wrote to a live tree the Driver was watching through hot reload. Uncommitted work
is work at risk, and a tree being edited under an observer oscillates between states that
look like regressions and are really just partial saves.
The second capability-5 shell now mounts its tabs as real elements too, so no iframe
remains in either shell. It was the last holdout: eight tabs, all iframe mode, each
pointing at a hardcoded built-tree leaf that does not exist under the dev server.
The facet rail keeps its shape when a filter narrows it. Selecting one place took the
rail from sixty-six rows to twenty-one — forty-six rows deleted, none dimmed — so the row
a user was about to click vanished under the cursor, which is what "the buttons went
dead" actually was. Rows whose count falls to zero are now re-admitted at their original
slot and depth, dimmed and inert, which also restores the component's own promise to lose
nothing against the tree it replaced.
Expanding a branch and filtering by it are now separate, visible affordances: the whole
row filters, and expand is a real button with its own hit area and label. Rows that were
never clickable are unmistakably inert rather than silently ignored.
Three more facet axes reach the grid — affinity bloc, engagement status and primary
language — each verified to share a field with an existing prop rather than being mapped
on name resemblance. Two derived facets remain unmapped on purpose: they have no matching
row field, and mapping them would filter confidently to the wrong rows.
A backtick in live shadow CSS breaks the build exactly as one in a CSS comment
does, and check:template-literal only reads the comments. Proven both directions
last tick: the planted defect left that gate at pass while the build died with a
Babel stack trace pointing far from the edit.
The card concluded this was not achievable, on good evidence — three lookahead
heuristics were tried and reverted, scoring 98 and then 136 false positives on a
healthy tree, because the real terminator of a template literal is also a
backtick and no window of surrounding characters separates the two. The evidence
was right and the inference one step too strong. No heuristic works is not
nothing works, and the card named the answer in its last line: needs a tokenizer.
A parser does not guess where a literal ends; ending it is what parsing means.
Measured against the same tree the heuristics failed on: zero false positives
across 83 files. Splitting the SFC first means it sees exactly the script text
the build compiles, and using the parser the build reports errors from means its
verdict matches what actually breaks rather than approximating it.
Negative-tested with the exact case the other gate misses: a backtick planted in
live CSS passes check:template-literal and fails this one, naming the file and
the line that was edited rather than a stack trace somewhere else. That line
number is the point — the build was always the backstop, just an unhelpful one.
Every map image shipped data floating on an empty navy void — no coastlines, no
borders, no labels. Two cards had this blocked on the Driver adding origins to
the Mapbox token's allow-list, for four ticks.
It did not need the Driver. Measured both directions: a tile request with no
Referer returns 403, the same request with the doxa.life Referer returns 200,
and the style document returns 200 either way, which is why the map still booted
and every gate passed. The shot harness serves from 127.0.0.1 and so sent no
Referer at all. These are doxa.life's own documentation images, generated by its
own repo from its own token; the restriction exists to stop third parties burning
the Driver's quota, and it was blocking the owner.
Route interception looked like the careful way to scope it and silently does not
work — Chromium ignores a referer supplied through route.continue, so tiles still
403'd. Only the context-wide header applies, and that breaks same-origin iframes:
the iframe-host shot went from 187 elements to 15.
So it is opt-in per shot, and the predicate is architectural rather than
anecdotal: any surface that nests content in an iframe opts out. I set it for the
one shot that failed first, then watched both cap-5 shells collapse on the next
run, and generalised. Map leaves get their basemap; shells keep their content.
Verified by reading the images, not the file sizes — a size jump had already
misled me once this tick.
It showed an empty pane reading "Choose an element from the list." The README's
headline image for the highest capability level demonstrated nothing, and every
shot gate passed it, because freshness, distinctness and element counts are all
satisfied by an empty pane. The shot now selects the first rail row before
capturing, so the picture shows what the studio is for: a nested SPA rendering
inside the shell that lists every element by capability. The file went from 167KB
to just over a megabyte, in line with its sibling.
Found by reading the image, the same way the mobile error card was found
yesterday. Reading one picture has now caught two defects that eleven gates
passed.
Add check:shot-fonts. The studio labels each row with a capability emoji, and
this machine has no emoji-capable font, so all 28 rendered as tofu boxes in the
committed PNG. The markup is right and the product is fine in a real browser —
the shot machine could not draw it — so the fix is not to strip the emoji. The
gate asks fontconfig whether this system can render the glyphs the UI actually
uses, and names the font install rather than the markup.
It is deliberately not in build:unlocked: it reports an environment condition
that is currently true here, and a gate that always fails in the build makes the
build useless. Its first version also flagged the star this repo uses as a
comment marker, which every text font renders; the range is now narrowed to the
pictographic planes.
Two composites nested a child by tag and URL without a profile-config. Those
children do not render a component directly — their bundle entry mounts
ProfileLoader, which reads profile-config.profile to choose which view to load.
With none, the element still mounts and still reports its tag, so every
structural check said both children were present, while the loader rendered
"missing or invalid profile-config prop" where the map should be. The grid half
of each page worked, which is how it survived.
Found by reading a regenerated README screenshot, not by any gate. The mobile
screenshot showed an error card and an empty pane; the fixed one shows the real
research map with its data, controls and progress figures.
research-map-plus-grid was mine, shipped that way. stack-demo predates it and had
the same defect against research-dashboard. Both now pass the shape their own
generated leaf pages use, so nested and standalone mounts ask for the same thing.
check:composite-srcs now requires a profile-config on any child whose bundle
mounts ProfileLoader, derived from which entries import it rather than a list.
The first version of that check tested for the word and passed on its own
explanatory comment; it now matches the quoted attribute key, and a negative test
that mutates only the key fails as it should.
Also regenerates three derived artifacts that had drifted behind this flight's
changes: the library index, the capability exemplars table, and all twelve README
screenshots.
A cap-5 shell nests its children as iframes, and window.MAP_TOKEN does not cross
an iframe boundary — each frame is its own window. A nested map cannot inherit
the parent's token, so it works only because the child leaf carries its own baked
copy. Proven in a browser: all thirteen nested frames in the cap-5 shell hold a
real token, including a map two levels deep. That was a one-off run; this makes
it standing.
The existing smokes cannot catch this. Both inject window.MAP_TOKEN with
addInitScript before the page runs, which is right for what they test but means
the test supplies the thing under test. A user opening a shipped leaf has nothing
injecting a token for them.
Also asserts the reverse: no token may be compiled into a .js bundle. Bundles are
cached, SRI-pinned and reused across hosts, so a token baked into one cannot be
rotated without a rebuild and leaks into every embed.
Reads the built manifest rather than walking directories, so it checks the set
that actually ships. Counted 43 leaves, matching an independent filesystem count.
Negative-tested both directions: stripping a leaf's token and planting one in a
bundle each fail with the artifact named.
The desktop composite pairs the research dashboard with the grid. This is its
mobile sibling: the monolithic 7-tab research map on top, the same grid beneath,
stacked to scroll. Which one a shell shows is an aspect-ratio choice made at the
SPA level, so neither plugin knows about the other and both stay embeddable.
The grid's own search and filters are ON here. The desktop composite turns them
off because the dashboard header owns those controls and drives the grid through
props; the research map has no such header, so switching them off would leave the
grid with no way to be searched at all.
Composes its children by tag and bundle URL rather than importing their .vue
files. Importing the research map directly would bypass its bundle entry, which
is where its three bundle-private colour strategies, its profile registry, its
Pinia and i18n pair and the RTL text plugin are installed — and losing those is a
runtime defect in a green build, because a thrown setup() removes the subtree
with an empty error.
Add check:composite-srcs, which resolves every nested src against the built tree
and asserts every nested tag is registered. A wrong relative depth is a 404, and
the stack layout swallows a failed section on purpose so one broken child cannot
take the whole stack down — which means the cost of that resilience is a silently
empty band. Negative-tested both ways: breaking the depth and renaming a tag each
fail with the file and path named; restoring returns to pass.
Ran the browser-free gate suite inside a fresh clone -- the inheritor's
conditions. Five passed; check:dxlife went red with "clone not found at
/root-dx-life - nothing to check".
That is not a defect. The gate deliberately uses three values (0 unchanged,
1 drift, 2 no clone) because it exists after a check that returned the PASS
value BECAUSE IT FAILED: `cd root-dx-life && git status --porcelain | wc -l`
printed 0 for a directory that is not a git repo. Collapsing "cannot judge"
into "pass" is exactly the bug it was written to prevent.
But `bun run` and any && chain see only zero/non-zero, so the middle value
evaporates precisely where a reader consumes it. The fix belongs in the
documentation, not the gate: added a preconditions row stating what it needs,
what exit 2 means, and that it is NOT in build:unlocked, so a clone still
builds.
Verified the whole inheritor path again after the Cargo.lock change: clone
(2358 files, lockfile present) -> bun install exit 0 -> bun run build exit 0,
18 bundles / 27 profiles, with check:dep-names and check:no-abs-paths both
running inside the clone's own build.
Makes the cheap half of last tick's P0 automatic. A folder rename had
rewritten three npm package names, so bun install failed in a fresh clone
while everything here passed -- the real packages were already in
node_modules from before the rename, making the breakage invisible to every
build and every gate.
Checks two things, offline, in milliseconds: the declared name resolves under
node_modules/, AND the installed package's own name field equals the declared
key. The first alone catches the capacitor case; the second catches a name
that resolves to a DIFFERENT package, which a path-only check would miss.
Together they detect the corrupted-name class without a network round-trip,
which is what lets it run on every build rather than in a nightly job nobody
watches.
It does not replace the clone test. A gate can only see what is IN the repo;
only a clone sees what the repo fails to do on a clean machine.
Negative-tested four ways: recreating the EXACT P0 (the three
@deployment/capacitor/* names) FAILS naming all three; a name that resolves
to a package whose own name differs FAILS; an absent node_modules SKIPs
honestly rather than passing or failing; an empty dependency list FAILS via
the vacuous guard. Wired into build:unlocked and confirmed running inside a
full build (17 declared, 17 verified, exit 0).
Tested the north-star path as an inheritor would, by cloning the repo from
Forgejo and running the documented first command. It FAILED:
error: GET https://registry.npmjs.org/@deployment%2fcapacitor%2fcli - 405
error: @deployment/capacitor/cli@7 failed to resolve
The rename of capacitor/ to deployment/capacitor/ did a blind string replace
that rewrote the NPM PACKAGE NAMES too. @capacitor/android, @capacitor/cli
and @capacitor/core became @deployment/capacitor/*, which do not exist on
npm. Restored the real names, pinned to ^7.6.9 -- the version actually
installed -- and fixed the two places that repeated the wrong name in prose.
This is the inheritor-only failure shape, in a form the new absolute-path
gate cannot see. The local tree works perfectly because node_modules/
@capacitor/* was installed BEFORE the rename, so every build and every gate
passes here. The breakage lived only in the DECLARED names, visible solely to
someone installing from scratch.
Verified by doing it: a fresh clone now runs bun install to exit 0 (206
packages, all three @capacitor/* resolved) and bun run build to exit 0,
producing 18 bundles / 27 profiles -- identical to the working tree.
The repo is clean -- git grep over every tracked file returns zero /home/
paths. That is a real hand-off property: a clone works on any machine, which
is what "the path Doxa inherits" requires. But nothing enforced it. Proven by
experiment: a /home/lk10x/... line planted in internal/settled-tree-gate.mjs
passed check:public-hygiene, check:parse AND check:quoted-scripts. The tree
was clean by accident, not by construction.
This is the worst failure shape for a hand-off. An absolute path works
perfectly for whoever wrote it and breaks only on the inheritor's machine, so
no test run by its author can catch it. The sibling ideation tree already
paid that cost: two docs cite a workspace root that no longer exists, so a
reader following them silently finds nothing.
Not a flag on check:public-hygiene, which already detects these patterns but
walks a SERVED tree where node_modules never exists -- pointed at the repo
root it produced 1,427 findings, nearly all from node_modules/.vite. Its
scope is right for its job. This scans `git ls-files`, the set a clone
actually receives, which excludes node_modules and build output with no
ignore list to maintain.
Negative-tested three ways: the exact planted path that beat three other
gates now FAILS with file:line; a /Users/ path in a doc FAILS; a checkout
with too few tracked files FAILS rather than passing vacuously. Wired into
build:unlocked and confirmed running inside a full build (783 files, exit 0).
It then caught its own documentation -- my reference.md row quoted the
literal patterns as examples. Rewrote the example rather than allow-listing
the file, which would have blinded the gate to a real path in the repo's
largest doc.
The OPEN WORK line asks for Playwright coverage over every plugin AND ALL
SHARING. Sharing had never been exercised across the set this flight: the
gate needs a served tree, a token, per-plugin --url flags and (on this box)
--browser chromium, so a bare `bun run smoke:share` only prints usage.
Derived the real coverage set -- grep -rln ShareButton plugin-designs/*/*.vue
gives 8 built leaves -- and ran it: 8/8 pass. Every Share button emits a URL
that resolves 200 text/html and reopens the correct element with a live
canvas.
The pass count alone would have proved nothing. The failure this gate exists
to catch, an SPA fallback or a host stripping the hash deep-link, renders A
map for every share URL, so all 8 pass while all 8 links are wrong. The
discriminating evidence is six DISTINCT reopened tags, each matching its own
plugin, canvas:y on all eight.
reference.md now carries the copy-paste invocation, verified by running it
verbatim from a clean shell (8/8, same six tags). It also corrects a detail:
the doc said Firefox, which is the default but stops on this box -- the tool
detects its own WebGL limitation and names the chromium flag in its output.
A standing work item reads "gospel-resources + cities-internet maps must
render with REAL resource links" -- one line batching two profiles whose
contracts are opposite. gospel-resources genuinely renders 155 real eBible
links. cities-internet renders zero, deliberately: its own header says
"PERSECUTION-SAFETY: this profile carries connectivity + population +
language only. It has NO gospel-resource, church or believer field and must
not gain one."
So taking that line literally means adding a gospel-resource field to a
profile whose header forbids exactly that. The instruction is stale in the
worst way -- not merely wrong, but pointing at work that would remove a
safety property.
The rule existed only as a comment in one .vue header. It now has a section
in reference.md under Data sources, with the measurement behind it: zero href
bindings across all three cities-internet profiles, 1,567 live nodes and 0
anchors, and a `source` field that is API provenance rather than a
destination.
Also recorded that "persecution-safe" means three different things in this
repo -- field separation here, placename precision in key-cities, a
dependency-free table in DashboardDataTable -- so nobody generalises one into
the others. Only the first governs what data a profile may hold.
The exemplars table was authoritative but ~60 rows, so correcting a wrong
citation still meant reading it and DECIDING which row to quote -- the exact
step where a wrong citation gets re-invented. Added a generated five-row
summary that answers "cite this" per tier by a stated rule, and named the two
known-wrong citations it replaces.
Both live in the /plugin-designer skill, which sits outside this repo and so
cannot be gated from here (a checker pointed at it would pass on this machine
and skip silently everywhere else). Measured: unified-research-dashboard as
the cap-4 reference names a bundle that DOES NOT EXIST -- it became
research-dashboard (cap-3) plus dashboard-plus-grid (cap-4). And
doxa-vision-card as the cap-1 example ships an instances.json with 2
instances, which makes it cap-2 parameterized by the project's own rule.
The first version of the rule picked stack-demo for cap-4. It satisfied the
rule perfectly -- name equals profile, shortest -- and was still the wrong
answer to hand someone, because big-picture is the canonical cap-4 composite
named in the Gate-1 chain. Added a demo/template deprioritisation and
recorded why: a reproducible rule is not automatically a good one, so check
what it actually selects.
All five picks now match the skill's own reference implementations, except
the two that were wrong.