diff --git a/internal/check-phone-print-keep.mjs b/internal/check-phone-print-keep.mjs new file mode 100644 index 000000000..8d9a9db86 --- /dev/null +++ b/internal/check-phone-print-keep.mjs @@ -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=` 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 = '
packet
' + + // 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') +} diff --git a/library/composables/printInPlace.js b/library/composables/printInPlace.js index 5372bf2ca..009c4b5bb 100644 --- a/library/composables/printInPlace.js +++ b/library/composables/printInPlace.js @@ -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}' + `${esc(alt || title)}` - // 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 }) diff --git a/package.json b/package.json index f32c5e0ec..8c981879e 100644 --- a/package.json +++ b/package.json @@ -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= 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 .", "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.",