Skip to content

Commit 209674a

Browse files
TMHSDigitalclaude
andcommitted
Raise text contrast, expose the passage to screen readers, add a focus hint, and blur only typed text in Hard Mode
- Untyped text, labels, and primary buttons now meet WCAG AA contrast. An axe check in the tests covers the main screen and both dialogs. - The passage is copied into a visually hidden element that describes the input. Settings pills set aria-pressed, and the result dialog is described by its WPM and accuracy. - When the input loses focus, a hint covers the passage. A letter key pressed elsewhere moves focus back to the input. - Hard Mode blurs characters already typed instead of the whole passage. Fixes #7, fixes #8, fixes #9, fixes #10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3770f69 commit 209674a

7 files changed

Lines changed: 216 additions & 23 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ Open `index.html` in a browser, or serve the folder with any static file server.
1616
- **Stats**: WPM, accuracy, mistakes, and your best WPM for the current settings, updated as you type.
1717
- WPM is correct characters divided by five, per minute of real elapsed time.
1818
- Accuracy is the share of typed characters that matched, counting mistakes you later fixed. It is rounded down, so 100% means no mistakes.
19-
- **Hard Mode**: blurs the text you are typing.
19+
- **Hard Mode**: blurs the characters you have already typed, so you cannot see your mistakes. The text ahead stays readable.
2020
- **Ghost Mode**: records the timing of your best run and replays its position as a second cursor while you type.
2121
- **Results**: a modal with your WPM and accuracy, a line chart of your last ten runs at the current settings once you have two or more, and a button that copies a one-line summary to the clipboard.
2222
- **Sound**: short tones made with the Web Audio API for keypress, mistake, and completion. Can be turned off.
2323
- **Keyboard**: Tab restarts while you are typing. Escape closes a dialog. Opening Settings during a run abandons the run.
24+
- **Focus**: if the typing input loses focus, a hint appears over the text. Pressing a letter key moves focus back to the input.
25+
- **Screen readers**: the passage is available as the input's description, settings buttons report whether they are selected, and the result dialog reads out WPM and accuracy.
2426
- **Reduced motion**: with the OS reduce-motion setting on, the background pulse, caret blink, and fades are turned off.
2527

2628
Pasting into the input is blocked, and the caret stays at the end: arrow keys, Home, End, select-all, and clicks do not move it.

‎index.html‎

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,15 @@ <h1>TYPE<span class="accent">SPEED</span></h1>
9797

9898
<div class="typing-container">
9999
<div id="quote-display" class="quote-display" aria-hidden="true"></div>
100+
<p id="quote-text" class="visually-hidden"></p>
101+
<div id="focus-hint" class="focus-hint" aria-hidden="true">
102+
Click here or press any key to type
103+
</div>
100104
<textarea
101105
id="typing-input"
102106
class="typing-input"
103107
aria-label="Type the text shown above"
104-
placeholder="Type here..."
108+
aria-describedby="quote-text"
105109
autocomplete="off"
106110
autocorrect="off"
107111
autocapitalize="off"
@@ -139,10 +143,11 @@ <h1>TYPE<span class="accent">SPEED</span></h1>
139143
role="dialog"
140144
aria-modal="true"
141145
aria-labelledby="result-title"
146+
aria-describedby="result-stats"
142147
>
143148
<div class="modal-content">
144149
<h2 id="result-title">Result</h2>
145-
<div class="modal-stats">
150+
<div class="modal-stats" id="result-stats">
146151
<div class="modal-stat">
147152
<span class="label">WPM</span>
148153
<span id="modal-wpm" class="value">0</span>
@@ -192,8 +197,12 @@ <h2 id="settings-title">Settings</h2>
192197
role="group"
193198
aria-labelledby="mode-label"
194199
>
195-
<button class="pill active" data-value="time">Time</button>
196-
<button class="pill" data-value="words">Words</button>
200+
<button class="pill active" data-value="time" aria-pressed="true">
201+
Time
202+
</button>
203+
<button class="pill" data-value="words" aria-pressed="false">
204+
Words
205+
</button>
197206
</div>
198207
</div>
199208

@@ -205,9 +214,11 @@ <h2 id="settings-title">Settings</h2>
205214
role="group"
206215
aria-labelledby="duration-label"
207216
>
208-
<button class="pill" data-value="15">15s</button>
209-
<button class="pill" data-value="30">30s</button>
210-
<button class="pill active" data-value="60">60s</button>
217+
<button class="pill" data-value="15" aria-pressed="false">15s</button>
218+
<button class="pill" data-value="30" aria-pressed="false">30s</button>
219+
<button class="pill active" data-value="60" aria-pressed="true">
220+
60s
221+
</button>
211222
</div>
212223
</div>
213224

@@ -219,9 +230,11 @@ <h2 id="settings-title">Settings</h2>
219230
role="group"
220231
aria-labelledby="word-count-label"
221232
>
222-
<button class="pill active" data-value="10">10</button>
223-
<button class="pill" data-value="25">25</button>
224-
<button class="pill" data-value="50">50</button>
233+
<button class="pill active" data-value="10" aria-pressed="true">
234+
10
235+
</button>
236+
<button class="pill" data-value="25" aria-pressed="false">25</button>
237+
<button class="pill" data-value="50" aria-pressed="false">50</button>
225238
</div>
226239
</div>
227240

@@ -236,7 +249,7 @@ <h2 id="settings-title">Settings</h2>
236249
<label class="toggle-label">
237250
<input type="checkbox" id="hard-mode-toggle-settings" />
238251
<span class="toggle-custom"></span>
239-
Hard Mode (blur the text)
252+
Hard Mode (blur what you have typed)
240253
</label>
241254
<label class="toggle-label">
242255
<input type="checkbox" id="ghost-mode-toggle" />

‎package-lock.json‎

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
},
1515
"license": "Apache-2.0",
1616
"devDependencies": {
17+
"@axe-core/playwright": "^4.14.0",
1718
"@eslint/js": "^9.17.0",
1819
"@playwright/test": "^1.64.1",
1920
"eslint": "^9.17.0",

‎script.js‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
// --- DOM Elements ---
66
const quoteDisplayElement = document.getElementById('quote-display');
7+
const quoteTextElement = document.getElementById('quote-text');
8+
const typingContainer = document.querySelector('.typing-container');
79
const typingInputElement = document.getElementById('typing-input');
810
const timerElement = document.getElementById('timer');
911
const wpmElement = document.getElementById('wpm');
@@ -276,6 +278,7 @@ function getQuoteText() {
276278
function renderNewQuote() {
277279
runtime.quoteText = getQuoteText();
278280
quoteDisplayElement.textContent = '';
281+
quoteTextElement.textContent = runtime.quoteText;
279282

280283
if (gameState.isGhostEnabled) {
281284
const ghostCursor = document.createElement('div');
@@ -590,7 +593,9 @@ function saveSettings() {
590593

591594
function setActivePill(selector, value) {
592595
selector.querySelectorAll('.pill').forEach((pill) => {
593-
pill.classList.toggle('active', pill.dataset.value === value);
596+
const active = pill.dataset.value === value;
597+
pill.classList.toggle('active', active);
598+
pill.setAttribute('aria-pressed', String(active));
594599
});
595600
}
596601

@@ -697,23 +702,47 @@ function trapFocus(e, modal) {
697702
}
698703
}
699704

705+
// Show the "press any key" hint while nothing would receive typing. Runs
706+
// after focus has settled, so opening a dialog does not flash it.
707+
function updateFocusHint() {
708+
const lost =
709+
document.activeElement !== typingInputElement &&
710+
!isModalOpen(settingsModal) &&
711+
!isModalOpen(resultModal);
712+
typingContainer.classList.toggle('unfocused', lost);
713+
}
714+
715+
// A printable key pressed while focus is elsewhere goes to the input. Form
716+
// controls keep their own keys, and Space still presses a focused button.
717+
function refocusOnType(e) {
718+
if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return;
719+
const active = document.activeElement;
720+
if (active === typingInputElement || typingInputElement.disabled) return;
721+
if (active && ['INPUT', 'SELECT', 'TEXTAREA'].includes(active.tagName)) return;
722+
if (e.key === ' ' && active && active.tagName === 'BUTTON') return;
723+
typingInputElement.focus();
724+
}
725+
700726
function handleKeydown(e) {
701727
if (e.key === 'Escape') {
702728
if (isModalOpen(settingsModal)) closeSettings();
703729
else if (isModalOpen(resultModal)) resetGame();
704730
return;
705731
}
706-
if (e.key !== 'Tab') return;
707-
708-
if (isModalOpen(settingsModal)) {
732+
if (e.key === 'Tab' && isModalOpen(settingsModal)) {
709733
trapFocus(e, settingsModal);
710734
return;
711735
}
712-
if (isModalOpen(resultModal)) {
736+
if (e.key === 'Tab' && isModalOpen(resultModal)) {
713737
trapFocus(e, resultModal);
714738
return;
715739
}
716740

741+
if (e.key !== 'Tab') {
742+
if (!isModalOpen(settingsModal) && !isModalOpen(resultModal)) refocusOnType(e);
743+
return;
744+
}
745+
717746
// Tab restarts only while typing. Elsewhere, and for Shift+Tab, it
718747
// moves focus as usual so the page stays keyboard-navigable.
719748
if (e.shiftKey || document.activeElement !== typingInputElement) return;
@@ -731,6 +760,8 @@ function init() {
731760
typingInputElement.addEventListener('mousedown', handleInputMousedown);
732761
typingInputElement.addEventListener('select', pinCaretToEnd);
733762
typingInputElement.addEventListener('focus', pinCaretToEnd);
763+
typingInputElement.addEventListener('focus', updateFocusHint);
764+
typingInputElement.addEventListener('blur', () => setTimeout(updateFocusHint, 0));
734765
typingInputElement.addEventListener('paste', (e) => e.preventDefault());
735766
typingInputElement.addEventListener('drop', (e) => e.preventDefault());
736767
resetButton.addEventListener('click', resetGame);

‎style.css‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515

1616
/* Text Colors */
1717
--text-main: #e2e8f0;
18-
--text-muted: #64748b;
19-
--text-ghost: #475569;
18+
--text-muted: #94a3b8; /* 5.5:1 on the lightest part of the container */
19+
--text-ghost: #8b9bb4; /* untyped text, 5:1 */
2020

2121
/* Accents */
2222
--accent-primary: #818cf8; /* Indigo-400 */
23+
--accent-strong: #4f46e5; /* Indigo-600, button fill with white text at 6.3:1 */
24+
--accent-strong-hover: #4338ca; /* Indigo-700 */
2325
--accent-glow: rgba(129, 140, 248, 0.5);
2426
--accent-secondary: #2dd4bf; /* Teal-400 */
2527

@@ -359,8 +361,10 @@ input:checked + .toggle-custom::after {
359361
transition: filter 0.3s ease;
360362
}
361363

362-
.quote-display.blurred {
363-
filter: blur(5px);
364+
/* Hard Mode: blur what has been typed, so mistakes cannot be seen. */
365+
.quote-display.blurred .correct,
366+
.quote-display.blurred .incorrect {
367+
filter: blur(4px);
364368
}
365369

366370
/* Text Effects */
@@ -420,6 +424,25 @@ input:checked + .toggle-custom::after {
420424
resize: none;
421425
}
422426

427+
/* Shown over the passage when the input does not have focus */
428+
.focus-hint {
429+
position: absolute;
430+
inset: 0;
431+
display: none;
432+
align-items: center;
433+
justify-content: center;
434+
font-size: 1rem;
435+
color: var(--text-main);
436+
background: rgba(15, 23, 42, 0.75);
437+
border-radius: var(--radius-md);
438+
pointer-events: none; /* Clicks reach the input underneath */
439+
z-index: 6;
440+
}
441+
442+
.typing-container.unfocused .focus-hint {
443+
display: flex;
444+
}
445+
423446
/* Ghost Cursor */
424447
.ghost-cursor {
425448
position: absolute;
@@ -621,12 +644,12 @@ footer {
621644
}
622645

623646
.action-btn.primary {
624-
background: var(--accent-primary);
647+
background: var(--accent-strong);
625648
color: white;
626649
}
627650

628651
.action-btn.primary:hover {
629-
background: var(--accent-glow);
652+
background: var(--accent-strong-hover);
630653
box-shadow: 0 0 15px var(--accent-glow);
631654
}
632655

0 commit comments

Comments
 (0)