From f29cd1af2a71835e6284d609de53bdfeb9ca6062 Mon Sep 17 00:00:00 2001 From: myst Date: Mon, 9 Feb 2026 16:34:38 +0100 Subject: [PATCH 1/9] feat(screenreader): implement announcement queue and stop functionality --- .../mud-client/mud-client.component.ts | 3 + .../terminal/mud-screenreader.spec.ts | 31 ++++++-- .../app/features/terminal/mud-screenreader.ts | 75 +++++++++++++++---- 3 files changed, 89 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts b/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts index 4ac544c..b1703e9 100644 --- a/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts +++ b/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts @@ -326,6 +326,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { : { value: message }; if (typeof payload === 'string') { + if (!payload.length) { + this.screenReader?.stopAnnouncements(); + } this.screenReader?.appendToHistory(payload); // Announce the complete input so user can verify what they typed this.screenReader?.announceInputCommitted(payload); diff --git a/frontend/src/app/features/terminal/mud-screenreader.spec.ts b/frontend/src/app/features/terminal/mud-screenreader.spec.ts index 035b5b2..93bce57 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.spec.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.spec.ts @@ -12,7 +12,8 @@ describe('MudScreenReaderAnnouncer', () => { liveRegion, undefined, undefined, - 100, + 50, + 1000, ); }); @@ -26,7 +27,7 @@ describe('MudScreenReaderAnnouncer', () => { expect(liveRegion.textContent).toBe('Hello World'); - jest.advanceTimersByTime(99); + jest.advanceTimersByTime(49); expect(liveRegion.textContent).toBe('Hello World'); jest.advanceTimersByTime(1); @@ -43,16 +44,19 @@ describe('MudScreenReaderAnnouncer', () => { expect(liveRegion.textContent).toBe(''); }); - it('resets the clear timer for rapid consecutive announcements', () => { + it('queues rapid consecutive announcements', () => { announcer.announce('First'); - jest.advanceTimersByTime(50); - announcer.announce('Second'); - jest.advanceTimersByTime(99); - expect(liveRegion.textContent).toBe('Second'); + expect(liveRegion.textContent).toBe('First'); + + jest.advanceTimersByTime(49); + expect(liveRegion.textContent).toBe('First'); jest.advanceTimersByTime(1); + expect(liveRegion.textContent).toBe('Second'); + + jest.advanceTimersByTime(50); expect(liveRegion.textContent).toBe(''); }); @@ -64,6 +68,19 @@ describe('MudScreenReaderAnnouncer', () => { expect(liveRegion.textContent).toBe(''); }); + it('stopAnnouncements() clears live region and backlog', () => { + announcer.announce('First'); + announcer.announce('Second'); + + expect(liveRegion.textContent).toBe('First'); + + announcer.stopAnnouncements(); + expect(liveRegion.textContent).toBe(''); + + jest.advanceTimersByTime(200); + 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..016b78e 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -1,4 +1,5 @@ -const DEFAULT_CLEAR_DELAY_MS = 300; +const DEFAULT_MIN_ANNOUNCE_MS = 700; +const DEFAULT_SPEECH_CHARS_PER_SEC = 12; const INPUT_CLEAR_DELAY_MS = 700; const ANSI_ESCAPE_PATTERN = /\x1B\[[0-9;?]*[ -\/]*[@-~]/g; const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B-\x1F\x7F]/g; @@ -16,12 +17,16 @@ export class MudScreenReaderAnnouncer { private inputClearTimer: number | undefined; private sessionStartedAt: number; private lastAnnouncedBuffer = ''; + private announceQueue: string[] = []; + private isAnnouncing = false; + private abortToken = 0; constructor( private readonly liveRegion: HTMLElement, private readonly historyRegion?: HTMLElement, private readonly inputRegion?: HTMLElement, - private readonly clearDelayMs: number = DEFAULT_CLEAR_DELAY_MS, + private readonly minAnnouncementMs: number = DEFAULT_MIN_ANNOUNCE_MS, + private readonly speechCharsPerSecond: number = DEFAULT_SPEECH_CHARS_PER_SEC, ) { this.sessionStartedAt = Date.now(); } @@ -31,7 +36,7 @@ export class MudScreenReaderAnnouncer { */ public markSessionStart(timestamp: number = Date.now()): void { this.sessionStartedAt = timestamp; - this.clear(); + this.stopAnnouncements(); this.clearHistory(); this.lastAnnouncedBuffer = ''; } @@ -64,12 +69,7 @@ export class MudScreenReaderAnnouncer { return; } - this.liveRegion.textContent = normalized; - console.debug( - '[ScreenReader] Live region updated:', - this.liveRegion.textContent, - ); - this.scheduleClear(); + this.enqueueAnnouncement(normalized); } /** @@ -80,11 +80,21 @@ export class MudScreenReaderAnnouncer { this.cancelClearTimer(); } + /** + * Stops any in-flight announcements and drops the queued backlog. + */ + public stopAnnouncements(): void { + this.abortToken += 1; + this.announceQueue = []; + this.isAnnouncing = false; + this.clear(); + } + /** * Disposes internal timers. */ public dispose(): void { - this.clear(); + this.stopAnnouncements(); this.cancelInputClearTimer(); } @@ -272,12 +282,44 @@ export class MudScreenReaderAnnouncer { this.lastAnnouncedBuffer = ''; } - private scheduleClear(): void { + private enqueueAnnouncement(normalized: string): void { + this.announceQueue.push(normalized); + this.processQueue(); + } + + private processQueue(): void { + if (this.isAnnouncing) { + return; + } + + const next = this.announceQueue.shift(); + if (!next) { + return; + } + + this.isAnnouncing = true; + const token = this.abortToken; + + this.liveRegion.textContent = next; + console.debug('[ScreenReader] Live region updated:', next); + + const duration = this.getAnnouncementDuration(next); + this.scheduleClear(duration, token); + } + + private scheduleClear(durationMs: number, token: number): void { this.cancelClearTimer(); this.clearTimer = window.setTimeout(() => { - this.clear(); - }, this.clearDelayMs); + if (token !== this.abortToken) { + return; + } + + this.liveRegion.textContent = ''; + this.isAnnouncing = false; + this.clearTimer = undefined; + this.processQueue(); + }, durationMs); } private cancelClearTimer(): void { @@ -287,6 +329,13 @@ export class MudScreenReaderAnnouncer { } } + private getAnnouncementDuration(text: string): number { + const chars = Math.max(text.length, 1); + const estimatedMs = Math.ceil((chars / this.speechCharsPerSecond) * 1000); + + return Math.max(estimatedMs, this.minAnnouncementMs); + } + // Input clear helpers are retained for potential future use (currently unused) private scheduleInputClear(): void { this.cancelInputClearTimer(); From 5d76e670a57e6c464eae3f040d16ab0045308864 Mon Sep 17 00:00:00 2001 From: myst Date: Mon, 9 Feb 2026 21:42:47 +0100 Subject: [PATCH 2/9] feat(screenreader): enhance live region announcements with structured output --- .../mud-client/mud-client.component.html | 9 ++- .../terminal/mud-screenreader.spec.ts | 44 ++---------- .../app/features/terminal/mud-screenreader.ts | 72 ++++--------------- 3 files changed, 26 insertions(+), 99 deletions(-) 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 @@ -
+
{ 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, - 50, - 1000, - ); + 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(49); - expect(liveRegion.textContent).toBe('Hello World'); - - jest.advanceTimersByTime(1); - expect(liveRegion.textContent).toBe(''); + const items = liveRegion.querySelectorAll('p.sr-log-item'); + expect(items.length).toBe(1); + expect(items[0].textContent).toBe('Hello World'); }); it('ignores announcements older than the current session', () => { @@ -44,22 +31,6 @@ describe('MudScreenReaderAnnouncer', () => { expect(liveRegion.textContent).toBe(''); }); - it('queues rapid consecutive announcements', () => { - announcer.announce('First'); - announcer.announce('Second'); - - expect(liveRegion.textContent).toBe('First'); - - jest.advanceTimersByTime(49); - expect(liveRegion.textContent).toBe('First'); - - jest.advanceTimersByTime(1); - expect(liveRegion.textContent).toBe('Second'); - - jest.advanceTimersByTime(50); - expect(liveRegion.textContent).toBe(''); - }); - it('clear() empties the live region immediately', () => { announcer.announce('Message'); expect(liveRegion.textContent).toBe('Message'); @@ -72,13 +43,10 @@ describe('MudScreenReaderAnnouncer', () => { announcer.announce('First'); announcer.announce('Second'); - expect(liveRegion.textContent).toBe('First'); + expect(liveRegion.textContent).toBe('FirstSecond'); announcer.stopAnnouncements(); expect(liveRegion.textContent).toBe(''); - - jest.advanceTimersByTime(200); - expect(liveRegion.textContent).toBe(''); }); it('ignores empty output after normalization', () => { diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 016b78e..e1f8a57 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -1,5 +1,3 @@ -const DEFAULT_MIN_ANNOUNCE_MS = 700; -const DEFAULT_SPEECH_CHARS_PER_SEC = 12; const INPUT_CLEAR_DELAY_MS = 700; const ANSI_ESCAPE_PATTERN = /\x1B\[[0-9;?]*[ -\/]*[@-~]/g; const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B-\x1F\x7F]/g; @@ -13,20 +11,14 @@ 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 = ''; - private announceQueue: string[] = []; - private isAnnouncing = false; - private abortToken = 0; constructor( private readonly liveRegion: HTMLElement, private readonly historyRegion?: HTMLElement, private readonly inputRegion?: HTMLElement, - private readonly minAnnouncementMs: number = DEFAULT_MIN_ANNOUNCE_MS, - private readonly speechCharsPerSecond: number = DEFAULT_SPEECH_CHARS_PER_SEC, ) { this.sessionStartedAt = Date.now(); } @@ -69,7 +61,7 @@ export class MudScreenReaderAnnouncer { return; } - this.enqueueAnnouncement(normalized); + this.appendToLiveRegion(normalized); } /** @@ -77,16 +69,12 @@ 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.abortToken += 1; - this.announceQueue = []; - this.isAnnouncing = false; this.clear(); } @@ -282,60 +270,24 @@ export class MudScreenReaderAnnouncer { this.lastAnnouncedBuffer = ''; } - private enqueueAnnouncement(normalized: string): void { - this.announceQueue.push(normalized); - this.processQueue(); - } - - private processQueue(): void { - if (this.isAnnouncing) { - return; - } - - const next = this.announceQueue.shift(); - if (!next) { - return; - } - - this.isAnnouncing = true; - const token = this.abortToken; - - this.liveRegion.textContent = next; - console.debug('[ScreenReader] Live region updated:', next); - - const duration = this.getAnnouncementDuration(next); - this.scheduleClear(duration, token); - } - - private scheduleClear(durationMs: number, token: number): void { - this.cancelClearTimer(); + private appendToLiveRegion(normalized: string): void { + const doc = this.liveRegion.ownerDocument; + const lines = normalized.split('\n'); - this.clearTimer = window.setTimeout(() => { - if (token !== this.abortToken) { - return; + for (const line of lines) { + if (!line.trim()) { + continue; } - this.liveRegion.textContent = ''; - this.isAnnouncing = false; - this.clearTimer = undefined; - this.processQueue(); - }, durationMs); - } + const item = doc.createElement('p'); + item.className = 'sr-log-item'; + item.textContent = line; + item.setAttribute('role', 'text'); - private cancelClearTimer(): void { - if (this.clearTimer !== undefined) { - window.clearTimeout(this.clearTimer); - this.clearTimer = undefined; + this.liveRegion.appendChild(item); } } - private getAnnouncementDuration(text: string): number { - const chars = Math.max(text.length, 1); - const estimatedMs = Math.ceil((chars / this.speechCharsPerSecond) * 1000); - - return Math.max(estimatedMs, this.minAnnouncementMs); - } - // Input clear helpers are retained for potential future use (currently unused) private scheduleInputClear(): void { this.cancelInputClearTimer(); From d89115192bf112e3b32e01f346d220555383529f Mon Sep 17 00:00:00 2001 From: myst Date: Tue, 10 Feb 2026 09:34:00 +0100 Subject: [PATCH 3/9] feat(screenreader): enhance stopAnnouncements method to temporarily disable aria-live --- .../app/features/terminal/mud-screenreader.ts | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index e1f8a57..33f03a8 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -76,6 +76,12 @@ export class MudScreenReaderAnnouncer { */ public stopAnnouncements(): void { this.clear(); + + const previousLive = this.liveRegion.getAttribute('aria-live') ?? 'polite'; + this.liveRegion.setAttribute('aria-live', 'off'); + queueMicrotask(() => { + this.liveRegion.setAttribute('aria-live', previousLive); + }); } /** @@ -272,20 +278,12 @@ export class MudScreenReaderAnnouncer { private appendToLiveRegion(normalized: string): void { const doc = this.liveRegion.ownerDocument; - const lines = normalized.split('\n'); - - for (const line of lines) { - if (!line.trim()) { - continue; - } + const item = doc.createElement('p'); + item.className = 'sr-log-item'; + item.textContent = normalized; + item.setAttribute('role', 'text'); - const item = doc.createElement('p'); - item.className = 'sr-log-item'; - item.textContent = line; - item.setAttribute('role', 'text'); - - this.liveRegion.appendChild(item); - } + this.liveRegion.appendChild(item); } // Input clear helpers are retained for potential future use (currently unused) From 382a696755311bf34440425bda2947c3af928e08 Mon Sep 17 00:00:00 2001 From: myst Date: Tue, 10 Feb 2026 21:39:10 +0100 Subject: [PATCH 4/9] feat(frontend): implement echo suppression and improve announcement formatting --- .../mud-client/mud-client.component.ts | 21 +++++++++++++++++++ .../terminal/mud-screenreader.spec.ts | 6 ++---- .../app/features/terminal/mud-screenreader.ts | 19 ++++++++++------- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts b/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts index b1703e9..0949184 100644 --- a/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts +++ b/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts @@ -71,6 +71,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private readonly terminalFitAddon = new FitAddon(); private socketAdapter?: MudSocketAdapter; private terminalAttachAddon?: AttachAddon; + private pendingEchoSuppression: string | null = null; private readonly terminalDisposables: IDisposable[] = []; private readonly resizeObs = new ResizeObserver(() => { @@ -326,6 +327,12 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { : { value: message }; if (typeof payload === 'string') { + const normalizedInput = + this.screenReader?.normalizeForComparison(payload); + + this.pendingEchoSuppression = normalizedInput?.length + ? normalizedInput + : null; if (!payload.length) { this.screenReader?.stopAnnouncements(); } @@ -480,6 +487,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 67667ba..b859902 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.spec.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.spec.ts @@ -16,9 +16,7 @@ describe('MudScreenReaderAnnouncer', () => { it('announces sanitized text by appending to the live region', () => { announcer.announce('Hello \x1b[31mWorld\x1b[0m\r\n'); - const items = liveRegion.querySelectorAll('p.sr-log-item'); - expect(items.length).toBe(1); - expect(items[0].textContent).toBe('Hello World'); + expect(liveRegion.textContent).toBe('Hello World\n'); }); it('ignores announcements older than the current session', () => { @@ -43,7 +41,7 @@ describe('MudScreenReaderAnnouncer', () => { announcer.announce('First'); announcer.announce('Second'); - expect(liveRegion.textContent).toBe('FirstSecond'); + expect(liveRegion.textContent).toBe('First\nSecond\n'); announcer.stopAnnouncements(); expect(liveRegion.textContent).toBe(''); diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 33f03a8..4f9e500 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -76,12 +76,16 @@ export class MudScreenReaderAnnouncer { */ public stopAnnouncements(): void { this.clear(); - const previousLive = this.liveRegion.getAttribute('aria-live') ?? 'polite'; this.liveRegion.setAttribute('aria-live', 'off'); - queueMicrotask(() => { + + const doc = this.liveRegion.ownerDocument; + this.liveRegion.appendChild(doc.createTextNode(' ')); + + setTimeout(() => { + this.liveRegion.textContent = ''; this.liveRegion.setAttribute('aria-live', previousLive); - }); + }, 0); } /** @@ -278,12 +282,11 @@ export class MudScreenReaderAnnouncer { private appendToLiveRegion(normalized: string): void { const doc = this.liveRegion.ownerDocument; - const item = doc.createElement('p'); - item.className = 'sr-log-item'; - item.textContent = normalized; - item.setAttribute('role', 'text'); + this.liveRegion.appendChild(doc.createTextNode(`${normalized}\n`)); + } - this.liveRegion.appendChild(item); + public normalizeForComparison(raw: string): string { + return this.normalize(raw); } // Input clear helpers are retained for potential future use (currently unused) From 0f0df1982120e49b535acf471453e7f6f127eb7d Mon Sep 17 00:00:00 2001 From: myst Date: Tue, 10 Feb 2026 21:59:17 +0100 Subject: [PATCH 5/9] fix(frontend): refine input handling by stopping announcements for empty inputs --- .../core/mud/components/mud-client/mud-client.component.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts b/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts index 0949184..1ab033c 100644 --- a/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts +++ b/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts @@ -333,12 +333,10 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.pendingEchoSuppression = normalizedInput?.length ? normalizedInput : null; - if (!payload.length) { + if (!normalizedInput?.length) { this.screenReader?.stopAnnouncements(); } this.screenReader?.appendToHistory(payload); - // Announce the complete input so user can verify what they typed - this.screenReader?.announceInputCommitted(payload); } this.mudService.sendMessage(payload); From aa53284f76c0570e23eb5f814495c4c318939fe7 Mon Sep 17 00:00:00 2001 From: myst Date: Tue, 10 Feb 2026 22:21:44 +0100 Subject: [PATCH 6/9] fix(frontend): increase delay for clearing live region announcements --- frontend/src/app/features/terminal/mud-screenreader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 4f9e500..cf3b29c 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -85,7 +85,7 @@ export class MudScreenReaderAnnouncer { setTimeout(() => { this.liveRegion.textContent = ''; this.liveRegion.setAttribute('aria-live', previousLive); - }, 0); + }, 100); } /** From c76f33c8ab42d0887c14b36ab11bf06075611260 Mon Sep 17 00:00:00 2001 From: myst Date: Tue, 10 Feb 2026 22:35:02 +0100 Subject: [PATCH 7/9] feat(screenreader): clear input region when clearing live region announcements --- frontend/src/app/features/terminal/mud-screenreader.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index cf3b29c..cc62e18 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -82,9 +82,16 @@ export class MudScreenReaderAnnouncer { const doc = this.liveRegion.ownerDocument; this.liveRegion.appendChild(doc.createTextNode(' ')); + if (this.inputRegion) { + this.inputRegion.textContent = ' '; + } + setTimeout(() => { this.liveRegion.textContent = ''; this.liveRegion.setAttribute('aria-live', previousLive); + if (this.inputRegion) { + this.inputRegion.textContent = ''; + } }, 100); } From 0b1a3ca91642849de066c142edbb454cc87b37f7 Mon Sep 17 00:00:00 2001 From: myst Date: Tue, 10 Feb 2026 22:55:01 +0100 Subject: [PATCH 8/9] feat(frontend): implement announceSynthetic method for empty input echo --- .../components/mud-client/mud-client.component.ts | 9 ++++++++- .../src/app/features/terminal/mud-screenreader.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts b/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts index 1ab033c..cf22236 100644 --- a/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts +++ b/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts @@ -334,7 +334,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { ? normalizedInput : null; if (!normalizedInput?.length) { - this.screenReader?.stopAnnouncements(); + this.screenReader?.announceSynthetic(' '); } this.screenReader?.appendToHistory(payload); } @@ -401,6 +401,13 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { return; } + if ( + (data === CTRL.CR || data === CTRL.LF) && + !this.inputController.hasContent() + ) { + this.screenReader?.announceSynthetic(' '); + } + this.inputController.handleData(data); } diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index cc62e18..51688a3 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -95,6 +95,19 @@ export class MudScreenReaderAnnouncer { }, 100); } + /** + * Appends a synthetic output token to the live region without normalization. + * Used to simulate a server echo for empty Enter without touching the server. + */ + public announceSynthetic(raw: string): void { + if (!raw) { + return; + } + + const doc = this.liveRegion.ownerDocument; + this.liveRegion.appendChild(doc.createTextNode(raw)); + } + /** * Disposes internal timers. */ From 22e4b65c6fa4645676816c259983cebd22481cd2 Mon Sep 17 00:00:00 2001 From: myst Date: Tue, 10 Feb 2026 23:07:13 +0100 Subject: [PATCH 9/9] refactor(frontend): remove redundant synthetic announcement logic --- .../mud-client/mud-client.component.ts | 10 ------- .../app/features/terminal/mud-screenreader.ts | 30 ------------------- 2 files changed, 40 deletions(-) diff --git a/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts b/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts index cf22236..d8cc2a1 100644 --- a/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts +++ b/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts @@ -333,9 +333,6 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.pendingEchoSuppression = normalizedInput?.length ? normalizedInput : null; - if (!normalizedInput?.length) { - this.screenReader?.announceSynthetic(' '); - } this.screenReader?.appendToHistory(payload); } @@ -401,13 +398,6 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { return; } - if ( - (data === CTRL.CR || data === CTRL.LF) && - !this.inputController.hasContent() - ) { - this.screenReader?.announceSynthetic(' '); - } - this.inputController.handleData(data); } diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 51688a3..4fdb852 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -76,36 +76,6 @@ export class MudScreenReaderAnnouncer { */ public stopAnnouncements(): void { this.clear(); - const previousLive = this.liveRegion.getAttribute('aria-live') ?? 'polite'; - this.liveRegion.setAttribute('aria-live', 'off'); - - const doc = this.liveRegion.ownerDocument; - this.liveRegion.appendChild(doc.createTextNode(' ')); - - if (this.inputRegion) { - this.inputRegion.textContent = ' '; - } - - setTimeout(() => { - this.liveRegion.textContent = ''; - this.liveRegion.setAttribute('aria-live', previousLive); - if (this.inputRegion) { - this.inputRegion.textContent = ''; - } - }, 100); - } - - /** - * Appends a synthetic output token to the live region without normalization. - * Used to simulate a server echo for empty Enter without touching the server. - */ - public announceSynthetic(raw: string): void { - if (!raw) { - return; - } - - const doc = this.liveRegion.ownerDocument; - this.liveRegion.appendChild(doc.createTextNode(raw)); } /**