UPG-100 batch HUD: on a phone, the block notice and the print's progress are visible

.upg-status is display:none below 768px (the legend sheet owns the bottom edge),
so the phone ceiling's "This needs a desktop or laptop computer." and the print's
progress ("fetching people groups…", "printing 905 people groups…") were written
only where a phone shows nothing. A reader tapped Prayer List and nothing visible
happened.

hudSay writes a batch action's message to the batch HUD's line (.upg-hud-flash,
now a polite live region, visible at every width) as well as to the status
badge. The block notice stays 6 s and failures 5 s. The print's progress is
mirrored into the same line whenever no flash is up, and is cleared when the
print ends.

check:batch-selection-export arm C checks that the notice, every batch action's
failure text and the progress reach the HUD line, that no batch action writes
via flashStatus alone, and that no rule hides .upg-hud-flash. Four mutants each
fail, including "notice written only to .upg-status".
This commit is contained in:
Doxa Maps 2026-10-01 12:48:39 +00:00
parent 7853689771
commit c78a829810
2 changed files with 33 additions and 10 deletions

View file

@ -256,9 +256,23 @@ const wired = {
const body = (name) => { const at = vue.indexOf(`async function ${name}() {`); return at < 0 ? '' : vue.slice(at, vue.indexOf('\n}\n', at)) }
const before = (name, call) => { const b = body(name); const g = b.indexOf('phoneTooBig('), p = b.indexOf(call); return g > 0 && p > g }
return before('makeSelectionPacket', 'packetPrints.printPacket(') && before('makeSelectionCards', 'packetPrints.printCardDeck(')
&& before('makeSelectionPoster', 'packetPrints.printPoster(') && /flashStatus\(t\('poster\.phoneLimitAll'\)/.test(vue)
&& before('makeSelectionPoster', 'packetPrints.printPoster(') && /hudSay\(t\('poster\.phoneLimitAll'\), 6000\)/.test(vue)
&& /const ON_PHONE = isPhonePrint\(\);/.test(vue)
})(),
// ★ ON A PHONE .upg-status IS display:none, so a batch message written only there is INVISIBLE (a reader tapped
// Prayer List and nothing visible happened). Every batch action's message, and the print's progress, reach the
// HUD's line (.upg-hud-flash, a polite live region that no rule hides) as well as the status badge.
'batch messages and progress reach the visible HUD on a phone': (() => {
const body = (name) => { const at = vue.indexOf(`async function ${name}() {`); return at < 0 ? '' : vue.slice(at, vue.indexOf('\n}\n', at)) }
const actions = ['makeSelectionPacket', 'makeSelectionCards', 'makeSelectionPoster'].map(body)
const tooBig = vue.slice(vue.indexOf('function phoneTooBig('), vue.indexOf('function phoneTooBig(') + 300)
return /const hudSay = \(text, ms\) => \{ flashStatus\(text, ms\); flashRef\(copyFlash, text, ms\); \};/.test(vue)
&& /hudSay\(t\('poster\.phoneLimitAll'\)/.test(tooBig) && !/flashStatus\(/.test(tooBig)
&& actions.every((b) => b && !/flashStatus\(/.test(b))
&& /class="upg-hud-flash" role="status" aria-live="polite">\{\{ copyFlash \|\| hudProgress \}\}/.test(vue)
&& /hudProgress\.value = printStatus;/.test(vue)
&& !/\.upg-hud-flash\{[^}]*display:\s*none/.test(vue) && !/@media[^{]*\{[^}]*\.upg-hud-flash\{[^}]*display:\s*none/.test(vue)
})(),
'the regroup warning on Copy': /const warn = regroupable\(sel\) \? '' : t\('upgMap\.copyNotRegroupable'\)/.test(vue),
// The packet / card-deck title goes through selectionTitleNames (arm A6 proves it), with the keyed empty case.
'the title names come from selectionTitleNames': /const names = selectionTitleNames\(zoneNames, selectedTerritories\.value, from,\s*\n\s*\(\) => keyedError\('wall\.hud\.noRecords'/.test(vue)

View file

@ -1528,14 +1528,15 @@ const packetPrints = usePosterPrints({
// watcher wrote is remembered and cleared exactly — the same rule flashRef follows above.
let printStatus = ''
watch(packetPrints.exportState, (st) => {
if (!st) { if (printStatus && status.value === printStatus) status.value = ''; printStatus = ''; return; }
if (!st) { if (printStatus && status.value === printStatus) status.value = ''; printStatus = ''; hudProgress.value = ''; return; }
if (st.phase === 'error') {
printStatus = '';
flashStatus(t('messages.packetFailed', { reason: st.message }), 4000);
printStatus = ''; hudProgress.value = '';
hudSay(t('messages.packetFailed', { reason: st.message }), 5000);
return;
}
printStatus = st.phase === 'done' ? '' : `${st.message}…`;
status.value = printStatus;
hudProgress.value = printStatus;
});
// BUNDLE-SPECIFIC: who is in this list. ⚠ pg_slugs BYPASSES the shared evaluator —
@ -1576,6 +1577,14 @@ function flashRef(target, text, ms) {
setTimeout(() => { if (target.value === text) target.value = ''; }, ms);
}
const flashStatus = (text, ms) => flashRef(status, text, ms);
// ★ A BATCH ACTION SPEAKS WHERE A PHONE CAN SEE IT. .upg-status is display:none below 768px (the legend sheet owns
// the bottom edge there), so a message written only to it was invisible on a phone: a reader tapped Prayer List
// and nothing visible happened. hudSay writes the same text to the batch HUD's line (.upg-hud-flash, a polite
// live region, visible at every width) as well as to the status badge.
const hudSay = (text, ms) => { flashStatus(text, ms); flashRef(copyFlash, text, ms); };
// The print's progress ("fetching people groups…", "printing 905 people groups…"), mirrored into the HUD for the
// same reason; the HUD shows it whenever no flash is up.
const hudProgress = ref('');
// `variant` selects WHICH packet — 'full' (map, life calendar, %-evangelical grid, triangle,
// profiles, sets, points, wheel) or 'lightweight' (map, triangle, profiles, points, wheel).
@ -2004,7 +2013,7 @@ async function exportSelectionCsv() {
const ON_PHONE = isPhonePrint();
function phoneTooBig(count) {
if (!overPhoneCeiling(count, ON_PHONE)) return false;
flashStatus(t('poster.phoneLimitAll'), 6000);
hudSay(t('poster.phoneLimitAll'), 6000);
return true;
}
@ -2031,7 +2040,7 @@ async function makeSelectionPacket() {
funnel: funnel && lists.length === 1 ? funnel : (funnel ? { ...funnel, listCount: 0 } : null),
});
} catch (e) {
flashStatus(t('messages.selectionPacketFailed', { reason: errorText(e, t).slice(0, 90) }), 4000);
hudSay(t('messages.selectionPacketFailed', { reason: errorText(e, t).slice(0, 90) }), 5000);
} finally { batchBusy.value = false; busyAction.value = ''; }
}
@ -2046,7 +2055,7 @@ async function makeSelectionCards() {
if (phoneTooBig(sel.count)) return;
await packetPrints.printCardDeck(sel);
} catch (e) {
flashStatus(t('upgMap.selectionCardsFailed', { reason: errorText(e, t).slice(0, 90) }), 4000);
hudSay(t('upgMap.selectionCardsFailed', { reason: errorText(e, t).slice(0, 90) }), 5000);
} finally {
batchBusy.value = false;
busyAction.value = '';
@ -2074,13 +2083,13 @@ async function makeSelectionPoster() {
// is exactly the wall's poster; with several, say so plainly instead of silently printing
// the first zone's poster as though it covered the selection.
if (lists.length > 1) {
flashStatus(t('messages.posterOneTerritory', { count: lists.length }), 5000);
hudSay(t('messages.posterOneTerritory', { count: lists.length }), 5000);
return;
}
if (phoneTooBig(lists[0].count)) return;
await packetPrints.printPoster(lists[0]);
} catch (e) {
flashStatus(t('messages.selectionPosterFailed', { reason: errorText(e, t).slice(0, 90) }), 4000);
hudSay(t('messages.selectionPosterFailed', { reason: errorText(e, t).slice(0, 90) }), 5000);
} finally { batchBusy.value = false; busyAction.value = ''; }
}
@ -3410,7 +3419,7 @@ onBeforeUnmount(() => {
@click="exportSelectionCsv">{{ busyAction === 'csv' ? t('buttons.working') : t('upgMap.exportCsv') }}</button>
<button class="upg-hud-btn clear" @click="clearSelection">{{ t('buttons.clearSelection') }}</button>
</div>
<div v-if="selCount" class="upg-hud-flash">{{ copyFlash }}</div>
<div v-if="selCount" class="upg-hud-flash" role="status" aria-live="polite">{{ copyFlash || hudProgress }}</div>
</div>
<div class="upg-status">{{ status }}</div>
</div>