Phone print: keep the in-page document until the reader is back, so a saved PDF is the preview

On a phone, printDocument mounts the document into the page (#dxm-print-root and
a print-only style) and calls window.print(). It dropped both at afterprint. But
Android Chrome's print() does not block: afterprint fires once the preview is
built, and the file "Save as PDF" writes is rendered later, after the reader
taps Save, from the page as it is then. The preview showed a packet's 11 pages
and the saved PDF was the bare host page.

The document now stays until the reader is back on the page: shown again after
being hidden, focused again after losing focus, or touched after print(), then
BACK_GRACE_MS more for a job still rendering. A new print replaces it, a
cancelled one drops it, and the ten-minute ceiling stays. Listening starts at
print(), so the Print tap of a late print does not count. printImageBlob's
object URL now follows the document (onRelease) instead of afterprint. On
screen the document is display:none, as before. Desktop prints through the
frame exactly as before.

check:phone-print-keep runs the phone timeline on fake timers and a stub DOM
with an Android user agent; the previous file fails it (gone after afterprint,
image revoked). Its self-test poisons the old drop at afterprint, no release
when back, listeners armed at mount, no grace, printImageBlob revoking at
afterprint, and no ceiling.
This commit is contained in:
Doxa Maps 2026-10-02 17:10:09 +00:00
parent 2e0800c5ad
commit 0b1fcde1db
3 changed files with 224 additions and 18 deletions

View file

@ -0,0 +1,176 @@
#!/usr/bin/env node
/**
* check-phone-print-keep.mjs — on a phone, the in-page print document outlives afterprint, so a saved PDF is the preview.
*
* THE DEFECT (2026-10-02, on a phone): UPG 100 → Print prayer packet. The print preview showed all 11 pages; "Save as
* PDF" saved the WEBSITE (one letter page with the host page's title). On a phone printDocument mounts the document into
* the page (#dxm-print-root plus a print-only style) and calls window.print(). It dropped both at afterprint, but
* Android Chrome's print() does not block, afterprint fires once the PREVIEW is built, and the saved file is rendered
* later, after the reader taps Save, from the page as it is then.
*
* A timeline on fake timers and a stub DOM, with an Android user agent (Playwright cannot reproduce Android's print
* pipeline; the Driver's phone is the real check):
* A. after print() and afterprint, the document and its print style are still mounted 30 s on (the Save render);
* B. it goes once the reader is BACK: the page shown again after being hidden, the window focused again after
* losing focus, or a touch after print(), then BACK_GRACE_MS later, not before;
* C. a late print (needs-tap): the Print tap itself does not count as being back, so the document is still
* mounted through the dialog;
* D. a reader who never comes back: dropped at FRAME_CEILING_MS, not before;
* E. printImageBlob: its object URL is NOT revoked at afterprint, and is revoked when the document goes;
* F. a new print replaces the previous document (one root mounted, and the previous one released);
* G. the desktop path is the frame, as before (printDocument's first line, verbatim).
* `--impl=<path>` runs the timeline against another copy (the pilot's, before this fix, fails A and E: the cause).
* `--self-test`: the old drop at afterprint, no release when the reader is back, the listeners armed at mount (so the
* Print tap drops it), no grace, printImageBlob revoking at afterprint, and no ceiling must each FAIL.
*/
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const implArg = process.argv.find((a) => a.startsWith('--impl='))
const SRC = implArg ? implArg.slice(7) : join(ROOT, 'library/composables/printInPlace.js')
const ROOT_ID = 'dxm-print-root'
// ── fake timers ─────────────────────────────────────────────────────────────────────────────────
let now = 0, seq = 0
const timers = new Map()
globalThis.setTimeout = (fn, ms = 0) => { const id = ++seq; timers.set(id, { at: now + Math.max(0, ms), fn }); return id }
globalThis.clearTimeout = (id) => { timers.delete(id) }
function advance(ms) {
const t = now + ms
for (;;) {
let next = null
for (const [id, x] of timers) if (x.at <= t && (!next || x.at < next[1].at || (x.at === next[1].at && id < next[0]))) next = [id, x]
if (!next) break
timers.delete(next[0]); now = next[1].at; next[1].fn()
}
now = t
}
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve() }
// ── a stub DOM: just what mountPrintRoot touches ────────────────────────────────────────────────────
function events() {
const L = new Map()
return {
addEventListener(t, f) { if (!L.has(t)) L.set(t, []); L.get(t).push(f) },
removeEventListener(t, f) { const a = L.get(t); if (a && a.indexOf(f) >= 0) a.splice(a.indexOf(f), 1) },
fire(t) { for (const f of [...(L.get(t) || [])]) f({ type: t }) },
}
}
function node(tag) {
const n = {
tag, id: '', attrs: {}, children: [], parent: null, textContent: '', style: {}, ...events(),
setAttribute(k, v) { this.attrs[k] = String(v) }, getAttribute(k) { return k in this.attrs ? this.attrs[k] : null },
appendChild(c) { c.parent = this; this.children.push(c); return c },
append(...cs) { for (const c of cs) this.appendChild(c) },
remove() { if (this.parent) { this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null } },
querySelectorAll() { return [] },
}
return n
}
let revoked = []
function world({ android = true, active = true } = {}) {
now = 0; seq = 0; timers.clear(); revoked = []
const body = node('body'), head = node('head')
const doc = {
body, head, visibilityState: 'visible', ...events(),
createElement: (t) => node(t), importNode: (n) => n,
getElementById: (id) => [...body.children, ...head.children].find((n) => n.id === id) || null,
querySelectorAll: () => [...body.children.filter((n) => n.id === ROOT_ID), ...head.children.filter((n) => 'data-dxm-print' in n.attrs)],
}
globalThis.document = doc
globalThis.window = { ...events(), prints: [], print() { this.prints.push(mounted()) } }
globalThis.DOMParser = class { parseFromString() { return { querySelectorAll: (s) => (s === 'style' ? [{ textContent: '@page{size:letter portrait;margin:0}' }] : []), documentElement: { getAttribute: () => null }, body: { childNodes: [node('div')] } } } }
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: {
userAgent: android ? 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Mobile Safari/537.36' : 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36',
userActivation: { isActive: active }, maxTouchPoints: android ? 5 : 0 } })
globalThis.URL.createObjectURL = () => 'blob:stub/1'
globalThis.URL.revokeObjectURL = (u) => { revoked.push([u, now]) }
}
const mounted = () => Boolean(document.body.children.find((n) => n.id === ROOT_ID)) && Boolean(document.head.children.find((n) => 'data-dxm-print' in n.attrs))
const rootCount = () => document.body.children.filter((n) => n.id === ROOT_ID).length
let bust = 0
const load = (src) => import('data:text/javascript;base64,' + Buffer.from(src + `\n// ${++bust}`).toString('base64'))
async function audit(src) {
const fails = []
const sample = await load(src)
const FRAME_CEILING_MS = sample.FRAME_CEILING_MS || 600000, BACK_GRACE_MS = sample.BACK_GRACE_MS || 0
if (!sample.FRAME_CEILING_MS || !sample.BACK_GRACE_MS) fails.push('G: printInPlace.js does not export FRAME_CEILING_MS and BACK_GRACE_MS (the timeline runs on, with a grace of 0)')
const HTML = '<!doctype html><html><head><style>@page{size:letter portrait}</style></head><body><div>packet</div></body></html>'
// A + B (hidden → visible)
{ world(); const m = await load(src); const how = await m.printDocument(HTML)
if (how !== 'page' || window.prints.length !== 1 || !window.prints[0]) fails.push(`A: a phone print did not print the mounted document (returned ${how}, prints ${JSON.stringify(window.prints)})`)
window.fire('afterprint'); advance(30000)
if (!mounted()) fails.push('A: the print document was gone 30 s after afterprint; a PDF saved then is the bare page')
document.visibilityState = 'hidden'; document.fire('visibilitychange'); advance(20000)
if (!mounted()) fails.push('B: the document went while the page was hidden (the print dialog was still up)')
document.visibilityState = 'visible'; document.fire('visibilitychange'); advance(BACK_GRACE_MS - 1)
if (!mounted()) fails.push('B: the document went at once when the page was shown again (no grace for a job still rendering)')
advance(2)
if (mounted()) fails.push('B: the document stayed after the reader was back on the page (shown again) and the grace')
}
// B (blur → focus) and B (a touch after print)
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); window.fire('blur'); advance(10000)
if (!mounted()) fails.push('B: the document went while the window had lost focus')
window.fire('focus'); advance(BACK_GRACE_MS + 1)
if (mounted()) fails.push('B: the document stayed after the window was focused again (back on the page)') }
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(5000); window.fire('pointerdown'); advance(BACK_GRACE_MS + 1)
if (mounted()) fails.push('B: the document stayed after the page was touched again') }
// C: a late print (the tap's activation expired): the Print tap is not "back"
{ world({ active: false }); const m = await load(src); let printNow = null
const how = await m.printDocument(HTML, { onNeedsTap: (p) => { printNow = p } })
if (how !== 'needs-tap' || !printNow) fails.push(`C: an expired tap did not hand out printNow (returned ${how})`)
else {
window.fire('pointerdown'); printNow(); window.fire('afterprint'); advance(30000)
if (window.prints.length !== 1 || !window.prints[0]) fails.push('C: printNow did not print the mounted document')
if (!mounted()) fails.push('C: the Print tap counted as being back: the document was gone 30 s into the dialog')
} }
// D: never back
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(FRAME_CEILING_MS - 1)
if (!mounted()) fails.push('D: the document went before the ceiling with the reader never back')
advance(2)
if (mounted()) fails.push('D: the document outlived FRAME_CEILING_MS') }
// E: printImageBlob's object URL
{ world(); const m = await load(src); m.printImageBlob({ size: 1 }, { title: 'poster' }); await flush()
window.fire('afterprint'); advance(30000)
if (revoked.length) fails.push(`E: printImageBlob revoked its object URL at ${revoked[0][1]} ms, before the reader was back (a saved PDF has no image)`)
if (!mounted()) fails.push('E: printImageBlob\'s document was gone 30 s after afterprint')
window.fire('pointerdown'); advance(BACK_GRACE_MS + 1)
if (revoked.length !== 1) fails.push(`E: printImageBlob's object URL was revoked ${revoked.length} time(s) after the reader was back (expected once)`) }
// F: a new print replaces the previous one
{ world(); const m = await load(src); let first = 0
await m.printDocument(HTML, { onRelease: () => { first++ } }); window.fire('afterprint'); await m.printDocument(HTML)
if (rootCount() !== 1) fails.push(`F: ${rootCount()} print roots mounted after a second print (expected 1)`)
if (first !== 1) fails.push(`F: the first print's onRelease ran ${first} time(s) when a second print replaced it (expected 1)`) }
// G: desktop unchanged (the frame path)
if (!src.includes("if (!isPhonePrint()) return openPrintFrame(html, { autoprint: true }) ? 'frame' : 'blocked'")) fails.push('G: printDocument\'s desktop path is no longer the frame, verbatim')
return fails
}
const src = readFileSync(SRC, 'utf8')
if (process.argv.includes('--self-test')) {
const MOUNT_END = ' ceiling = setTimeout(drop, FRAME_CEILING_MS)\n currentJob = job\n'
const poisons = [
['the old drop at afterprint', src.replace(MOUNT_END, " window.addEventListener('afterprint', () => setTimeout(drop, 0))\n" + MOUNT_END), /^A: /],
['no release when the reader is back', src.replace('const back = () => { if (leaving) return; leaving = true; clearTimeout(ceiling); ceiling = setTimeout(drop, BACK_GRACE_MS) }', 'const back = () => {}'), /^B: the document stayed/],
['the listeners armed at mount (the Print tap drops it)', src.replace(MOUNT_END, ' ceiling = setTimeout(drop, FRAME_CEILING_MS)\n job.printing()\n currentJob = job\n'), /^C: the Print tap counted/],
['no grace once the reader is back', src.replace('ceiling = setTimeout(drop, BACK_GRACE_MS) }', 'drop() }'), /^B: the document went at once/],
['printImageBlob revoking at afterprint', src.replace(" const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }\n", " const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }\n window.addEventListener('afterprint', () => setTimeout(release, 0))\n"), /^E: printImageBlob revoked/],
['no ceiling', src.replace(MOUNT_END, ' currentJob = job\n'), /^D: the document outlived/],
]
let missed = 0
for (const [name, poisoned, rx] of poisons) {
if (poisoned === src) { console.log(`NOT MEASURED: the poison anchor for "${name}" moved`); process.exitCode = 75; continue }
const f = await audit(poisoned); const hit = f.find((x) => rx.test(x))
console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}${f.length ? ` (failed instead: ${f[0]})` : ''}`)
if (!hit) missed++
}
if (missed) process.exitCode = 1
} else {
const f = await audit(src)
if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:phone-print-keep (${f.length})`); process.exitCode = 1 }
else console.log('PASS check:phone-print-keep — on a phone the print document (and printImageBlob\'s image) outlives afterprint, so a PDF saved after the preview is the document; it goes once the reader is back (shown, focused or touched again) plus a grace, or at the ceiling; the Print tap of a late print does not count; a new print replaces it; desktop prints through the frame as before')
}

View file

@ -74,20 +74,27 @@ export function openPrintFrame(bodyHtml, { autoprint = false } = {}) {
* may ignore, and a cancel that removes the document if the reader walks away. A caller without its own progress UI
* passes none and gets the shared Print bar (showTapToPrint).
*
* ★ ON A PHONE, AFTERPRINT IS NOT THE END OF THE PRINT (2026-10-02). Android Chrome's window.print() does not block,
* and afterprint fires once the PREVIEW is built; the file "Save as PDF" writes is rendered later, after the reader
* taps Save, from the page as it is THEN. The document used to be dropped at afterprint, so the preview showed a
* packet's 11 pages and the saved PDF was the bare host page (one letter page with the page's own title). The
* document is now kept until the reader is BACK on the page (mountPrintRoot says how), and onRelease is called then.
*
* @returns Promise<'frame' | 'page' | 'needs-tap' | 'blocked'>
*/
export async function printDocument(html, { onNeedsTap = null, imageWaitMs = 20000, labels = null } = {}) {
export async function printDocument(html, { onNeedsTap = null, imageWaitMs = 20000, labels = null, onRelease = null } = {}) {
if (!isPhonePrint()) return openPrintFrame(html, { autoprint: true }) ? 'frame' : 'blocked'
const job = mountPrintRoot(html)
const job = mountPrintRoot(html, { onRelease })
if (!job) return 'blocked'
await job.imagesLoaded(imageWaitMs)
const act = typeof navigator !== 'undefined' ? navigator.userActivation : null
if (act && act.isActive === false) {
const printNow = () => { if (job.live()) window.print() }
const printNow = () => { if (job.live()) { job.printing(); window.print() } }
if (typeof onNeedsTap === 'function') onNeedsTap(printNow, job.drop)
else showTapToPrint(printNow, job.drop, labels || {})
return 'needs-tap'
}
job.printing()
window.print()
return 'page'
}
@ -140,7 +147,7 @@ function showTapToPrint(printNow, cancel, labels = {}) {
export const PRINT_ROOT_ID = 'dxm-print-root'
let currentJob = null
function mountPrintRoot(html) {
function mountPrintRoot(html, { onRelease = null } = {}) {
if (typeof document === 'undefined' || !document.body || typeof DOMParser === 'undefined') return null
if (currentJob) currentJob.drop()
// EVERY BUNDLE CARRIES ITS OWN COPY OF THIS FILE, so currentJob only knows this bundle's root: a root (and its
@ -169,11 +176,29 @@ function mountPrintRoot(html) {
document.head.appendChild(style)
document.body.appendChild(root)
let ceiling = null
const onAfter = () => setTimeout(drop, 0)
// ★ KEPT UNTIL THE READER IS BACK, never dropped at afterprint (printDocument says why). From the moment print() is
// called (printing(), not at mount: the Print tap that starts a late print must not count), the reader is back when
// the page is shown again after being hidden, the window is focused again after losing focus, or the page is
// touched (the system print UI covers the page, so a touch there means it has gone). It goes BACK_GRACE_MS after
// that, since a print job can still be rendering while the dialog animates away. A new print replaces it, a
// cancelled one drops it, and FRAME_CEILING_MS is the ceiling. On screen it is display:none, so keeping it shows nothing.
let ceiling = null, armed = false, away = false, leaving = false
const back = () => { if (leaving) return; leaving = true; clearTimeout(ceiling); ceiling = setTimeout(drop, BACK_GRACE_MS) }
const onVisibility = () => { if (document.visibilityState === 'hidden') away = true; else if (away) back() }
const onBlur = () => { away = true }
const onFocus = () => { if (away) back() }
const onTouch = () => back()
const job = {
live: () => currentJob === job,
drop,
printing: () => {
if (armed) return
armed = true
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('blur', onBlur)
window.addEventListener('focus', onFocus)
window.addEventListener('pointerdown', onTouch, true)
},
imagesLoaded: (ms) => {
const pending = [...root.querySelectorAll('img')].filter((img) => !img.complete)
if (!pending.length) return Promise.resolve()
@ -186,12 +211,15 @@ function mountPrintRoot(html) {
}
function drop() {
clearTimeout(ceiling)
window.removeEventListener('afterprint', onAfter)
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('blur', onBlur)
window.removeEventListener('focus', onFocus)
window.removeEventListener('pointerdown', onTouch, true)
root.remove()
style.remove()
if (currentJob === job) currentJob = null
if (onRelease) { const done = onRelease; onRelease = null; try { done() } catch { /* the caller's own clean-up */ } }
}
window.addEventListener('afterprint', onAfter)
ceiling = setTimeout(drop, FRAME_CEILING_MS)
currentJob = job
return job
@ -201,6 +229,8 @@ function mountPrintRoot(html) {
export const FRAME_KEEP_MS = 60000
/** The absolute ceiling for a print frame whose load never ends. */
export const FRAME_CEILING_MS = 600000
/** A phone's in-page print document goes this long after the reader is back on the page (mountPrintRoot). */
export const BACK_GRACE_MS = 5000
/**
* Print a rendered poster IMAGE, at its real paper size, through the same in-place frame.
@ -234,14 +264,11 @@ export function printImageBlob(blob, { widthIn = 8.5, heightIn = 11, title = '',
+ 'html,body{margin:0;padding:0;background:#fff}'
+ 'img{display:block;width:100%;height:100%;object-fit:contain}</style></head>'
+ `<body><img src="${url}" alt="${esc(alt || title)}"></body></html>`
// A PHONE PRINTS THE PAGE (printDocument). The object URL lives until the page has printed, or the ceiling.
// A PHONE PRINTS THE PAGE (printDocument). The object URL lives as long as the in-page document does: until the
// reader is back on the page, a new print or the ceiling, NEVER afterprint (a saved PDF is rendered after it).
if (isPhonePrint()) {
let clock = null
const onAfter = () => setTimeout(release, 0)
const release = () => { clearTimeout(clock); window.removeEventListener('afterprint', onAfter); try { URL.revokeObjectURL(url) } catch { /* gone */ } }
window.addEventListener('afterprint', onAfter)
clock = setTimeout(release, FRAME_CEILING_MS)
printDocument(html, { onNeedsTap, labels }).then((how) => { if (how === 'blocked') release() }, release)
const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }
printDocument(html, { onNeedsTap, labels, onRelease: release }).then((how) => { if (how === 'blocked') release() }, release)
return true
}
const win = openPrintFrame(html, { autoprint: true })

View file

@ -355,8 +355,8 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:script-flags check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header:self-test check:preview-flag-dev-only:self-test check:poster-type-registry:self-test check:host-floor:self-test",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:fork-leaders check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:script-flags check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header:self-test check:preview-flag-dev-only:self-test check:poster-type-registry:self-test check:host-floor:self-test check:phone-print-keep:self-test",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:fork-leaders check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor check:phone-print-keep",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -433,9 +433,12 @@
"check:poster-type-registry": "node internal/check-poster-type-registry.mjs",
"check:poster-type-registry:self-test": "node internal/check-poster-type-registry.mjs --self-test",
"check:host-floor": "node internal/check-host-floor.mjs",
"check:host-floor:self-test": "node internal/check-host-floor.mjs --self-test"
"check:host-floor:self-test": "node internal/check-host-floor.mjs --self-test",
"check:phone-print-keep": "node internal/check-phone-print-keep.mjs",
"check:phone-print-keep:self-test": "node internal/check-phone-print-keep.mjs --self-test"
},
"//scripts": {
"check:phone-print-keep": "on a PHONE the in-page print document (#dxm-print-root and its print style, library/composables/printInPlace.js) and printImageBlob's object URL outlive afterprint, because Android Chrome fires afterprint once the PREVIEW is built and renders a saved PDF later, from the page as it is then (a phone's saved packet was the bare host page). A timeline on fake timers and a stub DOM with an Android user agent: still mounted 30 s after afterprint; released once the reader is back (shown again after hidden, focused again after blur, or touched after print()) plus BACK_GRACE_MS, or at the ceiling; the Print tap of a late print does not count; a new print replaces it; desktop is the frame path, verbatim. --impl=<path> runs it against another copy (the pre-fix file fails). Self-test poisons: the old drop at afterprint, no release when back, listeners armed at mount, no grace, printImageBlob revoking at afterprint, no ceiling.",
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
"browser-gates": "lists every package.json script that launches a browser (the same detection run-parallel uses to route them through internal/browser-slot.sh). Run each as internal/browser-slot.sh bun run <name>.",
"cap:sync": "NOT `bunx` — bunx is not on PATH in this environment (command not found, exit 127), which broke cap:sync -> apk:debug -> phone at step one. The CLI IS installed locally (@capacitor/cli 7.6.9); invoke the local bin directly. The rest of package.json already uses the spaced `bun x` form.",