Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
<div class="sr-announcer" aria-live="assertive" #liveRegionRef></div>
<div
class="sr-announcer"
role="log"
aria-live="polite"
aria-relevant="additions text"
aria-atomic="false"
#liveRegionRef
></div>

<div
class="sr-input"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 13 additions & 30 deletions frontend/src/app/features/terminal/mud-screenreader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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');
Expand All @@ -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');

Expand Down
38 changes: 15 additions & 23 deletions frontend/src/app/features/terminal/mud-screenreader.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 = '';
Expand All @@ -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();
}
Expand All @@ -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 = '';
}
Expand Down Expand Up @@ -64,27 +61,28 @@ export class MudScreenReaderAnnouncer {
return;
}

this.liveRegion.textContent = normalized;
console.debug(
'[ScreenReader] Live region updated:',
this.liveRegion.textContent,
);
this.scheduleClear();
this.appendToLiveRegion(normalized);
}

/**
* Clears the live region and any pending timers.
*/
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();
}

Expand Down Expand Up @@ -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)
Expand Down