Files
typo/app.js

160 lines
5.4 KiB
JavaScript
Raw Permalink Normal View History

Initial build: typo, a typing test built from your own code No backend, no build step -- static HTML/CSS/JS, themed identically to flit/wisp (same CSS vars, same "> " title prefix, same dark/mono/green aesthetic). Snippets are real, shipped code pulled from six repos in this family (flit, wisp, keep, stash, delve-term, goonk) rather than generic pangrams. Stats: WPM = (correct chars / 5) / minutes elapsed, timing from first keystroke not page load. Accuracy = correct keystrokes / total keystrokes tracked at the keydown level (comparing the pressed key against the exact next expected character before it's inserted), so backspaced-over mistakes still count against it rather than only diffing the final text. Keys/sec updates live via a 200ms tick. Tabs in source are rendered as two spaces so players never need to press Tab -- sidesteps a browser default-Tab-moves-focus fight entirely rather than fighting it with preventDefault. Verified via simulated typing sessions (puppeteer), not just static review -- both an artificially fast pass (confirms the math is internally consistent: 308 correct/310 total keystrokes -> 99%, matches displayed) and a realistic ~60wpm-paced pass (confirms no unit-conversion bug that only a slow, deliberate typist would hit tests-at-lightspeed wouldn't surface). That process caught two real bugs before they'd have hit a real player: 1. A stray `tabindex="0"` on the code display let the browser's native click-to-focus behavior steal focus away from the hidden textarea before our own focus() call ran -- typing silently did nothing. Fixed by removing the tabindex and switching to a mousedown+preventDefault handler instead of click. 2. The help modal's CSS set `display: flex` unconditionally, which (author CSS always beats the UA stylesheet) overrode the `hidden` attribute's default `display: none` -- the modal was invisibly covering the entire viewport and intercepting every click, at all times, whether or not it had been opened. Fixed with an explicit `.modal-overlay:not([hidden])` rule. Also verified the actual Docker image builds and serves correctly (nginx, no build stage needed since there's nothing to compile). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 14:12:44 +02:00
(() => {
const sourceLine = document.getElementById('source-line');
const codeDisplay = document.getElementById('code-display');
const input = document.getElementById('typing-input');
const statWpm = document.getElementById('stat-wpm');
const statAcc = document.getElementById('stat-acc');
const statKps = document.getElementById('stat-kps');
const statTime = document.getElementById('stat-time');
const restartBtn = document.getElementById('restart-btn');
const gameCard = document.getElementById('game-card');
const helpBtn = document.getElementById('help-btn');
const closeHelp = document.getElementById('close-help');
const helpModal = document.getElementById('help-modal');
let snippets = [];
let target = '';
let startTime = null;
let finished = false;
let totalKeystrokes = 0;
let correctKeystrokes = 0;
let tickHandle = null;
let resultBanner = null;
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function pickSnippet() {
return snippets[Math.floor(Math.random() * snippets.length)];
}
function loadSnippet() {
const s = pickSnippet();
// Tabs shown as two spaces so the player never needs to press Tab --
// a plain textarea's default Tab behavior is "move focus," and
// preventDefault-ing that just to insert a literal tab is more
// fragile (IME/mobile edge cases) than sidestepping it entirely.
target = s.code.replace(/\t/g, ' ');
sourceLine.innerHTML = `typing: <span class="accent">${escapeHtml(s.repo)}/${escapeHtml(s.file)}</span>`;
startTime = null;
finished = false;
totalKeystrokes = 0;
correctKeystrokes = 0;
input.value = '';
if (resultBanner) { resultBanner.remove(); resultBanner = null; }
render();
input.disabled = false;
input.focus();
stopTick();
startTick();
}
function render() {
const typed = input.value;
let html = '';
for (let i = 0; i < target.length; i++) {
const targetChar = target[i] === '\n' ? '\n' : target[i];
let cls = 'char-pending';
if (i < typed.length) {
cls = typed[i] === target[i] ? 'char-correct' : 'char-incorrect';
} else if (i === typed.length) {
cls = 'char-pending char-cursor';
}
html += `<span class="${cls}">${escapeHtml(targetChar)}</span>`;
}
codeDisplay.innerHTML = html;
updateStats();
}
function elapsedSeconds() {
if (startTime === null) return 0;
return (performance.now() - startTime) / 1000;
}
function updateStats() {
const typed = input.value;
let correctSoFar = 0;
for (let i = 0; i < typed.length && i < target.length; i++) {
if (typed[i] === target[i]) correctSoFar++;
}
const secs = elapsedSeconds();
const mins = secs / 60;
const wpm = mins > 0 ? Math.round((correctSoFar / 5) / mins) : 0;
const acc = totalKeystrokes > 0 ? Math.round((correctKeystrokes / totalKeystrokes) * 100) : 100;
const kps = secs > 0 ? (totalKeystrokes / secs) : 0;
statWpm.textContent = wpm;
statAcc.textContent = `${acc}%`;
statKps.textContent = kps.toFixed(1);
statTime.textContent = `${secs.toFixed(1)}s`;
}
function startTick() {
tickHandle = setInterval(() => { if (!finished) updateStats(); }, 200);
}
function stopTick() {
if (tickHandle) clearInterval(tickHandle);
tickHandle = null;
}
function finish() {
finished = true;
input.disabled = true;
stopTick();
updateStats();
resultBanner = document.createElement('div');
resultBanner.className = 'result-banner';
resultBanner.textContent = `Done — ${statWpm.textContent} wpm, ${statAcc.textContent} accuracy, ${statTime.textContent}. Nice.`;
gameCard.insertBefore(resultBanner, gameCard.querySelector('.actions'));
}
input.addEventListener('keydown', (e) => {
if (finished) return;
const isBackspace = e.key === 'Backspace';
const isNewline = e.key === 'Enter';
const isChar = e.key.length === 1;
if (!isBackspace && !isNewline && !isChar) return; // ignore Shift/Control/Arrow/Tab/etc
if (startTime === null && !isBackspace) startTime = performance.now();
totalKeystrokes++;
if (!isBackspace) {
const expected = target[input.value.length];
const pressed = isNewline ? '\n' : e.key;
if (expected !== undefined && pressed === expected) correctKeystrokes++;
}
});
input.addEventListener('input', () => {
render();
if (input.value === target) finish();
});
// mousedown + preventDefault, not click -- a click handler runs after the
// browser's own default-focus behavior has already fired, which was
// stealing focus away from the hidden textarea whenever this had a
// native tabindex. preventDefault on mousedown stops that entirely, so
// our explicit focus() call is the only thing that happens.
codeDisplay.addEventListener('mousedown', (e) => {
e.preventDefault();
if (!finished) input.focus();
});
restartBtn.addEventListener('click', loadSnippet);
helpBtn.addEventListener('click', () => { helpModal.hidden = false; });
closeHelp.addEventListener('click', () => { helpModal.hidden = true; });
helpModal.addEventListener('click', (e) => { if (e.target === helpModal) helpModal.hidden = true; });
fetch('snippets.json')
.then(r => r.json())
.then(data => {
snippets = data;
loadSnippet();
})
.catch(() => {
sourceLine.textContent = 'Could not load snippets.';
});
})();