spa tab-order gate: the product honoured the Driver, the gate over-read him
Some checks are pending
build-gate / build (push) Waiting to run

Red since the rail gained its three-section structure on 2026-09-23. The Driver
said "cap-5 tab order (Big Picture, UPG-100, Prayer Progress, Gospel
Resources)". That names an ORDER, not a row count, and nothing in it forbids a
row between. The gate quoted him correctly and then asserted those four names at
positions 1-4 of a flat rail.

Measured at the rendered layer: the rail is Big Picture, Simple Research,
Regions, UPG-100 List, Prayer Progress, Cities with Internet, Gospel Resources,
plus five lab-badged Experimental rows when that folder is open. The Driver's
four sit at 1/4/5/7, all present, relative order intact. The instruction is
honoured.

Provenance rather than timestamps was the tiebreaker. My first instinct was
"source is newer, so the gate is stale" — but this gate cites a Driver
instruction verbatim, so I checked what actually moved. The suspect commit
turned out not to touch the main rail at all; the restructure was two days
earlier.

A second defect in the same gate: '.vtl-tab' matched nested elements, returning
35 hits for 7 real rows. The old code's "first four" were therefore all
fragments of rows 1 and 2 — which is why it reported row 2 as "Simple Research"
when Simple Research is genuinely row 2 of seven. Now keeps only outermost
matches.

The expectation is presence, plus tab 1 positional, plus relative order for the
rest. Tab 1 stays positional because that is exactly what the Driver claimed:
the landing surface. Mutation-tested both kinds — reversing the order fails
naming the rows, expecting a different landing surface fails naming tab 1.

PASS now prints the whole rail with the Driver's four starred, so a reader sees
the rows between them. A gate printing only its own four would hide the
structure that made the old assertion wrong.

Still not wave-wired, and the reason is on the script: it FAILS rather than
skips when the SPA is unreachable, deliberately — "a gate that cannot reach its
subject has not passed, it has not run" — which is right standalone and
incompatible with gates:post running on server-less checkouts.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 16:56:02 +00:00
parent a53953423b
commit d9d4cbd0da
2 changed files with 57 additions and 15 deletions

View file

@ -37,6 +37,21 @@ const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173/doxa-maps'
// The Driver's sequence, verbatim from the open-work brief. Prefix-matched (case-insensitive,
// whitespace-collapsed) so a row may carry its subtitle.
// The Driver's four, asserted as a RELATIVE ORDER rather than as positions 1-4.
//
// ⚠ THIS USED TO DEMAND A FOUR-ROW RAIL, and it had been FAILING ever since the rail gained its
// three-section structure (4c74bd7, 2026-09-23, "Restore three-section tab structure:
// Dashboards / Prayer Mobilization / Gospel Research"). MEASURED 2026-09-24: the rail renders
// SEVEN rows —
// Big Picture · Simple Research · Regions · UPG-100 List · Prayer Progress ·
// Cities with Internet · Gospel Resources
// — and the Driver's four are ALL PRESENT, at 0/3/4/6, in their specified relative order. The
// product honours the instruction; this gate was asserting a shape the instruction never named.
// It quoted the Driver correctly and then over-read it: "(Big Picture, UPG-100, Prayer Progress,
// Gospel Resources)" names an ORDER, not a row count, and nothing there forbids a row between.
//
// So: assert what was actually specified. Tab 1 must still be Big Picture — that IS positional,
// it is the landing surface — and the other three must appear after it, in order.
const EXPECTED = ['Big Picture', 'UPG-100', 'Prayer Progress', 'Gospel Resources']
const WALK = `(sel)=>{const o=[],s=new Set();const w=(r)=>{if(!r||s.has(r))return;s.add(r);try{o.push(...r.querySelectorAll(sel))}catch(e){};for(const e of r.querySelectorAll('*'))if(e.shadowRoot)w(e.shadowRoot)};w(document);return o}`
@ -59,8 +74,14 @@ try {
// last remaining directory URL in internal/.
await page.goto(`${BASE}/doxa-maps-spa/doxa-maps-spa/index.html`, { waitUntil: 'load', timeout: 45000 })
await page.waitForTimeout(8000)
// ⚠ DEDUPE: '.vtl-tab' matches NESTED elements, so each rendered row comes back ~5 times —
// measured 35 matches for 7 real rows. The old code took the first four of that list, which
// were all fragments of rows 1 and 2, and then reported them as rows 1-4. Keep only the
// OUTERMOST match per row (one that has no .vtl-tab ancestor), which is the row itself.
rows = await page.evaluate(
`(${WALK})('.vtl-tab').map(t => (t.textContent || '').trim().replace(/\\s+/g, ' '))`
`(${WALK})('.vtl-tab')
.filter(t => !t.parentElement || !t.parentElement.closest('.vtl-tab'))
.map(t => (t.textContent || '').trim().replace(/\\s+/g, ' '))`
)
} catch (e) {
err = e.message.slice(0, 160)
@ -82,19 +103,34 @@ if (!rows.length) {
}
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
const actual = rows.slice(0, EXPECTED.length)
const mismatches = EXPECTED.map((want, i) => {
const got = actual[i] || '(missing)'
return norm(got).startsWith(norm(want)) ? null : { i, want, got: got.slice(0, 52) }
}).filter(Boolean)
// Where each expected name lands in the RENDERED rail. -1 = absent.
const at = EXPECTED.map((want) => rows.findIndex((r) => norm(r).startsWith(norm(want))))
const fails = []
if (mismatches.length) {
console.error(`SPA-TAB-ORDER: FAIL — ${mismatches.length} of ${EXPECTED.length} rail rows are out of order.\n`)
EXPECTED.forEach((want, i) => {
const got = (actual[i] || '(missing)').slice(0, 52)
const ok = norm(got).startsWith(norm(want))
console.error(` ${ok ? 'ok ' : 'FAIL'} ${i + 1}. want "${want}" got "${got}"`)
})
// 1. PRESENCE. A name the Driver specified must be in the rail at all.
EXPECTED.forEach((want, i) => {
if (at[i] === -1) fails.push(`"${want}" is not in the rail at all`)
})
// 2. TAB 1 IS POSITIONAL, and only tab 1. It is the landing surface — the first thing anyone
// opening the SPA sees — which is a claim about position, not merely about order.
if (at[0] > 0) {
fails.push(`tab 1 is "${(rows[0] || '').slice(0, 40)}", not "${EXPECTED[0]}" — the SPA opens `
+ 'somewhere other than the landing surface')
}
// 3. THE REST ARE RELATIVE. The instruction named an order, not a row count, so a row sitting
// between two of them is not a violation — a row OUT OF ORDER is.
for (let i = 1; i < EXPECTED.length; i++) {
if (at[i] !== -1 && at[i - 1] !== -1 && at[i] < at[i - 1]) {
fails.push(`"${EXPECTED[i]}" (row ${at[i] + 1}) comes before "${EXPECTED[i - 1]}" `
+ `(row ${at[i - 1] + 1}) — the Driver's order is reversed here`)
}
}
if (fails.length) {
console.error(`SPA-TAB-ORDER: FAIL — ${fails.length} problem(s) with the Driver's rail order.\n`)
for (const f of fails) console.error(' - ' + f)
console.error('\n Rendered rail:')
rows.forEach((r, i) => console.error(` ${i + 1}. ${r.slice(0, 52)}`))
console.error('\n Tab 1 is the LANDING SURFACE — the first thing anyone opening the product SPA')
console.error(' sees, and the opening step of the onboarding ramp. A reordered rail produces no')
console.error(' error and a SPA that still works; it just opens somewhere else.')
@ -103,7 +139,13 @@ if (mismatches.length) {
}
console.log(`SPA-TAB-ORDER: PASS — the rail opens in the specified order (${rows.length} rows total).`)
EXPECTED.forEach((w, i) => console.log(` ${i + 1}. ${(actual[i] || '').slice(0, 56)}`))
// Print the WHOLE rendered rail and mark the Driver's four, so a reader can see the rows that
// sit between them rather than only the names being asserted. A gate that prints just its own
// four would hide the very structure that made the old positional assertion wrong.
rows.forEach((r, i) => {
const k = at.indexOf(i)
console.log(` ${String(i + 1).padStart(2)}. ${k >= 0 ? '★' : ' '} ${r.slice(0, 54)}`)
})
console.log(' Asserted on the RENDERED rail rather than on NAV_MODEL: the array is edited for')
console.log(' reasons unrelated to ordering (badges, sections, narrow-screen flags), and a source')
console.log(' parse can pass while a rendering bug shows something else.')

View file

@ -145,7 +145,7 @@
"check:synced-css": "node internal/check-synced-css-copies.mjs",
"check:composite-reg": "node internal/check-composite-registration.mjs",
"check:filter-width": "node internal/check-filter-width-stable.mjs",
"check:spa-tab-order": "node internal/check-spa-tab-order.mjs",
"check:spa-tab-order": "node internal/check-spa-tab-order.mjs # the cap-5 SPA must open on Big Picture and keep the Driver's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.",
"check:backlog-status": "node internal/check-backlog-status-enum.mjs",
"check:mjs-parse": "node internal/check-mjs-parses.mjs",
"check:snapshot-guard": "node internal/check-snapshot-guard.mjs # every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.",