diff --git a/frontend/src/app/core/mud/components/mud-client/mud-client.component.html b/frontend/src/app/core/mud/components/mud-client/mud-client.component.html index dee04d7..20b818b 100644 --- a/frontend/src/app/core/mud/components/mud-client/mud-client.component.html +++ b/frontend/src/app/core/mud/components/mud-client/mud-client.component.html @@ -1,4 +1,11 @@ -
+
{ @@ -326,9 +327,13 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { : { value: message }; if (typeof payload === 'string') { + const normalizedInput = + this.screenReader?.normalizeForComparison(payload); + + this.pendingEchoSuppression = normalizedInput?.length + ? normalizedInput + : null; this.screenReader?.appendToHistory(payload); - // Announce the complete input so user can verify what they typed - this.screenReader?.announceInputCommitted(payload); } this.mudService.sendMessage(payload); @@ -477,6 +482,20 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { return; } + const normalizedOutput = this.screenReader.normalizeForComparison(data); + + if ( + this.pendingEchoSuppression && + normalizedOutput === this.pendingEchoSuppression + ) { + this.pendingEchoSuppression = null; + return; + } + + if (normalizedOutput) { + this.pendingEchoSuppression = null; + } + console.debug('[MudClient] Announcing to screenreader:', { rawLength: data.length, raw: data, diff --git a/frontend/src/app/features/terminal/mud-screenreader.spec.ts b/frontend/src/app/features/terminal/mud-screenreader.spec.ts index 035b5b2..b859902 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.spec.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.spec.ts @@ -5,32 +5,18 @@ describe('MudScreenReaderAnnouncer', () => { let announcer: MudScreenReaderAnnouncer; beforeEach(() => { - jest.useFakeTimers(); liveRegion = document.createElement('div'); - // Explicitly skip history region while overriding clear delay for tests - announcer = new MudScreenReaderAnnouncer( - liveRegion, - undefined, - undefined, - 100, - ); + announcer = new MudScreenReaderAnnouncer(liveRegion); }); afterEach(() => { announcer.dispose(); - jest.useRealTimers(); }); - it('announces sanitized text and clears after delay', () => { + it('announces sanitized text by appending to the live region', () => { announcer.announce('Hello \x1b[31mWorld\x1b[0m\r\n'); - expect(liveRegion.textContent).toBe('Hello World'); - - jest.advanceTimersByTime(99); - expect(liveRegion.textContent).toBe('Hello World'); - - jest.advanceTimersByTime(1); - expect(liveRegion.textContent).toBe(''); + expect(liveRegion.textContent).toBe('Hello World\n'); }); it('ignores announcements older than the current session', () => { @@ -43,19 +29,6 @@ describe('MudScreenReaderAnnouncer', () => { expect(liveRegion.textContent).toBe(''); }); - it('resets the clear timer for rapid consecutive announcements', () => { - announcer.announce('First'); - jest.advanceTimersByTime(50); - - announcer.announce('Second'); - jest.advanceTimersByTime(99); - - expect(liveRegion.textContent).toBe('Second'); - - jest.advanceTimersByTime(1); - expect(liveRegion.textContent).toBe(''); - }); - it('clear() empties the live region immediately', () => { announcer.announce('Message'); expect(liveRegion.textContent).toBe('Message'); @@ -64,6 +37,16 @@ describe('MudScreenReaderAnnouncer', () => { expect(liveRegion.textContent).toBe(''); }); + it('stopAnnouncements() clears live region and backlog', () => { + announcer.announce('First'); + announcer.announce('Second'); + + expect(liveRegion.textContent).toBe('First\nSecond\n'); + + announcer.stopAnnouncements(); + expect(liveRegion.textContent).toBe(''); + }); + it('ignores empty output after normalization', () => { announcer.announce('\x1b[31m\x1b[0m'); diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index c9e37b8..4fdb852 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -1,4 +1,3 @@ -const DEFAULT_CLEAR_DELAY_MS = 300; const INPUT_CLEAR_DELAY_MS = 700; const ANSI_ESCAPE_PATTERN = /\x1B\[[0-9;?]*[ -\/]*[@-~]/g; const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B-\x1F\x7F]/g; @@ -12,7 +11,6 @@ const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B-\x1F\x7F]/g; * - Clear the live region shortly after announcing to avoid re-reading history */ export class MudScreenReaderAnnouncer { - private clearTimer: number | undefined; private inputClearTimer: number | undefined; private sessionStartedAt: number; private lastAnnouncedBuffer = ''; @@ -21,7 +19,6 @@ export class MudScreenReaderAnnouncer { private readonly liveRegion: HTMLElement, private readonly historyRegion?: HTMLElement, private readonly inputRegion?: HTMLElement, - private readonly clearDelayMs: number = DEFAULT_CLEAR_DELAY_MS, ) { this.sessionStartedAt = Date.now(); } @@ -31,7 +28,7 @@ export class MudScreenReaderAnnouncer { */ public markSessionStart(timestamp: number = Date.now()): void { this.sessionStartedAt = timestamp; - this.clear(); + this.stopAnnouncements(); this.clearHistory(); this.lastAnnouncedBuffer = ''; } @@ -64,12 +61,7 @@ export class MudScreenReaderAnnouncer { return; } - this.liveRegion.textContent = normalized; - console.debug( - '[ScreenReader] Live region updated:', - this.liveRegion.textContent, - ); - this.scheduleClear(); + this.appendToLiveRegion(normalized); } /** @@ -77,14 +69,20 @@ export class MudScreenReaderAnnouncer { */ public clear(): void { this.liveRegion.textContent = ''; - this.cancelClearTimer(); + } + + /** + * Stops any in-flight announcements and drops the queued backlog. + */ + public stopAnnouncements(): void { + this.clear(); } /** * Disposes internal timers. */ public dispose(): void { - this.clear(); + this.stopAnnouncements(); this.cancelInputClearTimer(); } @@ -272,19 +270,13 @@ export class MudScreenReaderAnnouncer { this.lastAnnouncedBuffer = ''; } - private scheduleClear(): void { - this.cancelClearTimer(); - - this.clearTimer = window.setTimeout(() => { - this.clear(); - }, this.clearDelayMs); + private appendToLiveRegion(normalized: string): void { + const doc = this.liveRegion.ownerDocument; + this.liveRegion.appendChild(doc.createTextNode(`${normalized}\n`)); } - private cancelClearTimer(): void { - if (this.clearTimer !== undefined) { - window.clearTimeout(this.clearTimer); - this.clearTimer = undefined; - } + public normalizeForComparison(raw: string): string { + return this.normalize(raw); } // Input clear helpers are retained for potential future use (currently unused)