import { TypingGame } from '/game.js';
import { generateWords, normalizePassage } from '/words.js';
const $ = id => document.getElementById(id);
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
const now = () => performance.now();
const pretty = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value))) : '0';
const round1 = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value) * 10) / 10) : '0';
const modeNames = {'30':'30 seconds','60':'60 seconds','50':'50 words','endless':'endless','tempo':'tempo'};
const readLocal = (key, fallback) => { try { return JSON.parse(localStorage.getItem('typy.' + key)) ?? fallback; } catch { return fallback; } };
const writeLocal = (key, value) => { try { localStorage.setItem('typy.' + key, JSON.stringify(value)); } catch {} };
const settings = {...{advance:'space',preview:true}, ...readLocal('settings', {})};
const bests = readLocal('bests', {});
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
let selectedMode = '30';
let source = {kind:'common'};
let passage = [];
let game = null;
let ghost = null;
let view = 'play';
let currentResult = null;
let shareId = null;
let shareIntent = 'result';
let currentPath = '/';
let routeSerial = 0;
let lastWordKey = '';
let lastWordIndex = -1;
let renderedWord = '';
let wordSpans = [];
let overflowSpan = null;
let wordLayoutDirty = true;
let errorAnimation = null;
let lastClockPaint = 0;
let lastErrorCount = 0;
let replay = {playing:false,position:0,anchor:0,anchorPosition:0};
let toastTimer;
let transitioning = false;
let isComposing = false;
function setText(id, value) {
const node = $(id), text = String(value);
if (node.textContent !== text) node.textContent = text;
}
function setClass(node, name, enabled) {
if (node.classList.contains(name) !== enabled) node.classList.toggle(name, enabled);
}
function toast(message) {
clearTimeout(toastTimer);
$('toast').textContent = message;
$('toast').hidden = false;
toastTimer = setTimeout(() => { $('toast').hidden = true; }, 4500);
}
async function api(path, options = {}) {
const response = await fetch(path, {...options, headers:{...(options.body ? {'Content-Type':'application/json'} : {}), ...(options.headers || {})}});
let body;
try { body = await response.json(); } catch { throw new Error('The server could not answer. Please try again.'); }
if (!response.ok) {
const message = typeof body.error === 'string' ? body.error : body.error?.message || body.message;
const error = new Error(message || 'Something interrupted that request. Please try again.');
error.status = response.status;
throw error;
}
return body;
}
function sourceName() {
if (ghost) return 'racing ' + (currentResult?.name || 'a friendly ghost');
if (source.kind === 'topic') return (source.topic || '').replaceAll('-', ' ');
if (source.kind === 'daily') return 'the daily · ' + (source.day || 'today');
if (source.kind === 'paste') return 'your own words';
return 'common words';
}
function localBestKey() { return selectedMode; }
function updateOptions() {
const locked = source.kind === 'daily' || Boolean(ghost);
const effectiveAdvance = ghost ? currentResult.run.advance : settings.advance;
$('mode-switch').classList.toggle('locked', locked);
document.querySelectorAll('[data-mode]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.mode === selectedMode));
button.disabled = locked || view === 'replay';
button.title = locked ? (ghost ? 'A race uses the original run’s mode.' : 'The daily is a shared 60-second run.') : '';
});
$('source-label').textContent = sourceName();
$('advance-hint').innerHTML = selectedMode === 'tempo' ? 'finish before the beat' : effectiveAdvance === 'letter' ? 'last letter → next word' : 'space next word';
if ($('upcoming').hidden === settings.preview) wordLayoutDirty = true;
$('upcoming').hidden = !settings.preview;
$('previous-context').hidden = !settings.preview || !$('previous-word').textContent;
$('advance-setting').value = effectiveAdvance;
$('advance-setting').disabled = Boolean(ghost);
$('preview-setting').checked = settings.preview;
$('footer-board').hidden = source.kind !== 'daily';
$('daily-link').setAttribute('aria-current', source.kind === 'daily' ? 'page' : 'false');
$('best-score').textContent = bests[localBestKey()] ? 'device best ' + pretty(bests[localBestKey()]) + ' wpm' : '';
}
function focusTyping() {
if (view !== 'play' || document.querySelector('dialog[open]')) return;
$('typing-input').focus({preventScroll:true});
}
function newCommonPassage() {
const words = generateWords(300);
words[0] = 'breathe';
return words;
}
function beginRun({freshWords = false, focus = false} = {}) {
transitioning = false;
if (freshWords && source.kind === 'common' && !ghost) passage = newCommonPassage();
if (!passage.length) passage = newCommonPassage();
game = new TypingGame({words:passage, mode:selectedMode, advance:ghost ? currentResult.run.advance : settings.advance, paceWpm:readLocal('lastAverage', 40)});
view = 'play';
replay.playing = false;
lastWordKey = '';
lastWordIndex = -1;
renderedWord = '';
wordLayoutDirty = true;
lastErrorCount = 0;
$('load-panel').hidden = true;
$('play-panel').hidden = false;
$('result-panel').hidden = true;
$('replay-controls').hidden = true;
$('input-row').hidden = false;
$('typing-input').value = '';
$('typing-input').disabled = false;
$('ghost-race').hidden = !ghost;
$('finish-run').hidden = selectedMode !== 'endless' && selectedMode !== 'tempo';
$('tempo-track').hidden = selectedMode !== 'tempo';
$('restart-tip').hidden = false;
updateOptions();
renderGame(game.snapshot(now()));
if (focus || innerWidth > 700) focusTyping();
}
function drawWord(word, buffer, index, nextWord, flags = null, previousWord = '') {
const key = word + '|' + buffer + '|' + index + '|' + (flags?.join('') || '');
if (key === lastWordKey && !wordLayoutDirty) return;
const node = $('word');
const typed = Array.from(buffer || '');
const wordChanged = index !== lastWordIndex || word !== renderedWord;
if (wordChanged) {
// The next target is readable immediately. Animation must never consume
// part of the interval in which the player is already typing this word.
errorAnimation?.cancel();
const chars = Array.from(word || '');
node.classList.toggle('long', chars.length > 12);
node.classList.toggle('very-long', chars.length > 22);
wordSpans = chars.map(char => {
const span = document.createElement('span');
span.textContent = char;
return span;
});
overflowSpan = document.createElement('span');
overflowSpan.className = 'overflow';
overflowSpan.setAttribute('aria-hidden', 'true');
overflowSpan.hidden = true;
node.replaceChildren(...wordSpans, overflowSpan);
node.setAttribute('aria-label', 'Word to type: ' + word);
node.dataset.word = word;
$('typing-input').setAttribute('aria-label', 'Type the word: ' + word);
setText('word-number', String(index + 1).padStart(3, '0'));
setText('next-word', nextWord || '');
setText('previous-word', previousWord);
$('previous-context').hidden = !settings.preview || !previousWord;
wordLayoutDirty = true;
}
wordSpans.forEach((span, i) => {
let state = '';
if (i < typed.length) state = (flags ? flags[i] : typed[i] === span.textContent) ? 'correct' : 'incorrect';
if (i === typed.length) state += ' cursor';
if (span.className !== state) span.className = state;
});
const overflow = Math.max(0, typed.length - wordSpans.length);
if (overflowSpan.hidden !== !overflow) overflowSpan.hidden = !overflow;
if (overflow && overflowSpan.textContent !== '+' + overflow) overflowSpan.textContent = '+' + overflow;
// Fit a long pasted word once, on word/viewport changes. Letter feedback does
// not change glyph widths and must not force a layout read on every key.
if (wordLayoutDirty) {
node.style.fontSize = '';
const stageWidth = $('stage').clientWidth;
const width = node.scrollWidth;
const style = getComputedStyle(node);
const natural = parseFloat(style.fontSize);
const tracking = parseFloat(style.letterSpacing) / natural;
// The active word stays at the page center. Context words only use the
// remaining space on either side, without shifting or shrinking the target.
const anchor = .5;
const available = Math.max(1, stageWidth - 28);
const size = width > available ? Math.max(8, natural * available / width * .96) : natural;
if (size !== natural) node.style.fontSize = size + 'px';
$('word-pair').style.left = anchor * 100 + '%';
const gap = Math.max(12, size * .45);
const contextWidth = Math.max(0, stageWidth / 2 - width * size / natural / 2 - gap - 14) + 'px';
for (const id of ['previous-context', 'upcoming']) {
const context = $(id);
context.style.fontSize = size + 'px';
context.style.letterSpacing = (Number.isFinite(tracking) ? tracking * size : 0) + 'px';
context.style.width = contextWidth;
context.style[id === 'previous-context' ? 'right' : 'left'] = 'calc(100% + ' + gap + 'px)';
}
wordLayoutDirty = false;
}
lastWordKey = key;
lastWordIndex = index;
renderedWord = word;
}
function renderGame(state) {
const previous = state.wordIndex > 0 ? game.words[(state.wordIndex - 1) % game.words.length] : '';
drawWord(state.word, state.buffer, state.wordIndex, state.nextWord, null, previous);
setText('live-wpm', pretty(state.wpm));
setText('live-accuracy', pretty(state.accuracy));
setClass($('status-dot'), 'active', state.status === 'running');
if (state.status === 'ready') setText('run-status', ghost ? 'your ghost is ready' : source.kind === 'daily' ? 'same words, your rhythm' : 'a fresh start');
else if (state.errorIndex >= 0) setText('run-status', 'a little backspace, then onward');
else if (state.waitingForBeat) setText('run-status', 'right on time · wait for the beat');
else setText('run-status', state.streak >= 3 ? state.streak + ' clean words in a row' : 'one word, then the next');
const hintStart = $('run-hint').querySelector('.hint-start');
const hint = state.status === 'ready' ? 'type the word to begin' : state.errorIndex >= 0 ? 'backspace to correct' : state.waitingForBeat ? 'hold that rhythm' : 'keep your rhythm';
if (hintStart.textContent !== hint) hintStart.textContent = hint;
if (selectedMode === '30' || selectedMode === '60') {
setText('time-label', 'remaining'); setText('live-time', Math.ceil(state.remainingMs / 1000)); setText('time-unit', 's');
} else if (selectedMode === '50') {
setText('time-label', 'remaining'); setText('live-time', Math.max(0, 50 - state.completedWords)); setText('time-unit', 'words');
} else if (selectedMode === 'tempo') {
setText('time-label', 'beat pace'); setText('live-time', pretty(state.paceWpm)); setText('time-unit', 'wpm');
} else {
setText('time-label', 'in the flow'); setText('live-time', Math.floor(state.elapsedMs / 1000)); setText('time-unit', 's');
}
if (selectedMode === 'tempo') {
const fraction = state.beatDurationMs ? Math.max(0, state.beatRemainingMs / state.beatDurationMs) : 1;
$('tempo-progress').style.transform = 'scaleX(' + fraction + ')';
setText('tempo-caption', state.status === 'ready' ? 'type to catch the first beat' : state.waitingForBeat ? 'got it. the next beat is coming.' : 'finish the word before the line runs out');
Array.from($('tempo-lives').children).forEach((dot, i) => setClass(dot, 'lost', i < state.misses));
const label = (3 - state.misses) + ' missed beats remaining';
if ($('tempo-lives').getAttribute('aria-label') !== label) $('tempo-lives').setAttribute('aria-label', label);
}
if (ghost) {
const completed = ghost.wordTimings.filter(timing => timing.completed && timing.end <= state.elapsedMs).length;
setText('race-you', state.completedWords);
setText('race-them', completed);
const difference = state.completedWords - completed;
setText('race-delta', state.status === 'ready' ? '' : difference === 0 ? 'neck and neck' : Math.abs(difference) + (difference > 0 ? ' ahead' : ' behind'));
}
if (state.errors > lastErrorCount && !reducedMotion) {
errorAnimation?.cancel();
errorAnimation = $('word').animate([{transform:'translateX(0)'},{transform:'translateX(-3px)'},{transform:'translateX(3px)'},{transform:'translateX(0)'}], {duration:90});
}
lastErrorCount = state.errors;
lastClockPaint = now();
if (state.status === 'finished' && !transitioning) finishDisplay();
}
function press(key) {
if (view !== 'play' || !game || document.querySelector('dialog[open]')) return;
renderGame(game.press(key, now()));
}
function finishDisplay() {
transitioning = true;
const run = game.result(now());
const previousBest = Number(bests[selectedMode] || 0);
const qualifies = run.finishedReason !== 'manual' || selectedMode === 'endless' || selectedMode === 'tempo';
const isBest = qualifies && run.stats.completedWords > 0 && run.stats.wpm > previousBest;
if (isBest) { bests[selectedMode] = run.stats.wpm; writeLocal('bests', bests); }
if (run.durationMs >= 5000 && run.stats.wpm > 0) writeLocal('lastAverage', Math.max(10, Math.min(200, run.stats.wpm)));
const ghostStats = ghost?.stats;
currentResult = {run,source:{...source},name:'',local:true,isBest,ghostStats};
shareId = null;
ghost = null;
$('typing-input').blur();
displayResult(currentResult);
}
function displayResult(result) {
view = 'result';
replay.playing = false;
currentResult = result;
const run = result.run;
const stats = run.stats;
selectedMode = run.mode;
source = result.source || {kind:'common'};
passage = [...run.words];
$('load-panel').hidden = true; $('play-panel').hidden = true; $('result-panel').hidden = false;
$('result-kicker').textContent = (result.name ? result.name + ' · ' : '') + modeNames[run.mode] + (run.finishedReason === 'manual' && ['30','60','50'].includes(run.mode) ? ' · finished early' : ', well spent');
$('result-title').textContent = result.ghostStats ? (stats.wpm > result.ghostStats.wpm ? 'a little ahead of the ghost.' : 'a good race. another round?') : 'there’s your rhythm.';
$('result-wpm').textContent = pretty(stats.wpm);
$('result-accuracy').textContent = round1(stats.accuracy) + '%';
$('result-streak').textContent = stats.bestStreak + (stats.bestStreak === 1 ? ' word' : ' words');
$('result-words').textContent = stats.completedWords;
$('new-best').hidden = !result.isBest;
$('again-button').innerHTML = result.local ? 'go again ' : 'try these words ';
$('share-button').innerHTML = shareId ? 'copy result ' : 'share result ';
$('race-button').textContent = result.local ? 'challenge a friend →' : 'race this →';
$('replay-button').disabled = !run.events?.length;
$('share-button').disabled = !run.events?.length || run.replayTruncated;
$('race-button').disabled = !run.events?.length || run.replayTruncated;
$('board-open').hidden = source.kind !== 'daily';
if (run.replayTruncated) $('result-note').textContent = 'That was a long flow. Your score is complete; the replay holds the most recent portion, so this run cannot be shared.';
else if (source.kind === 'daily' && result.local) $('result-note').textContent = 'Share your result to add it to today’s board. A name is optional.';
else if (result.ghostStats) $('result-note').textContent = 'Your ghost finished at ' + pretty(result.ghostStats.wpm) + ' wpm. Race links always use the same passage and mode.';
else $('result-note').textContent = result.local ? 'Your result is on this device. Share it when you’re ready.' : 'Watch the original timing, or try the same words against this ghost.';
const slowest = stats.slowest?.slice(0, 3) || [];
$('slow-words').innerHTML = slowest.length ? slowest.map(row => '' + escapeHTML(row.word) + '' + round1(row.ms / 1000) + 's').join('') : 'your next run starts here';
const points = stats.sparkline || [];
if (points.length) {
const max = Math.max(20, ...points);
const coords = points.map((value, i) => [(i / Math.max(1, points.length - 1)) * 896 + 2, 83 - (Math.min(value, max) / max) * 70]);
const path = coords.map((point, i) => (i ? 'L' : 'M') + point[0].toFixed(1) + ',' + point[1].toFixed(1)).join(' ');
$('speed-chart').innerHTML = '
finding today’s rhythms…
'; try { const response = await api('/api/board?day=' + encodeURIComponent(day)); const entries = response.entries || []; $('board-content').innerHTML = entries.length ? 'No shared runs yet. Set today’s rhythm.
'; } catch (error) { $('board-content').innerHTML = '' + escapeHTML(error.message) + '
'; } } function openBoard() { $('board-dialog').showModal(); loadBoard(); } function topicRoute(topic) { const slug = topic.trim().toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 64).replace(/-$/, ''); if (!slug || ['api','r','race','daily'].includes(slug)) { toast('Try a few words about a topic, such as “the deep sea”.'); return; } route('/' + slug); } function restart({freshWords = false} = {}) { if (view === 'loading') return; if (view === 'result' && !currentResult?.local) { source = {...currentResult.source}; passage = [...currentResult.run.words]; selectedMode = currentResult.run.mode; } if (view === 'replay') replay.playing = false; // A local retry gets a clean address instead of replacing somebody else's result. if (view !== 'play') { const target = source.kind === 'daily' ? '/daily' : source.kind === 'topic' ? '/' + source.topic : '/'; history.replaceState({}, '', target); currentPath = target; shareId = null; } beginRun({freshWords,focus:true}); } document.addEventListener('keydown', event => { if (event.isComposing || event.ctrlKey || event.metaKey || event.altKey) return; if (document.querySelector('dialog[open]')) return; const target = event.target; if (target !== $('typing-input') && target.closest('input,textarea,select,[contenteditable=true]')) return; // Enter/Space must activate a focused control before they can be game // shortcuts. This keeps every toolbar and result action keyboard-accessible. if (target.closest('button,a') && (event.key === 'Enter' || event.key === ' ')) return; if (event.key === 'Escape') { event.preventDefault(); restart(); return; } if (view === 'result' && event.key === 'Enter') { event.preventDefault(); restart({freshWords:source.kind === 'common'}); return; } if (view !== 'play') return; if (event.key === 'Enter' && (selectedMode === 'endless' || selectedMode === 'tempo') && game.status === 'running') { event.preventDefault(); game.finish(now()); finishDisplay(); return; } if (target !== $('typing-input') && target.closest('button,a')) return; if (event.key === 'Backspace' || Array.from(event.key).length === 1) { event.preventDefault(); press(event.key); } }); const input = $('typing-input'); input.addEventListener('compositionstart', () => { isComposing = true; }); input.addEventListener('compositionend', event => { isComposing = false; const text = event.data || input.value; input.value = ''; for (const char of text) press(char); }); input.addEventListener('beforeinput', event => { if (event.inputType === 'deleteContentBackward') { event.preventDefault(); press('Backspace'); } if (event.inputType === 'insertFromPaste') { event.preventDefault(); toast('Paste a passage in the words menu, then type it here.'); } }); input.addEventListener('input', event => { if (isComposing || event.isComposing) return; const value = input.value; input.value = ''; for (const char of value) press(char); }); input.addEventListener('paste', event => { event.preventDefault(); toast('Use the words menu to choose a pasted passage.'); }); $('stage').addEventListener('click', focusTyping); $('finish-run').addEventListener('click', () => { if (game?.status === 'running') { game.finish(now()); finishDisplay(); } else toast('Type the first word to start your run.'); }); document.querySelectorAll('[data-mode]').forEach(button => button.addEventListener('click', () => { selectedMode = button.dataset.mode; if (source.kind === 'daily' || ghost || view === 'result') { ghost = null; source = {kind:'common'}; passage = newCommonPassage(); currentResult = null; history.pushState({}, '', '/'); currentPath = '/'; } beginRun({freshWords:source.kind === 'common',focus:true}); })); $('settings-open').addEventListener('click', () => { updateOptions(); $('settings-dialog').showModal(); }); $('settings-done').addEventListener('click', () => { const changed = settings.advance !== $('advance-setting').value || settings.preview !== $('preview-setting').checked; if (!ghost) settings.advance = $('advance-setting').value; settings.preview = $('preview-setting').checked; writeLocal('settings',settings); $('settings-dialog').close(); if (changed && view === 'play') beginRun({focus:true}); else { updateOptions(); if (changed && view === 'replay') renderReplay(replay.position); } }); $('source-open').addEventListener('click', () => $('source-dialog').showModal()); ['topic','paste','common'].forEach(tab => $('tab-' + tab).addEventListener('click', () => { ['topic','paste','common'].forEach(id => { $('tab-' + id).setAttribute('aria-selected',String(id === tab)); $('source-' + id).hidden = id !== tab; }); if (tab === 'topic') $('topic-input').focus(); else if (tab === 'paste') $('paste-input').focus(); })); $('source-topic').addEventListener('submit', event => { event.preventDefault(); topicRoute($('topic-input').value); }); document.querySelectorAll('[data-topic]').forEach(button => button.addEventListener('click', () => topicRoute(button.dataset.topic))); $('paste-input').addEventListener('input', () => { const count = normalizePassage($('paste-input').value).length; $('paste-count').textContent = count + ' / 300 words'; }); $('source-paste').addEventListener('submit', event => { event.preventDefault(); const words = normalizePassage($('paste-input').value); if (!words.length) { toast('Add a few words first.'); return; } routeSerial++; source = {kind:'paste'}; passage = words; ghost = null; currentResult = null; shareId = null; $('source-dialog').close(); history.pushState({}, '', '/'); currentPath = '/'; beginRun({focus:true}); }); $('common-start').addEventListener('click', () => route('/')); $('load-common').addEventListener('click', () => route('/')); $('load-retry').addEventListener('click', () => route(currentPath,false)); $('help-open').addEventListener('click', () => $('help-dialog').showModal()); $('help-done').addEventListener('click', () => { $('help-dialog').close(); focusTyping(); }); $('again-button').addEventListener('click', () => restart({freshWords:source.kind === 'common'})); $('replay-button').addEventListener('click', startReplay); $('replay-back').addEventListener('click', () => displayResult(currentResult)); $('replay-play').addEventListener('click', () => { if (replay.position >= currentResult.run.durationMs) replay.position = 0; replay.playing = !replay.playing; replay.anchor = now(); replay.anchorPosition = replay.position; $('replay-play').textContent = replay.playing ? 'pause' : 'play'; }); $('replay-scrubber').addEventListener('input', () => { replay.position = Number($('replay-scrubber').value); replay.anchor = now(); replay.anchorPosition = replay.position; renderReplay(replay.position); }); $('share-button').addEventListener('click', () => openShare('result')); $('race-button').addEventListener('click', () => { if (currentResult?.local) openShare('race'); else if (shareId) route('/race/' + shareId); }); $('save-result').addEventListener('click', saveResult); $('copy-link').addEventListener('click', copyShareLink); $('share-url').addEventListener('click', () => $('share-url').select()); $('board-open').addEventListener('click', openBoard); $('footer-board').addEventListener('click', openBoard); $('board-refresh').addEventListener('click', loadBoard); document.querySelectorAll('dialog').forEach(dialog => { dialog.addEventListener('click', event => { if (event.target !== dialog) return; const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) dialog.close(); }); }); document.addEventListener('click', event => { const anchor = event.target.closest('a'); if (!anchor || anchor.target || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return; const url = new URL(anchor.href); if (url.origin !== location.origin) return; event.preventDefault(); route(url.pathname); }); window.addEventListener('popstate', () => route(location.pathname,false)); window.addEventListener('resize', () => { wordLayoutDirty = true; if (view === 'play' && game) renderGame(game.snapshot(now())); else if (view === 'replay') renderReplay(replay.position); }); function frame(time) { // Input renders synchronously. Only the clock needs periodic refreshes; an // idle stage does no DOM work, while tempo keeps its smooth beat indicator. if (view === 'play' && game?.status === 'running' && (selectedMode === 'tempo' || time - lastClockPaint >= 100)) renderGame(game.tick(time)); else if (view === 'replay' && replay.playing) { replay.position = Math.min(currentResult.run.durationMs, replay.anchorPosition + time - replay.anchor); renderReplay(replay.position); if (replay.position >= currentResult.run.durationMs) { replay.playing = false; $('replay-play').textContent = 'play again'; } } requestAnimationFrame(frame); } route(location.pathname,false); requestAnimationFrame(frame);