Choosing a language in the studio translated the plugins it mounts but
left the studio itself in English: the title, the count, the filter box,
Collapse all, the section names, Copy embed, the loading and error
messages, and the rail's own Collapse button and aria labels.
The studio's strings now live in library/i18n/locales/<lc>/studio.json
for all 11 locales, held to key parity by check:i18n-parity like every
other catalog. The studio has no vue-i18n instance, so it looks strings
up directly (locale, then English, per key). The Explorer tree takes
them through a `strings` prop. VerticalTabsLayout gains an optional
`labels` prop for its five words; hosts that do not pass it keep the
English defaults, so doxa-maps-spa is unchanged until it opts in.
Verified by switching the studio through every language its switcher
offers (en, es, fr, pt, ar, ru): no English left in the studio chrome.
Technical names (CAP n, element tags, tier machine names in tooltips)
stay as they are.
template-bundle is the scaffold DX copies to start a plugin: its source
ships, but it is not a plugin. The dev manifest already skips it by
name (discover.mjs), while the build published it, so the built studio
listed template-map-a and template-map-b as two blank-looking plugins
that the dev studio never showed.
generate-tree now skips it by the same name rule, so the built
manifest lists exactly what dev lists: 21 bundles, 33 profiles. The
committed tree is brought in line without a full regeneration: the
entry is removed from manifest.json and from the staging page's baked
copy, the two leaf pages are deleted, and the archived gallery is
regenerated from the manifest. It is not added to gatedProfiles, which
is the private-data list.
Verified: check:sri and check-manifest-covers-discovery pass, the
staging page renders with no errors, and a sweep of the built studio
shows 31 plugins and no template rows.
Both called useI18n() UNGUARDED. That function THROWS ("Need to install with
`app.use` function") on any host that never installed vue-i18n, and seven
bundles here do not — big-picture, doxa-maps-spa, doxa-vision-card,
golden-path-check, people-groups-grid, plugin-studio, stack-demo. In a library
component this widely consumed, an unguarded call is a BLANK PAGE waiting for
the first bare host that renders it: exactly how library FilterDropdown blanked
people-groups-grid, a production plugin, fixed by studio in 6603dc17.
★ THIS IS PREVENTIVE, NOT A FIX FOR AN OBSERVED BREAK, and the distinction
matters. MEASURED: no bare bundle currently consumes either component, so the
blank page cannot be reproduced today — which matches the shepherd's framing
("LIVE today: none; latent debt"). What is fixed is the embedding hazard, and
the gift policy asks for plugins that work "standalone AND embedded".
⚠ THE FALLBACK IS DERIVED, NOT A TABLE. Hardcoding English for these eleven
keys would put a second copy of the `en` catalog inside the component, and two
copies of one fact drift — the exact trap behind the embedBase asset-path bug
this week. Humanizing the key's last segment gives "buttons.prayerList" ->
"Prayer List", "buttons.cardDeck" -> "Card Deck", "legend.columns.name" ->
"Name": readable English that CANNOT go stale, rather than a raw key shown to a
reader. te() answers false so every caller falls through to its own default.
VERIFIED: the fallback produces the correct English for every real key these
components use; with i18n present nothing changes —
check:legend-info-localized PASS (es/ar/zh still translate, 0 blank),
check:poster-card-fits PASS across 5 widths x 3 locales,
check:a11y-button-names PASS, SRI consistent.
I also confirmed 6603dc17 is an ancestor of my HEAD before pushing, per the
shepherd's warning: the FilterDropdown fix is intact in my tree, not reverted.
createAppI18n sets document.documentElement.dir=rtl for Arabic, which is right
for a translated app. Every string in these five profiles is hard-coded English
— measured at ?lang=ar: identical word count to ?lang=en and zero non-Latin
characters — so the flip gained nothing and cost a great deal. On the built
tree it clipped the legend title's first letter against the left edge, turned
30-59% into 59%-30, moved every colour swatch to the far side of its label,
threw sentence-ending periods to the START of lines, and dropped the zoom
controls on top of the search box. Not untranslated: scrambled.
The five profile roots declare dir=ltr until their strings are translated, and
the comment says to take it off on that day. The host page's own dir is left
alone, as createAppI18n already intends.
Arabic now renders exactly as English does: 12,379 of 12,379 pins, full
choropleth, nothing clipped.
On mount the map centred its active tab with
btn.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' })
and 'nearest' does not mean "do not scroll the page". It means "scroll every
scrollable ancestor by the least amount needed", and on a partner page the HOST
is an ancestor. Measured by the embed sweep: a page carrying this map below the
fold jumps to y≈640 the moment it mounts — on a phone, where Simple Research is
the default tab. The reader asked for nothing and the page moved under them.
The tab strip now scrolls ITSELF: compute the offset that centres the active
button inside its own box, clamp it to the strip's own scroll range, and write
scrollLeft. Same shape as SemanticTreeLegend's bringRowToTop, which scrolls the
container it found rather than asking the platform to walk ancestors. An element
that only ever writes its own scrollLeft cannot move anybody's page.
The exemption goes in the SAME commit, per that gate's contract — main never
carries a stale exemption or a red gate on someone else's bug. Both
NEIGHBOUR_KNOWN entries (research-map, research-map-plus-grid) are deleted here.
MEASURED after, with the exemption gone: check:gallery-embed PASS, and the
neighbour phase now reports "research-map leaves the host page alone" and
"research-map-plus-grid leaves the host page alone" on its own evidence — js 13
tabs, iframe 13 tabs, neighbour 13 tabs. The two KNOWN lines still printed belong
to regions-dashboard and are theirs to clear.
gospel-dashboard.vue declared @element gospel-resources-dashboard. index.js
registers exactly one custom element, gospel-resources-map, and every profile is
selected through its profile-config — which is why all five generated leaves
embed gospel-resources-map and work. The manifest reads that JSDoc line, so the
catalogue offered consumers a tag that resolves to nothing: copy the snippet
into a page and get an empty box. Invisible in dev, where the leaf HTML is what
loads, and only findable on a built tree.
Also retires the numbers shipped alongside it, which described a different
dataset: 3,457 groups with nothing became 1,613 and 347 million people became
208 million when the GRN sweep finished, and the tables profile still called
itself Eight tables over 3,550 resources when it carries eleven over 8,437
across 4,231 languages. The manifest is what a consumer of this map reads first.
- 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.
On a phone, opening the studio's rail over the research dashboard left
the dashboard's filter row painted across the drawer. Mounted content
shares the drawer's stacking context, and the dashboard raises its
filter bar to z-index 55 while the drawer sat at 20.
While the drawer is open on a phone it now sits at z-index 1000: at
that moment it is the only thing you can use. Desktop and plugin modal
scrims keep their existing order. Checked at 390px on dashboard-plus-grid
in the studio: the drawer covers the dashboard completely. doxa-maps-spa
shares this layout and gets the same fix.
Five codes still carried the retired ?languageId= URL, which nothing can verify.
Asking Arclight what each language actually has:
ars Arabic, Najdi The Savior (8 groups; film was their ONLY resource)
gjk Kachi Koli Magdalena - Director's Cut
mzn Mazanderani Magdalena - Director's Cut
lia Limba, West-Central no feature at all
jos Jordanian Sign Lang left alone, see below
counts.featureFilm >= 1 says a feature exists, not that it is JESUS. Three of
these had a real JESUS Film Project film in their own language under a different
title, and the map called all of them the JESUS Film — the same class of lie as
calling Open Bible Stories Scripture. They now ship under their own names, with
verified deep links; lia's film is dropped, because no component carries it and
a link nobody can check is worse than none.
jos is left exactly as it was. Arclight files iso3 jos under a row whose own
name reads Lebanese Sign Language, while the JESUS Film sits at a different id
named Jordanian Sign Language. The ISO join and the publisher's label disagree,
and resolving that is a data question, not a guess to make inside a fetcher.
The shepherd reported a P0 I caused: db36397b made library FilterDropdown call
useI18n() unconditionally, which throws "Need to install with `app.use`" on any
host that never installed vue-i18n. people-groups-grid deliberately has none, so
the grid rendered blank standalone, in the studio and in every composite nesting
it -- one of the seven production plugins going to DX. Studio fixed that
instance in 6603dc17.
That fix was NOT on my branch. 6603dc17 was not an ancestor of my HEAD and my
tree still carried the unguarded call, so my next push would have reverted it.
Merged before doing anything else.
PinHint carried the same unguarded call, added by me in 9ba6ad7a. Guarded the
same way: host translations when present, the English literal otherwise, with
te() answering false so callers fall through to their own defaults.
The gate the shepherd asked for is _chk-library-i18n-optional.mjs. It scans
library/components for useI18n() calls that are not behind a guard, and it
separates LIVE from LATENT, because a wall of identical failures gets ignored
while a list of one gets fixed: it computes which bundles install no i18n and
which of those actually import each offender.
Measured: 17 library components still call useI18n() unguarded, 7 bundles
install no i18n (big-picture, doxa-maps-spa, doxa-vision-card, golden-path-check,
people-groups-grid, plugin-studio, stack-demo), and LIVE is currently none --
no bare host renders an unguarded component today. The 17 are debt, not safety.
They are left for their owners rather than rewritten from here, since library/
reaches every bundle.
Two of my own errors on the way, both caught before they reached anyone. I first
reported 14 bare bundles, because I grepped for createI18n when the install is
usually createAppI18n from @map/i18n -- country-posters does install it. And the
gate first reported PeopleGroupDetail as LIVE in people-groups-grid on the
strength of a COMMENT mentioning it: a substring match cannot tell an import from
prose, which is precisely the defect I reported in check:shared-reached a day
earlier and then rebuilt myself. It now strips comments and requires a real
import statement or an element tag.
Two gaps found by opening every plugin in the studio under the dev
server:
- key-cities is gated (vite.doxa-maps-build-config.json gatedProfiles)
out of every public surface the build emits, but the dev manifest
listed it anyway, so the dev studio and the Research Gallery's
Experimental shelf showed a plugin the built tree deliberately hides.
The dev model now applies the same gate: 21 bundles, 33 profiles, as
in the built manifest. readBuildConfig() does not carry gatedProfiles,
so the gate is read from the config file, as generate-tree does.
- golden-path-check takes its tree base from a tree-base attribute, not
from profile-config. The studio only passed the latter, so inside the
studio the check guessed the tree from the page URL, fetched
/manifest.json, got a 404 and showed an empty self-test. The studio
now sets tree-base on every element it mounts; elements that do not
read it ignore it.
Dev sweep after: all 31 listed plugins mount with content, no page
errors, no failed requests. Rebuilt the plugin-studio bundle and
re-baked its SRI.
Embedded below the fold on a phone-width host page, the gallery disturbed
the page it was a guest on:
- The host's inherited typography flowed into the gallery. Shadow DOM
isolates selectors, not inheritance, so a host with body{font:20px/3;
letter-spacing:2px} re-flowed every rail row and legend (root measured at
20px / 60px / 2px). :host now resets the inherited text properties to the
gallery's own defaults (16px / normal / normal). Direction still inherits,
so RTL hosts stay RTL.
- Nested maps wrote the host's URL and title. The gallery now tells every
child urlSync:false. Regions needs to honour it (routed).
check:gallery-embed gains a good-neighbour phase: phone width, gallery
below the fold, hostile host CSS, the host's own #anchor and <title>. After
load and after every tab it asserts no host scroll, an unchanged URL, an
unchanged title, and no inherited typography. Two nested-plugin violations
are listed, owned and printed in NEIGHBOUR_KNOWN until their owners fix
them (same contract as MAPBOX_KNOWN):
- research-map.vue:647's scrollIntoView jumps the host to y=640 on load on
a phone.
- Regions' unconditional URL/title sync.
A boot timeout is now a named FAIL instead of an uncaught crash. The gate
caught a real one on the way: a rebuilt bundle whose leaf page still
pinned the old SRI hash.
It crashed on ENOENT — app/doxa-maps/gospel-resources/assets/tables/, a tree
deleted when data moved out of public/. Nothing noticed, because nothing ran it:
it had no npm script, so it was neither discoverable nor wired anywhere.
The dead path was the smaller half. Its other input was a private by-country
copy under plugin-designs/, and its people-group table was the curated 2,106-row
subset that resources-map.vue stopped fetching some time ago — it says so in its
own comments; the country sheet is built from the 12,379-pin index now. Repaired
in place it would have gone green about a surface it was no longer looking at.
Both inputs are the live tables. The country join is ISO3 instead of a substring
test against a comma-joined name blob, which the file's own comment admitted was
under-counting because country names contain commas. It reads 242 of 242
countries resolving to a pin and 8,435 sheet links, through the same flattener
the sheet renders from, and its self-test now requires four corruptions to go
red rather than two.
Also gives it and the stale-sweep gate an npm script, and corrects the link-rot
entry, which still told readers that jesusfilm.org and fivefish.org can never be
status-checked — the thing that gate stopped believing two commits ago.
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.
Click a country on the map, follow through to the table, export: you get 264 of
8,438 rows, correctly filtered, in a file called
gospel-resources-every-link-and-who-it-reaches.csv. Do it for a second country
and you get the same filename again, so the second download overwrites the first
or lands as "(1)". The rows were right and the file was unidentifiable, which
is the whole failure for the person this export is for — someone pulling a list
per country to hand to a team.
The filter now travels in meta.title, which is what the download is named from,
so China arrives as -people-s-republic-of-china.csv and German as
-german-standard.csv. Done here rather than by teaching the shared DataTable a
new prop, keeping the decision this file already made about a component with
other consumers, and it fixes the heading in the same move: it read "every
link, and who it reaches" over a table showing one language's six.
base/_reset.scss's `img, picture { max-width: 100%; display: block }` is a
document-level element rule, so it never reached our shadow root. Measured on a
COUNTRY page, where the uupg photos actually render: their img.uupg__image has
max-width 100%, ours had NONE, so an image wider than its grid cell overflows
our card where theirs is constrained. display already matched, and
height/border-radius/object-fit already match because our .uupg__image class
sets them (100px, 7.5px, cover), so max-width was the whole gap.
_globals.scss:80's `img { border-radius: var(--border-radius-lg) }` is
deliberately NOT ported. On their site it rounds every image because every image
there is theirs; inside our root it would also round the poster-wall previews,
part of a sanctioned addition their page does not have. The elements that ARE
clones already carry the right radius from their own class.
An earlier run reported img as 'absent from BOTH pages' -- `.uupg__image` IS the
img, not a wrapper, so a descendant selector matched nothing. That is the third
time a mis-selector has masqueraded as agreement here, so the probe now fails
loudly on anything it did not actually exercise instead of counting it as a pass.
Opening every plugin in the studio under the dev server on a fresh
worktree, 13 of them mounted with empty data: every table, boundary and
people-groups file 404'd. Two causes.
- The dev asset lookup read public/assets (a mirror generate-tree
writes, absent on a fresh clone) and plugin-designs/*/data, but never
data/, the tracked source that mirror is copied from. It now falls
back to data/<rel>, which is what DX gets when they clone.
- Plugins nested in the studio resolve assets against the tree base the
studio hands them (/__plugin-gallery/), as they do against <tree>/assets
in the built tree, but only /assets/ was served. Both prefixes are
served now.
After: the same sweep shows the data 404s gone from all 13.
- 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.
db36397b made the dropdown call useI18n() unconditionally. That throws
"Need to install with `app.use` function" on any host that never
installed vue-i18n, and people-groups-grid deliberately doesn't (its
labels come through its `t` prop). The grid rendered 2 DOM nodes and a
page error: standalone, in the plugin studio, and inside every
composite that nests it (research-map-plus-grid, stack-demo).
The dropdown now uses the host's translations when it has them and the
English strings otherwise. Verified: people-groups-grid renders with no
error again, and region-dashboards in French still shows "Aucune option
trouvée".
The two curated figures — growing & unreached, deaths a day — sit under a
footnote saying how much narrower their basis is than the pins. It read "covers
391 of these 391 groups — a narrower set than the pins" for every one of the 242
countries, because the script that recomputes country rows from the pins
overwrote the curated group count with the pin count and the footnote went on
reading it. Always false, and false about the one thing it existed to qualify.
The real numbers were still in the commit that introduced the overwrite and are
restored on the rows: China 158 of 391, India 856 of 2,146. They are recorded
once now and never rewritten, because the pins change every build and a curated
analysis does not.
The same block also drew both figures for the 72 countries that have no curated
row at all — "Est. deaths/day 0", which is not a measurement. It is hidden for
them instead. A first pass at the fix handed those 72 a basis equal to their pin
count, inventing the number the footnote exists to qualify; null now means there
is no curated set here.
MEASURED while sweeping my own gates for the count-only pattern: a poster card's
TEXT reads "116 UPG · Show on map" — a count and an action, no zone. The
identity lives entirely in the image's alt ("Northwest India — prayer poster…").
So the wall is CORRECT AS BUILT — all 27 previews carry a descriptive alt, and
sighted and screen-reader readers both learn which poster is which. No defect.
But that alt was doing load-bearing work nothing asserted: remove it and the
wall becomes 27 anonymous pictures for a screen-reader user while every
geometry, clipping, hover and reachability assertion here stays green.
★ I ALMOST ASSERTED THE WRONG THING. The obvious move on seeing "no zone name in
the card text" is to require one. That would have been me inventing a
requirement nobody stated — the same line I drew on the card-stock labels and
the UUPGs header. Measuring first showed the identity was already there, in the
attribute rather than the text, so the right assertion protects what exists
instead of mandating what I would have preferred.
MUTATION-TESTED: emptying the alt binding fails all 15 width x locale
combinations with "27 poster preview(s) have no alt text", exit 1. Restored,
gate green, SRI consistent.
Open a group in the gap and the sheet listed five pools that found nothing —
Scripture, film, FCBH audio, Bible Stories, Zume — and said nothing at all about
Global Recordings Network or Scripture Earth. For 1,220 groups GRN is the only
resource there is, so silence about it at the moment of decision does not read
as "searched and empty", it reads as "nobody asked". Both now have an absent
case, with the pool named.
The GRN chip asks the data whether the pool was really searched, and that is the
part worth keeping. The sweep ran against the gap first and stopped there, so
3,202 codes that already carry resources have never been asked — the first
version of this chip told German, which certainly does have Words of Life, that
none was found. The build had recorded the truth per code all along: a swept
code lists grn under checked, an unswept one carries "grn (not yet swept)".
The chip reads that instead of assuming the sweep was universal.
Host as a placeholder and the SRI hash elided (it changes every build), with a
pointer to manifest.json for the exact copy, so the README cannot go stale.
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.
The legend's caveat is the paragraph a reader uses to decide what "nothing
found" is worth. It said GRN was being swept a batch at a time and part of the
pool was still unread. That was true while the crawl ran and false the moment it
ended, and it stayed on the page through four commits that knew better. Found by
reading a screenshot, which is the only thing that could have found it.
Corrected on all three surfaces, and scoped honestly rather than upgraded: the
sweep finished for every one of the 1,192 languages in the gap, so "nothing
found" no longer rests on an unread pool — but codes that already carry a
resource were not all asked, because the sweep was spent on the gap first. Both
halves are now on the page.
A gate holds it. The DATA decides whether the claim is allowed: while codes are
genuinely undecided it passes, and it starts failing the moment they are all
decided, so it re-arms itself for the next slow pool. It does not flag a claim
rendered under a v-if on the pending count, because that form expires on its own
and is the one to copy. Its first version asked about every code on the map
instead of the gap, measured 3,202 undecided and would have let the stale
sentence stand.
The gate counted .card elements and checked the photo/typographic split. A card
with a photo and a BLANK NAME satisfied all of it: the total held, the split
held, the page arithmetic held. Counting proves the element exists; the spec
says pages 2..n carry "the people groups, 20 per page, with photos", and a slot
that names nobody is not an entry in a prayer list.
★ MY FIRST VERSION FAILED ITS OWN MUTATION TEST, and the reason is the useful
part. It tested card.textContent. Blanking every name still left 18 characters
per card, because the typographic fallback ("no photo available") lives INSIDE
.card — so the card was not empty, it just said nothing about a people group.
The assertion passed a packet where all 95 names were gone.
Now it measures .nm, the name line the renderer writes (packet-render.js:229).
Same lesson as the nophoto count one level up, and as the consumersOf substring
test: match the thing you mean, not a container that happens to include it.
MEASURED on laos: 95 names, 0 blank, shortest "1. Aheu" — a threshold of 3 sits
far below any real entry and above an empty one.
MUTATION-TESTED against the mutation that defeated the first version: blanking
<div class="nm"> fails with "95 of 95 people-group card(s) have no NAME",
exit 1. Restored, and check:packets-all stays green across all five shapes.
Their base/_reset.scss zeroes margins with
`body,h1,h2,h3,h4,p,figure,blockquote,dl,dd { margin: 0 }` -- a bare ELEMENT
selector, specificity 0-0-1. Their `.stack > * + *` is 0-1-0, so the stack wins
and their heading gets its 10px.
We recreated that reset as class rules, `.rd-title{margin:0}` and
`.rd-summary{margin:0}`. Those are ALSO 0-1-0, because `*` contributes nothing
to specificity, so `.rd-head > * + *` and `.rd-title` tied and source order
decided it -- the later class rule won and the stack margin was discarded.
Measured on live /regions/africa, their head is a(0px), h1(10px), p(10px).
Ours was h1(0px), p(0px): the title and strapline sat flush against the back
link, 10px tight, on every region and country page. Porting the reset as the
element selector it actually is, and deleting the two class-level margin:0
declarations, restores their cascade exactly -- both now read 10px.
Found by sweeping for the defect CLASS rather than the instance: for every
`> * + *` container in our sheets, any child that is not the first and still
computes margin-top 0 has lost the same race. That turned up three candidates,
and measuring THEIR page settled the third -- their .region-goal is 0px too, so
our .rd-goal matching at 0px was never a defect. A heuristic found the
suspects; only the live measurement could tell a defect from a faithful copy.
This is the fourth of their global rules that the shadow boundary changed the
behaviour of, after box-sizing, the list bullet and prefers-reduced-motion --
but the first where the rule DID reach us and the port altered its specificity.
Copying a global element rule as a scoped class rule is not a faithful port even
when the declaration is identical.
reset-vs-live now reports 0 differences; region-vs-live 4/4, list-vs-live 21/21,
table rows 4/4, SRI passes.
Landing by rebase leaves the old branch tip orphaned, and reconciling it with a
-s ours merge is how this branch got one. The rule against rebasing over such a
merge is further down the file; the recipe that creates the condition now says
so where the work happens.