#!/usr/bin/env node
/**
* check-poster-card-fits — a poster card's print-options row must stay inside its own card.
*
* THE DEFECT THIS CATCHES, reported by the repo-hygiene agent off a fresh 1440px screenshot and
* confirmed here: every poster card's print-options row (Prayer List · Card Deck · stock select ·
* Poster Map) burst its card and overlapped the neighbours. MEASURED 2026-09-24 at 1440px:
* 27 of 27 cards overflowing, worst 110px past a 212px card.
*
* ★ WHY SOURCE READING MISSED IT. `.pfs` already had `width:82%`, so the rule looks correct — a
* constrained box. But flex items default to `min-width:auto` and REFUSE to shrink below their
* intrinsic content width, so four controls pushed the row open regardless of the container's
* width. The fix is flex-wrap plus min-width:0 on the children; the point for this gate is that
* only GEOMETRY shows it.
*
* ⚠ IT ASSERTS TWO THINGS, because either alone is satisfiable by a broken UI:
* 1. No print row intrudes into another card's box — the reported symptom.
* 2. Every control still has a usable box. "Nothing overflows" is trivially true if the row
* collapsed the buttons to zero width, which would be a worse product than the overflow.
*
* Usage: node internal/check-poster-card-fits.mjs [leafUrl] — SKIPs when nothing is served.
*/
import { chromium } from './lib/playwright-guarded.mjs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { servesThisTree } from './_serves-this-tree.mjs'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const DEFAULT_LEAF = (process.env.BASE_URL || 'http://127.0.0.1:4173')
+ `/${TREE_NAME}/my-upg-100-list/upg-100-mfe/index.html`
const LEAF = process.argv[2] || DEFAULT_LEAF
// ⚠ SWEEP WIDTHS. The report named 1440px, so this first guarded 1440px — but the defect is a
// FLEX-SHRINK failure, and those are width-dependent: a narrower viewport gives the controls less
// room, which is where the next regression appears. Guarding one width encodes the reporter's
// sampling choice, not the property.
const WIDTHS = (process.env.CARD_FIT_WIDTH || '390,768,1024,1440,1920')
.split(',').map((n) => Number(n.trim())).filter(Boolean)
// ★ AND SWEEP LOCALES, for exactly the same reason the widths are swept. The requirement was full
// words visible AND localization supported; this gate only ever ran in English, which is
// the shortest of the eleven. MEASURED 2026-09-24 at 1440px, the three print labels:
// en Prayer List 76px · Card Deck 72px · Poster Map 79px
// ro Listă de rugăciune 122px · Pachet de cartonașe 133px · Harta afișului 92px (+75%)
// zh 禱告名單 56px · 禱告卡組 56px · 海报地图 56px
// Romanian is the widest RENDERED, which is the property that matters — not the longest string.
// Chinese is only 4 characters yet each glyph is near double-width, so a character-count heuristic
// would have picked the wrong worst case. The gate asserts scrollWidth > clientWidth, which is
// pixels, so the sweep has to be pixels too.
// en is always included as the control; ro and zh are the measured extremes. Override with
// CARD_FIT_LANG to check a specific locale.
const LANGS = (process.env.CARD_FIT_LANG || 'en,ro,zh')
.split(',').map((s) => s.trim()).filter(Boolean)
let reachable = false
try { const r = await fetch(LEAF, { signal: AbortSignal.timeout(4000) }); reachable = r.ok } catch { /* precondition */ }
if (!reachable) {
console.log(`POSTER-CARD-FITS: SKIP — nothing served at ${LEAF}.`)
console.log(' Serve the built tree: python3 -m http.server 4173 --directory public (or set BASE_URL)')
process.exit(0)
}
// ★ "ANSWERS 200" AND "IS SERVING THIS BUILD" ARE DIFFERENT QUESTIONS, and only the first was
// asked above. This gate reports on geometry it cannot attribute: point it at a stale server and
// it measures the OLD build and says PASS, with nothing in the output naming which tree it saw.
//
// THAT IS NOT HYPOTHETICAL — I DID IT FOR SEVENTEEN TICKS. The published site was many merges
// behind while I ran this gate against it and recorded the results as live verification. They were
// measurements of a tree that was not mine. The defect that finally surfaced (country-posters
// previews 404ing) was only visible once the publish made live and local agree; before that, this
// gate would have passed on the stale copy either way.
//
// bundler-2 found this class first (BUG-BROWSER-GATES-CAN-MEASURE-ANOTHER-WORKTREES-TREE: :4173 on
// this box was held by a different worktree) and wrote _serves-this-tree.mjs. Its comment is the
// rule: "A gate that can report on the wrong tree is worse than no gate — it produces confident
// findings about code nobody changed, and hides real ones."
//
// ⚠ NOT MEASURED, NOT SKIP, and the distinction is the point. A SKIP reads as "nothing to check
// here"; this is "I could not attribute what I saw", which is a different and louder fact. Exit 2
// so a caller can tell it apart from both a pass (0) and a real failure (1).
// The helper wants the tree's BASE, not a leaf URL — it fetches //manifest.json.
// LEAF is `////index.html`, so cut at that segment. The name is
// a config knob (_tree-name.mjs), and a hardcoded 'doxa-maps' here is how a rename silently makes
// a gate measure a folder that no longer exists — which cost me a whole tick of false readings.
const TREE_BASE = LEAF.split(`/${TREE_NAME}/`)[0]
const treeCheck = await servesThisTree(TREE_BASE, ROOT)
if (!treeCheck.ok) {
console.error('POSTER-CARD-FITS: NOT MEASURED — the server is not serving this checkout.')
console.error(` ${treeCheck.why}`)
console.error(' Every number below would describe someone else\'s build. Point BASE_URL at a')
console.error(' server for THIS tree, or publish first if you meant to measure live.')
process.exit(2)
}
const WALK = `(root, out) => { out = out || []; const rec = (r) => { for (const e of r.querySelectorAll('*')) { out.push(e); if (e.shadowRoot) rec(e.shadowRoot) } }; rec(root); return out }`
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
async function measure(width, lang) {
const mobile = width < 500
const page = await browser.newPage({ viewport: { width, height: 1000 }, isMobile: mobile, hasTouch: mobile })
// ?lang= is how the app selects a locale (library/i18n/index.js:83). Appended rather than
// replaced so a caller can still pass a leaf URL that already carries query params.
const url = lang && lang !== 'en'
? LEAF + (LEAF.includes('?') ? '&' : '?') + 'lang=' + encodeURIComponent(lang)
: LEAF
await page.goto(url, { waitUntil: 'load', timeout: 45000 })
await page.waitForTimeout(10000)
// ★ HOVER IS INVISIBLE TO A STATIC PROBE, which is exactly how the blue outline stayed
// unbuilt for so long. A blue outline around the hovered poster was reported missing more than once;
// measuring the loaded page found `outline:none` on all 136 framed elements and concluded it had
// never existed — true, and useless, because the affordance in question only exists WHILE
// THE POINTER IS OVER THE CARD. The pointer has to actually move.
//
// ⚠ AND IT MUST NOT MOVE ANYTHING. The whole rest of this gate asserts where the print row sits.
// A hover that grew a border would re-lay-out the card and push that row, so a cosmetic state
// could break a gate about print controls. Outline paints outside the box; this asserts the box
// WIDTH is identical hovered and not, which is the property that keeps the two independent.
let hover = null
try {
const target = await page.evaluate(`(() => {
const all = (${WALK})(document)
const c = all.filter((e) => (e.className||'').toString().split(' ').includes('card'))
.filter((e) => { const b = e.getBoundingClientRect(); return b.width > 2 && b.height > 2 })[0]
if (!c) return null
c.scrollIntoView({ block: 'center' })
const r = c.getBoundingClientRect()
const cs = getComputedStyle(c)
// ⚠ MEASURE clientWidth, NOT THE BORDER BOX. The card is a GRID ITEM, so its outer width is
// fixed by the track (minmax(210px,1fr)) and CANNOT change no matter what the hover rule
// does — a border grown on hover eats INWARD and leaves getBoundingClientRect().width
// identical. Asserting on the outer width is therefore unfalsifiable here, which is a
// decorative assertion, not a real one. MEASURED: growing border-width to 4px on hover left
// the outer width at 212px and moved nothing this gate was watching. clientWidth is the
// CONTENT box — it shrinks when the border grows, and it is what actually repositions the
// print row, which is the property worth protecting.
return { x: r.x + r.width / 2, y: r.y + r.height / 2, w: Math.round(r.width),
inner: c.clientWidth, outlineStyle: cs.outlineStyle }
})()`)
if (target) {
await page.mouse.move(target.x, target.y)
await page.waitForTimeout(500)
const hovered = await page.evaluate(`(() => {
const all = (${WALK})(document)
const c = all.filter((e) => (e.className||'').toString().split(' ').includes('card'))
.filter((e) => { const b = e.getBoundingClientRect(); return b.width > 2 && b.height > 2 })[0]
if (!c) return null
const cs = getComputedStyle(c)
return { outlineStyle: cs.outlineStyle, outlineColor: cs.outlineColor,
outlineWidth: cs.outlineWidth, w: Math.round(c.getBoundingClientRect().width),
inner: c.clientWidth }
})()`)
if (hovered) hover = { before: target, after: hovered }
}
} catch (_) { /* hover is an enhancement; never let it break the geometry run */ }
// ★ CAN A READER ACTUALLY REACH THE LAST POSTER? Everything else here measures cards that are
// ALREADY laid out — which is satisfied perfectly by a wall whose bottom half is unreachable.
// The Kingdom-agent ruled UPG-100 the one mobile-app showcase, promising an "Instagram-style
// scroll", and nothing asserted it.
//
// ⚠ DO NOT ASK document.documentElement.scrollHeight. MEASURED at 390x844 on upg-100-mfe: it
// reports 844 — exactly one viewport — while the wall holds 13,032px of cards. The composite
// scrolls an INNER element, so the document is not the thing that moves and a document-level
// check would call a healthy wall empty. Find the element that actually overflows.
//
// It SCROLLS rather than computing: geometry saying "the last card is at 13001px" is not the
// same claim as "scrolling brings it into view with working controls".
let reach = null
try {
reach = await page.evaluate(`(async () => {
const all = (${WALK})(document)
const vis = (e) => { const b = e.getBoundingClientRect(); return b.width > 2 && b.height > 2 }
const cards = all.filter((e) => (e.className||'').toString().split(' ').includes('card')).filter(vis)
if (!cards.length) return null
const last = cards[cards.length - 1]
// The scroller is whichever ancestor-ish element genuinely overflows. Picking the LARGEST
// overflow avoids matching a small inner scroller (the legend's own list, say).
const scrollers = all.filter((e) => {
const cs = getComputedStyle(e)
return /(auto|scroll)/.test(cs.overflowY) && e.scrollHeight > e.clientHeight + 8
}).sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))
const sc = scrollers[0]
if (sc) { sc.scrollTop = sc.scrollHeight } else { window.scrollTo(0, document.body.scrollHeight) }
await new Promise((r) => setTimeout(r, 1000))
const lb = last.getBoundingClientRect()
const ctrls = [...last.querySelectorAll('.pbtn,.pstock')]
return {
scrolled: sc ? Math.round(sc.scrollTop) : Math.round(window.scrollY),
maxScroll: sc ? sc.scrollHeight - sc.clientHeight : 0,
lastInView: lb.top < window.innerHeight && lb.bottom > 0,
lastControls: ctrls.length,
lastClipped: ctrls.filter((e) => e.scrollWidth > e.clientWidth + 1).length,
lastUnusable: ctrls.filter((e) => {
const r = e.getBoundingClientRect(); return r.width < 18 || r.height < 10
}).length,
}
})()`)
} catch (_) { /* reachability is additive; never let it break the geometry run */ }
const out = await page.evaluate(`(() => {
const all = (${WALK})(document)
const vis = (e) => { const b = e.getBoundingClientRect(); return b.width > 2 && b.height > 2 }
const cards = all.filter((e) => (e.className||'').toString().split(' ').includes('card')).filter(vis)
const boxes = cards.map((c) => ({ c, b: c.getBoundingClientRect() }))
let intrusions = 0, overflowing = 0, worst = 0, coveringPreview = 0, worstY = 0
let previewsPainted = 0, previewsBroken = 0, noPreview = 0, unnamedPreview = 0
let notTaped = 0, worstTape = 0, selectsInRow = 0
let headNotTaped = 0, worstHead = 0, mapCovered = 0, worstCover = 0
let uncontained = 0, worstBelow = 0
for (const { c, b } of boxes) {
const row = c.querySelector('.pfs')
if (!row) continue
const rb = row.getBoundingClientRect()
if (rb.right > b.right + 1 || rb.left < b.left - 1) {
overflowing++
worst = Math.max(worst, Math.round(Math.max(rb.right - b.right, b.left - rb.left)))
}
// ★ AND THE CARD CONTAINS ITS ROW (2026-09-30, from a phone at two posters per row). The row
// wrapped to three lines and hung about 45px below the card's white box, into the row gap:
// horizontally inside, so the check above stayed green. PosterWall now fits each card to its
// own row (--pfs-room); this is the assertion that keeps that true in every locale x width.
if (rb.bottom > b.bottom + 0.5) {
uncontained++
worstBelow = Math.max(worstBelow, Math.round(rb.bottom - b.bottom))
}
// ⚠ THE VERTICAL AXIS TOO — this measured only left/right, which is exactly how it passed a
// visible regression. Fixing the horizontal overflow with flex-wrap grew the row
// 35px -> 78px, and because it sits at bottom:-14px the extra height pushed its TOP from
// 216px up to 173px in a 238px card: over the preview image and the card's outline.
// Horizontal clean, card visibly broken, gate green.
// COUNT THE PREVIEW, do not merely guard on it. An if-guard protects this loop from a
// placeholder card — and also means a card with NO preview passes the over-preview assertion
// perfectly. BOTH a missing-previews symptom AND the overlap were reported;
// measuring only the overlap leaves the first unasserted.
// NOTE: no backticks in this block — it lives inside a JS template literal.
const img = c.querySelector('img')
// ONLY JUDGE WHAT IS IN VIEW. Every preview carries loading=lazy, so a card below the fold
// legitimately has naturalWidth 0 — measured at 390px: 8 of 27 painted, the first unpainted
// one 3926px down an 844px viewport, and ZERO failing HTTP responses. A first version of this
// assertion called that 18 broken images. Deferred is not broken; only an in-viewport image
// that never painted is.
// ★ THE ALT IS THE CARD'S ONLY NAME. MEASURED 2026-09-25: a card's TEXT reads
// "116 UPG·Show on map" — a count and an action, no zone. The identity lives in the image's
// alt ("Northwest India — prayer poster…"), which all 27 carry. So the wall is correct as
// built, and that alt is doing load-bearing work NOTHING asserted: remove it and a
// screen-reader user meets 27 anonymous pictures while every check here stays green.
if (img && !(img.getAttribute('alt') || '').trim()) unnamedPreview++
if (!img) noPreview++
else {
const ib2 = img.getBoundingClientRect()
const inView = ib2.top < window.innerHeight && ib2.bottom > 0
if (img.complete && img.naturalWidth > 0) previewsPainted++
else if (inView) previewsBroken++
}
if (img) {
const ib = img.getBoundingClientRect()
if (ib.height > 2 && rb.top < ib.bottom - 1) {
coveringPreview++
worstY = Math.max(worstY, Math.round(ib.bottom - rb.top))
}
}
// ★ A STICKER STRADDLES THE EDGE IT TAPES (2026-09-25, the sticker/banner brief).
// The card is a BANNER displaying a map; each sticker sits centred on a banner edge, part
// on the banner's own white space and part overhanging. Three things were each true while
// this gate was green, so all three are now asserted:
// 1. the row floated 26px BELOW the banner, touching nothing (the shipped state);
// 2. the row sat entirely below the edge, touching at one pixel;
// 3. the row straddled correctly but landed ON THE MAP — "offensive to the map".
// over-preview above catches only a row overlapping the from above.
const frame = c.querySelector('.main')
if (frame) {
const fb = frame.getBoundingClientRect()
// STRADDLE: some of the sticker on the banner, some off it. Requiring a near-even split
// would encode a ratio the brief explicitly left open (probably an equal or larger
// overlap); requiring BOTH sides to be non-trivial is the property actually stated.
const onBanner = Math.round(fb.bottom - rb.top)
const offBanner = Math.round(rb.bottom - fb.bottom)
if (onBanner < 4 || offBanner < 4) {
notTaped++
const d = onBanner < 4 ? onBanner : -offBanner
worstTape = Math.abs(d) > Math.abs(worstTape) ? d : worstTape
}
}
// ★ NO NATIVE DROPDOWN IN THE DEFAULT ROW (same ruling). The reason is not cosmetic: a
// dropdown on the card makes it hard for others to have dropdowns too — a