// Source: ZenidWeb/wwwroot/WebSDKDemo/src/platforms/web/feedback-announcer.ts - edit only there. // The ZenidWeb build compiles it and copies the JS into the iOS and Android SDK WebView bundles // next to web-visualizer.js. interface Message { instruction: string; step: string; immediate: boolean; assertive: boolean } const noMessage: Message = { instruction: "", step: "", immediate: false, assertive: false }; const sameMessage = (a: Message, b: Message): boolean => a.instruction === b.instruction && a.step === b.step; // The same text becoming the outcome of the flow has to be said again, through the assertive // region, so urgency is part of the announcement identity. const sameAnnouncement = (a: Message, b: Message): boolean => sameMessage(a, b) && a.assertive === b.assertive; /** * The accessible feedback channel of the capture visualizer: a visible instruction plate * (aria-hidden) plus visually hidden live regions for the screen reader. * * A message must stay unchanged for settleMs before it counts. The plate then shows it right away, * while the spoken channel changes no faster than the screen reader can speak the previous * message; intermediate messages are dropped rather than queued. */ export class FeedbackAnnouncer { private static readonly settleMs = 250; private static readonly minIntervalMs = 1000; private static readonly maxIntervalMs = 4000; // 350 ms per word is about 170 words per minute, the default rate of VoiceOver, TalkBack and // Narrator. private static readonly msPerWord = 350; // The DOM nodes outlive the announcer that created them (see reset), so a later instance can // adopt them - the previous one must then stop writing. private static readonly owners = new WeakMap(); private container: HTMLElement | null = null; private visibleTextEl: HTMLDivElement | null = null; private politeRegion: HTMLDivElement | null = null; private alertRegion: HTMLDivElement | null = null; private pending: Message = noMessage; // latest raw message, waiting out the settle window private settled: Message = noMessage; // latest settled message: what the plate shows private spoken: Message = noMessage; // latest message written to a live region private settleTimer: ReturnType | null = null; private voiceTimer: ReturnType | null = null; private spokenStep: string = ""; private spokenText: string = ""; private plateVisible: boolean = true; private lastSpokenAt: number = Number.NEGATIVE_INFINITY; constructor(containerElement: HTMLElement) { this.container = containerElement; this.createOrReuseNodes(containerElement); FeedbackAnnouncer.owners.set(containerElement, this); } /** Call on every processed frame. immediate skips the settle window and the pacing gate, * assertive marks the outcome of the flow, which also interrupts the screen reader. */ public setMessage(instruction: string, step: string, immediate: boolean = false, assertive: boolean = false): void { if (!this.isOwner()) return; const message = { instruction, step, immediate, assertive }; // Before the checks below, because the same text may still be settling as a mid-capture // instruction. if (immediate && !sameAnnouncement(message, this.settled)) { this.stopTimer(this.settleTimer); this.settleTimer = null; this.pending = message; this.settled = message; this.writePlate(); this.scheduleVoice(); return; } // Already the settled message: drop any transient candidate. if (sameMessage(message, this.settled)) { this.stopTimer(this.settleTimer); this.settleTimer = null; this.pending = message; this.settled = message; // Spoken text unchanged, so a frame following the outcome must not have it announced // a second time as guidance. if (sameMessage(message, this.spoken)) this.spoken = message; // The settled message may still be unspoken, for instance after a one-frame detour // cancelled its pending announcement. this.scheduleVoice(); return; } // Already settling - don't restart the window, or a stable message never settles. if (sameMessage(message, this.pending)) { this.pending = message; return; } this.pending = message; this.stopTimer(this.settleTimer); this.settleTimer = null; // Back to what was last spoken: a message still waiting for the gate is a detour nobody // needs to hear about. if (sameAnnouncement(message, this.spoken)) { this.stopTimer(this.voiceTimer); this.voiceTimer = null; } this.settleTimer = setTimeout(() => { this.settleTimer = null; this.settled = this.pending; this.writePlate(); this.scheduleVoice(); }, FeedbackAnnouncer.settleMs); } /** Hiding the plate does not silence the spoken channel: not drawing text over the camera view * must not take the guidance away from a screen reader. */ public setPlateVisible(visible: boolean): void { if (visible === this.plateVisible) return; this.plateVisible = visible; this.writePlate(); } /** Stop the timers and clear the visible plate. The DOM nodes stay in the container: a live * region has to exist before it is written, so re-inserting it at the start of every capture * would cost the first announcement (iOS VoiceOver in particular). */ public reset(): void { this.stopTimer(this.settleTimer); this.stopTimer(this.voiceTimer); this.settleTimer = null; this.voiceTimer = null; // Only if the nodes are still ours - a newer announcer may already be using them. if (this.isOwner()) { this.pending = noMessage; this.settled = noMessage; this.spoken = noMessage; this.forgetSpoken(); this.writePlate(); // The live regions keep their text: a screen reader reads a region a moment after it // changes, and the last message is often the outcome of the capture that just ended. FeedbackAnnouncer.owners.delete(this.container!); } this.container = null; this.visibleTextEl = null; this.politeRegion = null; this.alertRegion = null; } private forgetSpoken(): void { this.spokenStep = ""; this.spokenText = ""; this.lastSpokenAt = Number.NEGATIVE_INFINITY; } private isOwner(): boolean { return this.container !== null && FeedbackAnnouncer.owners.get(this.container) === this; } private stopTimer(timer: ReturnType | null): void { if (timer !== null) clearTimeout(timer); } private createOrReuseNodes(containerElement: HTMLElement): void { // Direct children only: a nested match means a different-version markup, which must be // rebuilt. const existingVisible = containerElement.querySelector(':scope > .zenid-feedback-instruction') as HTMLDivElement | null; const existingPolite = containerElement.querySelector(':scope > .zenid-feedback-live') as HTMLDivElement | null; const existingAlert = containerElement.querySelector(':scope > .zenid-feedback-alert') as HTMLDivElement | null; if (existingVisible && existingPolite && existingAlert) { // Empty regions let the next capture repeat the previous one's last message and still // be announced - a screen reader announces a change. existingVisible.textContent = ''; existingVisible.style.display = 'none'; existingPolite.textContent = ''; existingAlert.textContent = ''; this.visibleTextEl = existingVisible; this.politeRegion = existingPolite; this.alertRegion = existingAlert; return; } // Leftovers from another markup version (or a partial injection): remove before rebuilding. containerElement.querySelectorAll('.zenid-feedback-live, .zenid-feedback-alert, .zenid-feedback-instruction') .forEach(el => el.remove()); const containerPosition = window.getComputedStyle(containerElement).position; if (containerPosition === 'static' || containerPosition === '') { containerElement.style.position = 'relative'; } // aria-hidden: the screen reader gets the same text (plus step progress) through the live // region instead, never twice. const visible = document.createElement('div'); visible.className = 'zenid-feedback-instruction'; visible.setAttribute('aria-hidden', 'true'); Object.assign(visible.style, { position: 'absolute', top: '30%', left: '50%', transform: 'translate(-50%, -50%)', maxWidth: '80%', zIndex: '10000', // above the canvas (9999) boxSizing: 'border-box', margin: '0', padding: '0', textAlign: 'center', whiteSpace: 'pre-line', // corelib feedback may be multi-line color: '#ffffff', // The outline is the contrast over the camera view. textShadow: '-1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000, 0 1px 3px rgba(0, 0, 0, 0.95)', font: "700 20px system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", lineHeight: '1.25', transition: 'none', // honors prefers-reduced-motion pointerEvents: 'none', display: 'none', } as Partial); // Two regions, because the politeness of a region must not change under the screen reader - // several of them read it when the region is registered, not when the text arrives. // No role="alert": it makes screen readers prefix the text with "Alert". const polite = this.createLiveRegion('zenid-feedback-live', 'polite'); const alert = this.createLiveRegion('zenid-feedback-alert', 'assertive'); containerElement.appendChild(visible); containerElement.appendChild(polite); containerElement.appendChild(alert); this.visibleTextEl = visible; this.politeRegion = polite; this.alertRegion = alert; } /** Visually hidden by a 1px clipped box, NOT display:none - that would drop the region from * the accessibility tree. */ private createLiveRegion(className: string, politeness: string): HTMLDivElement { const region = document.createElement('div'); region.className = className; region.setAttribute('aria-live', politeness); region.setAttribute('aria-atomic', 'true'); Object.assign(region.style, { position: 'absolute', width: '1px', height: '1px', padding: '0', margin: '-1px', overflow: 'hidden', clip: 'rect(0, 0, 0, 0)', whiteSpace: 'nowrap', border: '0', } as Partial); return region; } private writePlate(): void { if (!this.visibleTextEl || !this.isOwner()) return; const text = this.plateVisible ? this.settled.instruction : ''; this.visibleTextEl.textContent = text; this.visibleTextEl.style.display = text ? 'block' : 'none'; } private writeLive(text: string, assertive: boolean): void { const target = assertive ? this.alertRegion : this.politeRegion; if (!target) return; // The outcome replaces the guidance; guidance never touches the outcome, which a screen // reader may still be reading. if (assertive && this.politeRegion) this.politeRegion.textContent = ''; if (target.textContent !== text) target.textContent = text; } /** Time a screen reader needs to speak the text, used as the gap before the next message. */ private static speakTimeMs(text: string): number { const words = text.split(/\s+/).filter(word => word).length; return Math.min(Math.max(words * FeedbackAnnouncer.msPerWord, FeedbackAnnouncer.minIntervalMs), FeedbackAnnouncer.maxIntervalMs); } /** Speak the settled message once the pacing gate opens; silence and immediate messages skip * it. Whatever is settled when the gate opens wins, so intermediate messages are dropped. */ private scheduleVoice(): void { this.stopTimer(this.voiceTimer); this.voiceTimer = null; if (sameAnnouncement(this.settled, this.spoken)) return; const skipGate = this.settled.immediate || (!this.settled.instruction && !this.settled.step); const gap = FeedbackAnnouncer.speakTimeMs(this.spokenText); const wait = skipGate ? 0 : Math.max(0, this.lastSpokenAt + gap - performance.now()); if (wait === 0) { this.speak(); } else { this.voiceTimer = setTimeout(() => this.speak(), wait); } } private speak(): void { this.stopTimer(this.voiceTimer); this.voiceTimer = null; if (!this.isOwner() || sameAnnouncement(this.settled, this.spoken)) return; this.spoken = this.settled; const stepPrefix = this.spoken.step !== this.spokenStep ? this.spoken.step : ''; const text = [stepPrefix, this.spoken.instruction].filter(part => part).join('. '); this.writeLive(text, this.spoken.assertive); if (text) { this.spokenStep = this.spoken.step; this.spokenText = text; this.lastSpokenAt = performance.now(); } // The outcome ends the capture, and the native hosts keep one announcer for the whole life // of the page: the next capture must announce its first message even when it repeats the // last one, and must not wait out a gate from the previous one. if (this.spoken.assertive) this.forgetSpoken(); } }