Skip to content

Commit 1df7b8a

Browse files
committed
feat(frontend): implement MudScreenReader for improved accessibility
1 parent 41490a6 commit 1df7b8a

File tree

10 files changed

+420
-20
lines changed

10 files changed

+420
-20
lines changed

frontend/setup-jest.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
import 'jest-preset-angular/setup-jest';
1+
// import 'jest-preset-angular/setup-jest';

frontend/src/app/core/mud/components/mud-client/mud-client.component.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
<div
2+
class="sr-announcer"
3+
aria-live="polite"
4+
aria-atomic="true"
5+
aria-relevant="additions"
6+
#liveRegionRef
7+
></div>
8+
9+
<div class="sr-history" role="log" aria-live="off" #historyRegionRef></div>
10+
111
<div class="mud-output" #hostRef></div>
212

313
@if (!(isConnected$ | async)) {

frontend/src/app/core/mud/components/mud-client/mud-client.component.scss

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
display: flex;
33
flex-direction: column;
44
min-height: 0;
5+
position: relative;
56

67
.mud-output {
78
flex: 1 1 0;
@@ -10,6 +11,34 @@
1011
overflow: hidden;
1112
}
1213

14+
.sr-announcer {
15+
position: absolute;
16+
width: 1px;
17+
height: 1px;
18+
padding: 0;
19+
margin: -1px;
20+
overflow: hidden;
21+
clip: rect(0, 0, 0, 0);
22+
white-space: pre-wrap;
23+
border: 0;
24+
}
25+
26+
.sr-history {
27+
position: absolute;
28+
width: 1px;
29+
height: 1px;
30+
padding: 0;
31+
margin: -1px;
32+
overflow: hidden;
33+
clip: rect(0, 0, 0, 0);
34+
white-space: pre-wrap;
35+
border: 0;
36+
}
37+
38+
.sr-log-item {
39+
white-space: pre-wrap;
40+
}
41+
1342
/* Optionales Styling */
1443
.disconnected-panel {
1544
flex: 0 0 auto;

frontend/src/app/core/mud/components/mud-client/mud-client.component.ts

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
MudPromptManager,
2222
MudSocketAdapter,
2323
MudPromptContext,
24+
MudScreenReaderAnnouncer,
2425
} from '../../../../features/terminal';
2526

2627
/**
@@ -37,7 +38,9 @@ const DELETE_SEQUENCE = `${CTRL.ESC}[3~`;
3738

3839
/**
3940
* Angular wrapper around the xterm-based MUD client. The component hosts the terminal,
40-
* wires the input/prompt helpers together and mirrors socket events to the view.
41+
* wires the input/prompt helpers together and mirrors socket events to the view. A
42+
* custom screenreader announcer replaces xterm's built-in screenReaderMode to avoid
43+
* duplicated output and replaying history after reconnects.
4144
*/
4245
@Component({
4346
selector: 'app-mud-client',
@@ -52,19 +55,10 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
5255
private readonly terminal: Terminal;
5356
private readonly inputController: MudInputController;
5457
private readonly promptManager: MudPromptManager;
58+
private screenReader?: MudScreenReaderAnnouncer;
5559
private readonly terminalFitAddon = new FitAddon();
56-
private readonly socketAdapter = new MudSocketAdapter(
57-
this.mudService.mudOutput$,
58-
{
59-
transformMessage: (data) => this.transformMudOutput(data),
60-
beforeMessage: (data) => this.beforeMudOutput(data),
61-
afterMessage: (data) => this.afterMudOutput(data),
62-
},
63-
);
64-
private readonly terminalAttachAddon = new AttachAddon(
65-
this.socketAdapter as unknown as WebSocket,
66-
{ bidirectional: false },
67-
);
60+
private socketAdapter?: MudSocketAdapter;
61+
private terminalAttachAddon?: AttachAddon;
6862

6963
private readonly terminalDisposables: IDisposable[] = [];
7064
private readonly resizeObs = new ResizeObserver(() => {
@@ -84,6 +78,12 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
8478
@ViewChild('hostRef', { static: true })
8579
private readonly terminalRef!: ElementRef<HTMLDivElement>;
8680

81+
@ViewChild('liveRegionRef', { static: true })
82+
private readonly liveRegionRef!: ElementRef<HTMLDivElement>;
83+
84+
@ViewChild('historyRegionRef', { static: true })
85+
private readonly historyRegionRef!: ElementRef<HTMLElement>;
86+
8787
protected readonly isConnected$ = this.mudService.connectedToMud$;
8888
protected readonly showEcho$ = this.mudService.showEcho$;
8989

@@ -96,7 +96,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
9696
fontFamily: 'JetBrainsMono, monospace',
9797
theme: { background: '#000', foreground: '#ccc' },
9898
disableStdin: false,
99-
screenReaderMode: true,
99+
screenReaderMode: false,
100100
});
101101

102102
this.inputController = new MudInputController(
@@ -116,6 +116,28 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
116116
* to socket events and reports the initial viewport dimensions to the server.
117117
*/
118118
ngAfterViewInit() {
119+
// Initialize screenreader announcer before terminal/socket setup
120+
// to ensure we capture the session start BEFORE any output arrives
121+
this.screenReader = new MudScreenReaderAnnouncer(
122+
this.liveRegionRef.nativeElement,
123+
this.historyRegionRef.nativeElement,
124+
);
125+
console.debug(
126+
'[MudClient] Screenreader announcer initialized, live region:',
127+
this.liveRegionRef.nativeElement,
128+
);
129+
130+
// Now initialize socket adapter AFTER screenreader is ready
131+
this.socketAdapter = new MudSocketAdapter(this.mudService.mudOutput$, {
132+
transformMessage: (data) => this.transformMudOutput(data),
133+
beforeMessage: (data) => this.beforeMudOutput(data),
134+
afterMessage: (data) => this.afterMudOutput(data),
135+
});
136+
this.terminalAttachAddon = new AttachAddon(
137+
this.socketAdapter as unknown as WebSocket,
138+
{ bidirectional: false },
139+
);
140+
119141
this.terminal.open(this.terminalRef.nativeElement);
120142
this.terminal.loadAddon(this.terminalFitAddon);
121143
this.terminal.loadAddon(this.terminalAttachAddon);
@@ -152,15 +174,17 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
152174
this.showEchoSubscription?.unsubscribe();
153175
this.linemodeSubscription?.unsubscribe();
154176

155-
this.terminalAttachAddon.dispose();
156-
this.socketAdapter.dispose();
177+
this.terminalAttachAddon?.dispose();
178+
this.socketAdapter?.dispose();
157179
this.terminal.dispose();
180+
this.screenReader?.dispose();
158181
}
159182

160183
protected connect() {
161184
const columns = this.terminal.cols;
162185
const rows = this.terminal.rows;
163186

187+
this.screenReader?.markSessionStart();
164188
this.mudService.connect({ columns, rows });
165189
}
166190

@@ -203,6 +227,10 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
203227
? message
204228
: { value: message };
205229

230+
if (typeof payload === 'string') {
231+
this.screenReader?.appendToHistory(payload);
232+
}
233+
206234
this.mudService.sendMessage(payload);
207235
}
208236

@@ -272,6 +300,8 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
272300
*/
273301
private afterMudOutput(data: string) {
274302
this.promptManager.afterServerOutput(data, this.getPromptContext());
303+
this.announceToScreenReader(data);
304+
this.screenReader?.appendToHistory(data);
275305
}
276306

277307
/**
@@ -292,6 +322,23 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
292322
};
293323
}
294324

325+
/**
326+
* Announces new server output via the custom screenreader announcer.
327+
* Called AFTER prompt restoration so we announce the final visible text.
328+
*/
329+
private announceToScreenReader(data: string): void {
330+
if (!this.screenReader) {
331+
return;
332+
}
333+
334+
console.debug('[MudClient] Announcing to screenreader:', {
335+
rawLength: data.length,
336+
raw: data,
337+
});
338+
339+
this.screenReader.announce(data);
340+
}
341+
295342
/**
296343
* Convenience helper for patching the local state object.
297344
*/

frontend/src/app/features/terminal/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './models/escapes';
22
export * from './mud-input.controller';
33
export * from './mud-prompt.manager';
44
export * from './mud-socket.adapter';
5+
export * from './mud-screenreader';
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { MudScreenReaderAnnouncer } from './mud-screenreader';
2+
3+
describe('MudScreenReaderAnnouncer', () => {
4+
let liveRegion: HTMLElement;
5+
let announcer: MudScreenReaderAnnouncer;
6+
7+
beforeEach(() => {
8+
jest.useFakeTimers();
9+
liveRegion = document.createElement('div');
10+
// Explicitly skip history region while overriding clear delay for tests
11+
announcer = new MudScreenReaderAnnouncer(liveRegion, undefined, 100);
12+
});
13+
14+
afterEach(() => {
15+
announcer.dispose();
16+
jest.useRealTimers();
17+
});
18+
19+
it('announces sanitized text and clears after delay', () => {
20+
announcer.announce('Hello \x1b[31mWorld\x1b[0m\r\n');
21+
22+
expect(liveRegion.textContent).toBe('Hello World');
23+
24+
jest.advanceTimersByTime(99);
25+
expect(liveRegion.textContent).toBe('Hello World');
26+
27+
jest.advanceTimersByTime(1);
28+
expect(liveRegion.textContent).toBe('');
29+
});
30+
31+
it('ignores announcements older than the current session', () => {
32+
const now = Date.now();
33+
const earlier = now - 500;
34+
35+
announcer.markSessionStart(now);
36+
announcer.announce('Old content', earlier);
37+
38+
expect(liveRegion.textContent).toBe('');
39+
});
40+
41+
it('resets the clear timer for rapid consecutive announcements', () => {
42+
announcer.announce('First');
43+
jest.advanceTimersByTime(50);
44+
45+
announcer.announce('Second');
46+
jest.advanceTimersByTime(99);
47+
48+
expect(liveRegion.textContent).toBe('Second');
49+
50+
jest.advanceTimersByTime(1);
51+
expect(liveRegion.textContent).toBe('');
52+
});
53+
54+
it('clear() empties the live region immediately', () => {
55+
announcer.announce('Message');
56+
expect(liveRegion.textContent).toBe('Message');
57+
58+
announcer.clear();
59+
expect(liveRegion.textContent).toBe('');
60+
});
61+
62+
it('ignores empty output after normalization', () => {
63+
announcer.announce('\x1b[31m\x1b[0m');
64+
65+
expect(liveRegion.textContent).toBe('');
66+
});
67+
});

0 commit comments

Comments
 (0)