upg100's harness recorded my two files as NOT MEASURED because its stubs threw on
them. Both were FIDELITY gaps — the stub modelled states that cannot occur — so
they are fixed by making the stub more like the browser, never by relaxing a
comparison:
· `.value` was undefined, so a component reading an INJECTED ref threw
"Cannot read properties of undefined" (region-dashboards.vue:199,
cfg.value.regionSlug — ProfileLoader always provides that object).
· the stub threw on ANY string coercion, because a Proxy over a function has no
primitive conversion. That alone produced regions-list's "Cannot convert
object to primitive value".
`.value` is now the EMPTY STRING, which is the one shape that serves both real
cases honestly: a locale ref's .value IS a string, so `${locale.value || 'en'}`
correctly yields 'en'; and reading a property off '' returns undefined instead of
throwing, so an injected-config read falls through its own `|| ''` exactly as in
the browser. Symbol.toPrimitive makes any remaining stub render empty.
Measured components: 2 -> 4, with no NOT MEASURED left.
★ AND THE MIGRATION FOUND A REAL BUG, which is the part worth reading.
regions-list's Cyrillic heading rule ended with a trailing comma and NO
declaration block, so the concatenation emitted:
[lang="ru"] .rl-title, [lang="ru-RU"] .rl-title,regions-list
A selector list with nothing to apply — the Russian heading font was silently
never applied — AND it swallowed useShadowStyles' style-id argument as CSS,
leaving that sheet unlabelled. Braces stayed balanced, so
check:shadow-css-parses could not see it, and it passed 80/80 both before and
after. cyrillicHeading('.rl-title') replaces it and emits a real rule.
SO THE BYTE-COMPARE SPLITS, deliberately, and I did not paper over it:
region-dashboards.vue IDENTICAL — cyrillicHeading('.rd-title','.rd-h2')
emits exactly the literal it replaced, verified
character-for-character before the swap. A pure
de-duplication.
regions-list.vue CHANGED, and the change IS the fix. The old bytes were
the bug, so its baseline is re-taken rather than
preserved. Preserving it would have pinned a broken
rule forever, which is the one thing a byte-identical
gate must not be used for.
NOT MIGRATED, on purpose: strongMedium(). The helper emits bare `strong{...}`
while region-dashboards emits `.rd-root strong{...}` — a narrower selector.
Swapping it would widen the rule to every <strong> in the shadow root, a
behaviour change disguised as a de-duplication. scrollbarGutter() is likewise
left inline: both files carry it inside a larger .rd-scroll/.rl-scroll rule, not
as a standalone declaration the helper could replace.
Both files now gated (owned:true) instead of recorded-only, so a future edit that
changes their emitted CSS fails rather than being noted.
check:composite-freshness found stale on main: country-map. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
Rings that zoom apart, as a flag, so either answer to row 130 is one line at
the call site. DEFAULT IS 'fit' — this changes nothing today.
'fit' keeps every pin at least minSep apart at the chart's own scale and reports
what will not fit. That is the right behaviour for a PRINTED sheet, where there
is no zoom and a dot a reader cannot resolve is a dot that is not there.
'fill-all' places all of them. Separation is in map units, so it grows with zoom:
measured on the shipped table, the worst ring is ring 1 (1,190 groups in the
smallest circle) separating to 8px on screen at z=4.45, and ring 8 (1,932) at
z=3.50. The GROUP label tier already starts at 5.2, so every pin is clickable
before the names appear. At the start zoom the band reads as a dense arc of
colour rather than countable dots, which is the intended appearance — and the
reason a caller in this mode must caption the true total rather than a count
that changes with zoom.
The row count had to follow the POPULATION here, not the pin size. Keeping the
'fit' row count would have packed 1,932 pins into the two rows a band allows at
full size, and they would have sat almost on top of each other along the arc —
the single-line defect again, one level down. Ring 8 uses 14 rows in fill-all
against 2 in fit.
Real table: fit places 1,851 of 7,296, fill-all places 7,296 of 7,296, both with
zero pins outside their band. Six new assertions, and the row-growth arm is
poison-tested: making fill-all reuse the fit row count fails it at "2 rows vs 2".
pb-prod (the public API) ran the migrations in the pulled checkout, which follows the table (development), so a
restart moved production's schema to unpublished work. It now reads /data/pb-prod-migrations/current: a snapshot
of PRODUCTION_BRANCH's migrations taken at start (the ref build-cdn.sh uses) and refreshed from the very snapshot
a publish builds. Only a change restarts pb-prod, and the restart is local: pb-prod runs in a loop, so the
container stays up. It fails closed: an error keeps the last good snapshot, and with none it is an empty folder,
never the development checkout. --automigrate=false, so a change made in prod's dashboard writes no migration.
/admin/status shows prodSchema next to published.
The why now says what the maps are for, in the order they serve it: giving disciples, churches and
leaders what they need to bless the nations, with indigenous disciples, churches and leaders
multiplying and blessing their own peoples as the deeper answer to the Great Commission. It names the
three things the maps serve: praying for the unreached peoples of the world, mobilising believers to
bless their own cities, and caring for the needy with private maps.
A map, a data table, a charts rail and a detail drawer behave as one dashboard
without importing each other, and nothing said how. This records the arrangement
that exists on development today; every claim in it was checked against the code
rather than remembered.
One composable provides five keys and every panel injects what it needs, with
null and {} defaults that are load-bearing: a panel rendered outside a shell must
degrade rather than throw, because a thrown setup() removes the subtree with an
empty error and no build failure.
The part worth writing down is that the bus carries two different kinds of
traffic through one generic on/emit pair. Four STATE events belong to
useSelectionBus and are emitted only by it. Seven UI events are passed through
the same bus by panels with no composable behind them. Reading the second kind as
if it were the first is how a maintainer concludes a contract is dead.
That mistake is named in the file because it has already been made twice while
auditing this kit: two panels listen for 'select' and no panel emits it, which
looks like a broken wire until you notice the emitter is the composable at
useSelectionBus.js:202. Grepping components for a bus's traffic cannot see the
bus itself.
It also records that selection is deliberately NOT reactive -- selectionSet is a
plain Set and selectionVersion is the reactive handle -- and that the charts rail
participates in filtering only, so selecting a pin does not highlight its slice.
That last one is the current contract, not an oversight: whether selection should
reach the charts is a product question and it is open.
The components stay in plugin-designs. A library component with one consumer is a
relocation rather than a promotion, and this repo has already had to move such
modules back; they graduate when a second dashboard consumes them.
Found by running the SHIPPED table through the layout instead of the uniform
sample the module was built against. The real near-neighbours data is heavily
skewed — ring 8 carries 1,932 groups, ring 5 carries 100 — and against it
bandFill placed 962 of 7,296. It dropped 87% of the map, and every drop was
faithfully reported, which is exactly why no assertion caught it: all 16 asked
"is the overflow reported?" and none asked "is the CAPACITY right?".
The capacity model counted one circle of pins at the band's innermost radius. A
band has depth, so it holds several concentric rows, each with its own
circumference. Counting them is a ~2x correction at ringGap 24 and more at
larger gaps; on the real table it takes placement from 962 to 1,851 on the gate's
geometry and every under-full ring now places everything it is given.
Placement had to follow: a hashed radius with one angular step was fine while a
ring held a dozen pins and wrong once it held hundreds, because two pins could
take nearly the same angle at nearly the same radius while the capacity sum
insisted there was room. Pins are now dealt round-robin into rows, each row
evenly spaced across the usable arc, staggered a quarter slot so adjacent rows
do not line up radially and read as spokes.
Two bugs found on the way, both by the gate failing honestly:
- the stagger first added half a step to every odd row, which pushed that row's
last pin past the end of the arc and into the label's keep-out — 7 overlapping.
Staggering WITHIN the slot keeps every pin inside its own slot.
- the shrink arm tested at 200 groups, which only crowded a ring while capacity
was undercounted. It now tests at 2,000, the honest pressure for a 7,296-row
table, and still reaches the pin floor and reports.
The new capacity arm asserts a RATIO against the single-row count, not a magic
number: its first version hardcoded ">400" from the component's ringGap of 22
while the gate builds radii at 24 — the code was right and my threshold was
guessed. Poison-tested: forcing rows back to 1 gives 1.05x and fails both arms.
layoutCenteredSet gives every group on ring N the same radius, so a crowded ring
draws as one line of dots on a circle. The target needs each ring to be
a BAND, groups spread evenly through it, never overlapping each other or the
ring's label.
library/components/charts/ringBands.js is that geometry, with no Vue, no engine
and no DOM, so the Wall poster can consume the same module rather than growing a
second copy of it. bandFill() places; curvedRingLabels() returns each label's arc
AND the keep-out that bandFill refuses to place inside — computed from one source
so "pins never overlap the labels" is true by construction rather than usually.
Determinism is a requirement, not a nicety: every position is a pure function of
the item's own id, so two printings of one packet compare sheet to sheet and a
dot that moves means the DATA moved. There is deliberately no seed. Input order
does not affect any position either, so a caller that filters or re-sorts does
not reshuffle the chart.
An over-full ring SHRINKS its pins to a floor before anything is dropped — a
visible pin beats an unplaced one — and only then reports the remainder with the
ring and the counts.
check:ring-bands, 16 assertions, every arm with a negative control. One arm is
there because this gate passed the bug it now catches: uncapped, a long label on
ring 1 wanted 96% of that small circle, so capacity was zero and all twelve
groups on the innermost ring were reported unplaced while every arm stayed green
— "reported, not silently dropped" is not the same promise as "the ring draws".
Labels now truncate at a share of their own circumference and report how many
characters fit. Poison-tested: restoring the old share reproduces "empty rings: 1".
Seven shadow-root components each carry their own copy of DX Life's
base/_globals.scss rules, because page CSS cannot reach into a shadow
root. Measured first: where the copies overlap they are byte-identical
(the Cyrillic stack 3x, strong{font-weight:500} 2x, scrollbar-gutter 2x),
so this is deduplication, not a fix.
The module exports FUNCTIONS, not strings, and the measurement forced
that: the six copies of the Cyrillic rule share one declaration but have
six different subjects (.rd-goal-card__title, .rd-h2, .rd-title,
.region-card__title, .rl-title x2). A fixed rule string would have to
pick one class and silently stop styling the other five. The caller owns
its selector; the module owns the declaration.
cyrillicHeading takes a selector LIST, not a selector. GoalCard styles
one heading class; RegionSectionCard groups two into a single rule
sharing one declaration block. A one-selector signature emits two rules
instead of one -- the same CSS to a browser, but not the same
stylesheet, and the bar here is unchanged, not equivalent.
Migrated GoalCard and RegionSectionCard. regions-dashboard's two files
port the same rules and stay with their owner; the harness records them
so that migration can run against this same baseline.
The img border-radius rule (_globals.scss:80) is documented as
deliberately NOT ported, because an invisible omission is one that gets
"fixed" later.
PROOF: internal/check-ported-globals-identical.mjs executes each
component's setup, captures the string handed to useShadowStyles and
hashes it. The emitted CSS is byte-identical across the refactor
(987d94b1cd66110d, 897d948b17b21efe, both captured from 45e31f102).
The harness's first version hashed the SOURCE of the call and was
wrong: this refactor replaces a literal with a function call, so the
source changes by construction -- a text-hash gate fails a perfect
refactor and cannot tell that from a broken one. It measured the
artifact when the property under test was the output. It failed exactly
that way on first run.
Shown capable of failing, four ways: dropping letter-spacing, a wrong
font name, 500->600, and splitting the grouped rule into two. All four
go red; restoring goes green. Components that cannot be evaluated are
reported NOT MEASURED (exit 75), never as a pass.
country-map (the previous commit) moves RegionCountryMap.vue from regions-dashboard/src to
library/components/maps so a page can mount the country map alone. check:region-fill-repaint read the
old path and crashed on the move; it reads the library file now and still PASSes, with the repaint
after setData intact (the move carried the hardening that landed in 608e8ec29). check:universal-props
held the file's fixed en-US population format by its old per-bundle path; the same held decision now
sits on the library file for every bundle that imports it (regions-dashboard and country-map), which
the allowlist permits only for library/. One comment still pointed at src/useRegionsI18n.js.
The caret (C11) and the reduced-motion gate were written in parallel, and only together did the gap
show: the new caret animates its arrow (transform 0.18s) and its colours with no reduced-motion rule.
The rule sits in the useShadowStyles string, where a shadow root can see it. The arrow still flips
to the right direction; it only stops turning on the way there.
Seven components with no importer, removed. The interesting part is not the
deletion, it is that an import census was NOT sufficient to authorise it, and
two other agents proved that rather than me.
WHAT AN IMPORT CENSUS MISSED. internal/check-gospel-set-charts.mjs iterated
['BoundedSet.vue', 'CenteredSetTarget.vue'] as literal STRINGS and read their
props off disk. No import, so no census and no build would have warned; deleting
them would have turned a gate red on main with nothing to explain it. gospel
found that in their own file after I asked, and retargeted the arm at the
components that actually render (GiBoundedSetMap / GiCenteredSetMap, 2d2e853a7).
Their retarget also fixed a quieter defect: the seed guarantee had been asserted
against two files NOTHING renders, so it would have kept passing while the
shipping component grew a seed prop.
I did not take either agent's word for it. Verified gospel's retarget on their
sha and ran their gate green before deleting; verified upg100's structural claim
that packet-render.js cannot mount a component at all — 0 resolveComponent, 0
defineAsyncComponent, 0 dynamic import(), with the same probe returning a hit
elsewhere in library so an empty result is a real absence.
DELETED: charts/LanguageTreeChart.vue, components/charts/VisionFunnel.vue,
components/charts/BoundedSet.vue, components/charts/CenteredSetTarget.vue,
components/legends/ListSelectorLegend.vue,
components/dashboards/DashboardBreadcrumb.vue, composables/useMapSelection.js.
NOT DELETED, and worth stating because the names collide: the lowercase
boundedSet.js / centeredSet.js HELPERS are load-bearing — packet-render.js and
GiBoundedSetMap import them directly, and check-gospel-set-charts still does too.
Also left alone: colors/resource.js (a colour STRATEGY resolved by MODE NAME
through _registry.js, 'resource' appearing in mapStore.js and GiResources.vue)
and layouts/app-wrapper-material/nav.js (115 mentions). Both look dead to an
import census and would break a working map.
check:no-dead-library scans for a filename as a STRING across internal/,
library/ and plugin-designs/, and:
· DISTINGUISHES A READ FROM A COMMENT (gospel's ask). check-packet-variants.mjs
discusses CenteredSetTarget.vue in prose while asserting against JSON. A
comment hit is reported, never fatal — a gate that cannot tell the two apart
cries wolf once and is then ignored. It currently reports 4 such mentions.
· IS TWO-SIDED (upg100's ask). It also asserts a KNOWN-PRESENT dynamic
reference is found — LegendDesktop lazy-loads LegendMobile through import().
Mutation-tested: blinding the scanner makes it FAIL on that control instead
of passing with "no references found", which is what a one-sided version
would have done.
· ignores a longer identifier that merely contains the name, so GiBoundedSet.vue
is not read as a reference to BoundedSet.vue.
Both arms mutation-tested and every mutated file restored byte-identical.
Registered in all three places and verified by re-reading the parsed
package.json — the suite insert silently hit the scripts KEY instead of the suite
string on the first attempt, which verifying caught and trusting would not have.
17 of 25 library components that ship motion carried no reduced-motion rule, so
they animated regardless of the reader's OS setting.
The reason it cannot be fixed once, centrally: a @media (prefers-reduced-motion)
rule in the host document does not cross a shadow boundary, and these render
inside one. Measured while surveying: NO generated leaf page ships such a rule
at all — 0 occurrences across the whole built tree — so there was not even a
page rule to inherit. Each component owns its own.
CSS-ONLY AND ADDITIVE. Nothing but new @media blocks; no existing rule changed.
Verified by diff: 0 real removals across 17 files. The two lines git reports as
removed are a trailing comma moving as a new string was chained onto an existing
concatenation, and both original rules are still present — checked by grepping
for them, not by reading the diff.
WHERE MOTION CARRIES MEANING, it is kept rather than hidden:
· SemanticTreeLegend's spinner MEANS "loading". Stopped dead it is a static
ring that says nothing, so it is slowed to a step crawl (2.4s, steps(8)) —
visibly working, no smooth spin.
· LegendProgressBar's only motion is a hover filter, so it simply stops; the
bar's FILL is geometry, not animation, and still reports its value.
The rule goes where each component's styles actually go, and that varies more
than expected: 13 append into a useShadowStyles template literal, 3 into
string-concatenated sheets, and VerticalTabsLayout into its VTL_SHADOW_CSS
constant AND its duplicate <style> block, because its own header says the two
copies must stay identical.
check:reduced-motion asserts both halves, each mutation-tested to fail before I
trusted a pass: strip the rule from an animating component and it fails; move a
rule from the injected sheet into a <style> block and it fails too, because on a
useShadowStyles component that rule would never reach the shadow root. Both
mutated files restored byte-identical.
Two of my own errors caught by tooling rather than by review, and worth naming:
my first patch asserted useShadowStyles existed in every target and RAISED on
VerticalTabsLayout instead of silently skipping it — the assert-the-anchor habit
from the last registration failure working as intended. Then I appended CSS
after the label argument on two concat-built sheets, corrupting the injection
key; check:shadow-css-parses named both files and the fix was to insert before
the label. A gate I did not write caught what I got wrong.
Registered in all three places and verified by re-reading the parsed
package.json.
Three copies of the same control: SemanticTreeLegend's .stl-collapse-btn, and
two in LegendDesktop — the data-view caret and the detail-mode button, whose own
comment says it "Matches SemanticTreeLegend's .stl-collapse-btn look". It
matched the look. A control is not its look, and the copies lost both of the
parts that do not render:
· aria-expanded. A disclosure control that never reports its state leaves a
screen-reader user with a button called "toggle legend" and no way to learn
whether the legend is open. This is the same defect I fixed in LegendMobile
last week, in a component sitting in the same folder as the fix.
· the RTL arrow. STL carries .stl-collapse-btn:dir(rtl) svg{rotate(0deg)}
because a panel that slides off the other edge needs its caret to point the
other way. LegendDesktop has no :dir(rtl) rule at all, so in Arabic its
caret points AT the panel it would open.
The label also now names the ACTION rather than the control: collapseLegend /
expandLegend instead of a fixed "toggle legend". All three keys were already
translated in all eleven locales, so this adds no translation debt.
THE ARROW AND THE ARIA COME FROM ONE PROP. They drifted apart because they were
two independent pieces of markup that happened to agree; `open` now drives both,
so a future copy cannot get one right and the other wrong.
STATE STAYS WITH THE CALLER. Two consumers drive collapse from uiStore's
tri-state cascade (fullyOpen -> open -> collapsed) and one from a local boolean.
A shared control that quietly adopted either would have broken the other, so
this one owns no state and no persistence — it renders what it is told and emits
the intent.
TWO THINGS DELIBERATELY UNCHANGED. The detail-mode control was an INVISIBLE
28x28 hit target — transparent fill, no border, transparent colour — over the
panel's corner; making it suddenly visible is a look change nobody asked for, so
the slot keeps the position, the size and the invisibility, and only the
semantics improve. And LegendDesktop's four light/dark theme rules stay in
LegendDesktop: they are its themes, not the control's. They outrank the shared
base on specificity (0,3,0 and 0,2,0 against 0,1,0), so which sheet is injected
first does not decide the colour.
Caught while writing it: the first draft styled the caret with <style scoped>,
which defineCustomElement silently DROPS for any child loaded through
import.meta.glob — it would have rendered unstyled in every shadow root while
looking right in the source. That is what useShadowStyles exists for, and what
every sibling legend component already does.
Two cards, BUILD-LOCALIZED-COUNTRY-NAMES-ISO3-TO-ISO2 and
BUILD-ISO3-TABLE-COVERS-ALL-242-COUNTRIES. I measured before building and most
of both already exists: countryName(iso3, lang) is in library/utils, and the
bridge is already 242 entries with an exact correspondence to countries.json —
0 codes missing, 0 orphans, verified in both directions rather than by trusting
the table's own _meta. All 242 resolve in en, ar and zh today.
So what was actually missing is the thing that keeps it true. Writing the table
and keeping it correct are separate acts, and only the first one looks like work
— which is the same reason this repo already has a gate for orphaned gates.
check:country-names asserts both directions of drift (a data code with no
alpha-2, an alpha-2 the data never uses) and that every code resolves to a real
NAME rather than to its own input, which is what Intl returns for a well-formed
code it does not know.
★ IT CARRIES TWO ARMS THAT MUST FAIL. A coverage gate that only counts hits
passes identically on 242 correct entries and on a table where every lookup
silently returns its input, so the last block feeds it an unassigned code and a
malformed one and FAILS if either is reported as resolved. Mutation-tested in
both directions before committing: deleting TCD from the bridge fails with
"1 code(s) with no alpha-2"; pointing TCD at a bogus QQ fails with three
unresolved lookups, one per locale. Table restored byte-identical after.
ar and zh are not decoration: ar is our only RTL locale, and zh uses a script
where an untranslated Latin code is unmistakable on sight.
NOT WIRED INTO THE REGIONS TABLES AND POPUPS, deliberately, against the brief.
All four regions fetches already send &lang=, so the API returns country labels
in the reader's language and summarizeRegions names each country from that
label — those surfaces are already localized. Swapping countryName() in would
replace a correct localized name with a differently-spelled one: Intl's English
differs from our canonical names for 42 of 242 countries ("Cabo Verde" ->
"Cape Verde", "Bonaire, Sint Eustatius, and Saba" -> "Caribbean Netherlands").
I re-measured that 42 rather than trusting the header that claims it. Changing
which name is canonical is a product decision and it would break clone parity
against live doxa.life, so it is not mine to make inside a localization card.
public/README.md's bundle count moves 24 -> 25: generated by bun run tree when
the country-map bundle was added on the parent commit, not hand-edited.
DX-life's country pages render nothing where a map belongs. The map they need
has been in this repo since the regions clone landed — it was just unreachable
without loading the whole regions dashboard, a bundle that fetches the people
group list, builds four stat cards, a region chooser and a table, none of which
a country page wants. So this is not a new map. It is the same component, given
its own element and its own bundle.
<doxa-country-map profile-config='{"countryCodes":["TCD"],"colorByPrayer":true}'>
RegionCountryMap and useRegionsI18n move to library/ because they now have a
second consumer, which is the only thing that earns a place there. Six import
sites updated in this commit; nothing else moved.
THE CONTRACT arrives through profile-config, the channel every element here
uses, so a generated page and a hand-written host page are configured the same
way: countryCodes, center, zoom, bounds, colorByPrayer, researchUrl,
prayBaseUrl. Two translations happen at the edge rather than by widening the
component — colorByPrayer is a boolean here and colorMode a string inside, and
countryCodes accepts an array OR "TCD,NER,MLI", because an HTML attribute is a
string and JSON-inside-an-attribute is the shape people get wrong.
THE ELEMENT FETCHES ITS OWN PINS, and that is what makes prayBaseUrl part of the
contract rather than decoration. An element handed a country code, a base URL
and no way to fetch would draw a country outline with nothing inside it — the
blank page it exists to fix, in a new disguise. Same endpoint, same fields, same
mapping as the dashboard; it asks for a country instead of the world.
THE FIELD LIST TRAVELS WITH THE MAPPING, in peopleGroupPoints.js. The obvious
version of this commit asks for PEOPLE_GROUP_LIST_FIELDS — the list the stats
need — and every pin still draws, every popup comes up empty, and nothing logs
anything. The popup half is what makes it a map you can read, so the two are
one export and neither can be requested without the other. The dashboard's own
request is unchanged: the concatenation produces the identical string.
countryUrl is additive, not in the confirmed contract. The component's country
link needs a base and DX-life's country pages live under /regions/; a host that
sets nothing gets that documented default.
Narrowed by a measurement that arrived after the gate landed. The header and the
package.json description said the ordering was "not the cause" of the grey
Regions tab. True, but far too weak: the ordering is UNCONSTRUCTIBLE today.
region-dashboards.vue:1091 gates the ENTIRE map subtree behind v-if="loading",
and loadRegions assigns points.value inside the try while loading.value = false
sits in the finally. So points are always populated before the element that reads
them is permitted to mount — the map cannot mount with empty points, which is the
one thing this ordering requires.
rega measured it rather than reasoned it: 0 grey in 36 mounts, including a run
with the prayer API held back 20 seconds. The map was constructed AFTER the data
every time (API 44.4s -> construct 45.2s -> addLayer with 175 match arms). The
prediction's precondition never occurred, so it was neither confirmed nor
falsified — it was unreachable.
So this gate guards a FUTURE refactor that drops that v-if data gate. That is a
narrower and more useful claim than "hardening", and it is what the next reader
needs: someone removing the loading gate for a good reason would otherwise
reintroduce a flat-grey fill with nothing to catch it.
Comments and one description string only — no code changed, verified by
filtering the diff to non-comment lines (empty). The gate still PASSES, and
still FAILS when the repaint is stripped, so narrowing the prose did not weaken
the instrument. gates:pre 52/52 on development.
The grey Regions tab stays OPEN and is not this. The untested dimensions are the
engine (all 36 mounts were MapLibre; the Mapbox path reads a vector
country-boundaries source that PRODUCTION-MATRIX:235 records as 403-ing on the
sandbox token), the Plugin Studio preview entry, and what "grey" means — a whole
grey canvas and pale country fills are disjoint bugs.
window.__doxaStudioWidgets keeps the corner hosts across a hot update so a live widget keeps its
state, but a real unmount never released it, so the hosts and every widget element in them stayed
on the page. It is now released in beforeUnmount, except during a hot update.
The desktop legend gets :export-enabled and @export from this file's template, so
a reader at a desk can print a poster or a packet. LegendMobile does not read
props from here. It reaches for an injected handler and decides whether to offer
the control at all by whether one exists -- mobileExportEnabled is
`typeof onLegendExport === 'function'`. Nothing here provided it, so the phone
showed no export while the same map on a desktop showed both.
That is why it went unreported for so long: it is an ABSENCE, not a broken
button. Nobody clicks a control that was never drawn, and the desktop, where this
kind of thing gets checked, was always correct.
The provide carries EXPORT_FEATURE_ENABLED rather than the bare function. The
desktop already passes that flag, so providing unconditionally would hand a phone
an affordance the desktop deliberately hides when the flag is off; and because
"is there a handler" IS the mobile switch, passing null when it is off is exactly
what makes the sheet hide the control. countries-map fixed this first and its
comment records the same two causes on my-upg-100-list, so this is the third
instance of one pattern rather than a new bug.
Measured before building: the sibling item in the same diff, the RTL-safe CSS the
research map was said to owe the country map, is already done -- zero physical
declarations remain once comments are excluded, and the one apparent hit is prose
describing why a padding exists.
closes#15 of COUNTRY-RESEARCH-MAP-DIFF
From the selected green group's brief: the unreached peoples that count it among their nearest believers, at the chosen E-level, closest first (the same list as the CSV), the closest 30 by name with a pray.doxa.life link where the group has one. The letter is editable, then copied or downloaded as a .txt (UTF-8, readable in any editor). It opens in a dialog that takes focus, keeps it and closes on Escape.
check:composite-freshness found stale on main: key-cities. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
usePosterLayout has always computed a real layout: the map narrowed by the legend
column and inset by the margins, the title strip and the footer strip. Nothing in
the exporter ever read it. The map was rastered at full page size -- edge to edge,
across the margins and both bands -- and the title, legend and footer were then
composited on top of that image. The legend lay over the map it describes, and
there were no bands at all.
What kept it hidden is that PosterPreview.vue DOES honour layout.map, so the
on-screen preview has always shown the intended design and only the exported file
was wrong. A preview is not evidence for an export.
The obvious fix breaks a different product. country-posters embeds this renderer's
output as a full-sheet <img> inside its own packet page, beneath the packet's own
title, so chrome that is on by default prints a second header and footer within
their page. UPG-100's packet does not call this renderer at all and was never at
risk -- checked rather than assumed. So the composed sheet is requested with
spec.layout === 'poster', and every existing caller, which sets none, keeps what
it had.
That guarantee is stronger than "equivalent": with no layout the renderer passes
the SAME px OBJECT it always did to the rasteriser and composites no sheet, so the
untouched path is unchanged by identity, not merely by value. The check asserts
exactly that, by executing the decision with four real caller shapes -- including
country-posters' A4 at 150 DPI -- rather than reading the source for a promise.
Its second negative test is the ruling's own failure mode: make defaultPosterSpec
set the layout and country-posters is named as silently opted in.
A sheet with no room left for a map after its margins, bands and legend column
falls back to full-bleed and says so, because silence there is the bug above.
Simple Research's export asks for the layout explicitly.
The page-1 notice for a missing map existed only in English, which
turns check:packet-strings-parity red: the packet catalogue must be
whole in every language, and English-by-choice is not one of its
declared exceptions (only the licensed Mark 5 passage is).
All eleven blocks now carry the sentence, reusing each block's own
page-1 wording for "prayer list" so the notice reads like the line
above it. The English says "prayer list" rather than "zone": zone is an
internal term, and the page itself calls it a prayer list. The Chinese
block is Traditional script, so the sentence is too, with the Taiwan
term for printing.
The loud gate's note described the old English-only state; it now
reports which locales fall back, or that none do.
Page 1 of every packet draws the zone map from mapSvg. When the map
fetch fails, the page interpolated an empty string: a title, a
subtitle, a privacy footnote, and nothing where the map belongs. A
failed fetch and a successful render were byte-indistinguishable in
the printed PDF, and the packet read as undeveloped.
It is not a rare edge. The served asset pool ships boundaries, fonts,
images, people-groups, pplr, previews, tables and vendor, and no
posters/ directory at all, because generate-tree gates it behind
posterSources and bundlePosters, both default false. Over live HTTP
against the served tree: previews/sudan.webp 200,
boundaries/world-adm0-web.geojson 200, posters/sudan.html 404. So on a
default build every packet's page 1 has been blank.
The browser path's catch around the poster fetch is right to keep
printing -- a missing map must not cost the reader the people pages --
but degrading and degrading silently are different things, and only
the renderer knows the map is absent when it draws the page. It now
prints one quiet sentence in the map's place.
The sentence is English-only on purpose. t() falls back to English per
key, so the other ten locales print it in English until a translator
supplies theirs, rather than ten invented translations nobody can
check.
The real fix for the blank map is shipping the poster sources in the
asset pool; that is a tree-config change and goes separately.
check-packet-map-missing-loud is two-sided: loud when the map is
missing AND quiet when it is present with the SVG intact, in en/es/ar,
both variants. Shown capable of failing four ways: the silent cover, a
notice that always prints, a notice that displaces the map, and the raw
key printed instead of a sentence.
The worktree example branched from main and pushed to it; main is production now and the team lands
on development. The quick start cloned doxa-open/doxa-maps, which is not the repo, and sent readers to
localhost:5173 while the dev server takes the next free port when 5173 is busy (a fresh clone's friend
test came up on 5174).
The joining page, DEPLOY and the laptop sync default moved to development in the
commit before this one. This finishes the move and closes what a fresh clone's
friend test found.
- landing-preconditions diffs against origin/development when no landing branch
is exported, instead of main.
- CLOUD-DEVELOPMENT-ENVIRONMENT, the plugin-designer skill (SKILL.md, workflow,
glossary) and the shared dev server's notes land on and follow development;
the skill says never to push to main.
- The sync fetches with the laptop's own credentials. git runs with prompts off,
so on a clone with no credential helper it failed every interval with a bare
"fetch-failed / remote: Unauthorized", which read as a broken server. That case
is now its own state, needs-credentials: the log (once, not every tick) and
/api/dev-sync say "dev-sync needs git credentials for <remote>". The joining
page and .env.example say so, and that a fresh clone starts on main and must
switch to development before the sync can fast-forward it.
Measured in scratch repos: the private remote with no helper gives
needs-credentials and one warning over two ticks; a local remote with no
DX_SYNC_BRANCH follows development and fast-forwards; a missing remote stays
fetch-failed.
Page 1 of every UPG-100 prayer packet is the zone map, drawn from
<tree>/assets/posters/<id>.html. The tree shipped no posters/ at all:
generate-tree gates the folder behind posterSources and bundlePosters, both
default false, so the fetch 404'd and the packet, which rightly keeps printing
without its map, printed page 1 blank.
The dx-maps config now sets posterSources: the 28 sources, without the 41 MB of
.webp rasters. Measured: 27 MB raw, 7 MB gzipped. The copies are byte-identical
to the tracked data/posters/*.html, so git stores each blob once and a push adds
no new pack bytes. The code default stays false for other deployments.
check:poster-print-fallback now RUNS by default instead of skipping. Part 1 needs
no browser and runs in every wave: when the config ships sources, the built tree
must carry every data/posters/*.html, byte-identical (a stale copy prints an old
map just as quietly). It FAILs 28 of 28 on the tree before this change and PASSes
after. Part 2 clicks the real print control and is NOT MEASURED (75) outside the
browser slot; with no url it serves this checkout and opens the UPG-100 wall. The
skips that exited 0, which a wave reads as a pass, now exit 75. Registered in
gates:post.
The joining page now teaches laptop (the workshop), development (the table everyone lands on, followed by the
shared dev server) and main (production: /cdn, the websites and every partner snippet load it; never pushed to
directly). Its commands pull and push development. Schema is made on a laptop, never in the shared server's
PocketBase screen, whose automatic migration files are lost at the next restart.
DEPLOY: the cloud server follows development and publishes main at /cdn (PRODUCTION_BRANCH=main); publishing
merges development into main; EMBED_BASE_URL makes snippets absolute; /cdn revalidates within 5 minutes. The
drop-in example pointed at a deleted branch and now names research-gallery-drop-in, with a link to running all
three DX-life ways on a laptop.
Laptop sync (DX_SYNC_REMOTE) follows development by default, so teammates' landings arrive before they are
published.
The Driver, on Simple Research: clicking print "breaks the CSS" -- the map
controls and the legend jump upward.
It is not the Export->poster path. That prints an off-screen iframe whose whole
body is a single <img> carrying its own @page, and no CSS in this plugin can
reach inside it. This is the browser's own print of the live page, for which this
plugin had no print rules at all.
The mechanism comes out of the cascade rather than a screenshot. .rm-root is
position:absolute with inset:0 and no positioned ancestor, so its containing
block is the initial one: the viewport on screen, the PAGE BOX in print.
.rm-map-area draws all of its height from that root through flex:1 1 0, and the
canvas, the legend, the badges and the scroll edges are each absolute;inset:0
inside it. In a paged medium the root stops resolving to a viewport height, the
flex child collapses toward zero, and everything pinned inside it stacks at the
top of the sheet. That is the jump the Driver saw.
So in print the root stops being absolute and flows in normal document order,
and the map area is given an explicit height, which restores a real box for its
absolutely-positioned children to sit in.
Overlays that describe INTERACTION rather than content are hidden: the scroll
affordances would print as grey bars down both edges, and a provider badge and a
loading veil say nothing on paper. The legend stays. It is the key to what the
map is showing, and a printed choropleth without its legend is an unreadable
picture.
Verified by reasoning over the cascade, not in a browser: this needs eyes on the
print preview before the card closes.
closes the print half of DRV1650-SR-POSTER-EXPORT
One bubble per bridge language, on its largest unreached group and sized by the unreached population a campaign in it reaches; it follows the search and the believers filter, names itself on hover and picks the language on a click. The map was empty until a language was chosen.
The panels, legend, map notice, list text and arrows use the reading direction (in Arabic the panel sits on the right and the map frames its selection in the part you can see); the P-level counts use the same number format as every other count. How it works takes focus when it opens, keeps it, closes on Escape and gives focus back to its button.
Share opens the phone's share sheet for the group's prayer page, or copies the link and says Copied!. The loading overlay was position:fixed and covered an embedding page; it now sits inside the wall like the offline notice. Landscape / portrait arranges the Social board only: in Bless mode the wall is the poster.
The screen shows the printed sheets themselves, stacked as they print (three 60 × 36 in pages for 5 × 8 ft, glue bands and all). Every card sits where it prints, sized by the same cardText() the print uses; the title, the prayer-themes chart and the footer are the print's own HTML in an overlay that follows the zoom and lets every click through to the wall.
The chart's rotated labels are sized from their own text and wrap; its theme headers fit their row. A long group name shrinks to fit (never below 6 pt) or takes two lines, and the photo gives way so the card's content never outgrows the card. The verse reference prints at 6 pt. The size labels are isolated left to right, so an RTL page no longer reorders '5 × 8 ft · 3×'.