fix(typy): make word transitions immediate and fast typing forgiving
ci / test (pull_request) Successful in 53s

This commit is contained in:
teahubxyz committed 2026-10-24 23:27:50 +00:00
1 parent 5c8d0d9889
commit 7ef789f294
13 files changed
+1052 -62

No files matched your search

+5 -1
View File
@@ -8,7 +8,8 @@ One word at a time. Type it, find the next one, settle into a rhythm.
## Play
- 30 seconds, 60 seconds, 50 words, endless, and tempo.
- The first typed character starts the run. Backspace fixes mistakes. Space advances; settings can advance on the last correct letter instead.
- The first typed character starts the run. Backspace fixes mistakes. Space advances; settings can advance on the last correct letter instead. Automatic advancement tolerates one habitual Space immediately afterward without adding an error or extra output.
- Word changes are immediate and stationary. Letter feedback reuses the rendered glyphs; word fitting runs only when the word or viewport changes. Idle stages do no repeated DOM work, and the optional next-word preview sits close to the current target.
- Tempo waits for each word's beat, accelerates by 3.5% on success, and ends after three missed beats. Its starting pace comes from the device's last measured average.
- Common words are weighted by frequency. The words menu also accepts a topic or a pasted passage, capped at 300 words.
- Topic paths such as **/react-hooks** generate a short Venice passage and cache it for 30 days.
@@ -36,9 +37,12 @@ Optional browser acceptance uses Playwright and a Chromium executable:
CHROME=/path/to/chrome node test/browser-smoke.mjs
CHROME=/path/to/chrome node test/integration.mjs
CHROME=/path/to/chrome node test/responsiveness.mjs
The integration script uses the actual local backend, creates a test result, reloads its public link, checks the preview PNG, replays it, and opens the ghost race. Set TYPY_BASE_URL to test a deployment; it creates one clearly named test replay and does not submit a synthetic daily-board score.
The responsiveness script keeps normal motion enabled and checks immediate readable word transitions, idle rendering, fast spaced input in both advancement modes, and phone long-word layout. Engine tests also verify exact 100/160 WPM input streams in 30/60-second runs without changing the scoring formula.
The build bundles the HTML and static source into Worker ES modules. Commit both the authored files and generated **src/page.js** and **src/assets.js**. Deployment refuses mismatched generated files.
## Deployment
+342
View File
@@ -0,0 +1,342 @@
{
"url": "http://127.0.0.1:8789/",
"sourceHash": "392ce9b1a3b4e0f433ee09ff2b0491aed25bd038d2efc157a1545398756a182a",
"viewport": {
"width": 1440,
"height": 1000
},
"motion": "no-preference",
"phases": [
{
"phase": "ready-idle",
"durationMs": 1505.5,
"textWrites": 0,
"unchangedTextWrites": 0,
"innerWrites": 0,
"layoutReads": 0,
"layoutReadMs": 0,
"styleReads": 0,
"animations": 0,
"keyCount": 0,
"keyHandlerMedianMs": 0,
"keyHandlerP95Ms": 0,
"keyHandlerMaxMs": 0,
"keyToRafP95Ms": 0,
"frameCount": 91,
"framesOver25ms": 0,
"framesOver50ms": 0,
"firstKeyOpacity": [],
"byId": {},
"cdp": {
"LayoutCount": 0,
"LayoutDuration": 0,
"RecalcStyleCount": 0,
"RecalcStyleDuration": 0,
"ScriptDuration": 0.003116,
"TaskDuration": 0.016005
}
},
{
"phase": "native-typing",
"targetWpm": 100,
"displayWpm": "98",
"durationMs": 17136.600000000093,
"textWrites": 485,
"unchangedTextWrites": 0,
"innerWrites": 0,
"layoutReads": 56,
"layoutReadMs": 10.099999998230487,
"styleReads": 0,
"animations": 0,
"keyCount": 140,
"keyHandlerMedianMs": 0.20000000018626451,
"keyHandlerP95Ms": 0.7999999998137355,
"keyHandlerMaxMs": 1.2000000001862645,
"keyToRafP95Ms": 16.299999999813735,
"frameCount": 1029,
"framesOver25ms": 0,
"framesOver50ms": 0,
"firstKeyOpacity": [
{
"word": "a",
"opacity": 1
},
{
"word": "calm",
"opacity": 1
},
{
"word": "river",
"opacity": 1
},
{
"word": "moves",
"opacity": 1
},
{
"word": "through",
"opacity": 1
},
{
"word": "the",
"opacity": 1
},
{
"word": "city",
"opacity": 1
},
{
"word": "and",
"opacity": 1
},
{
"word": "a",
"opacity": 1
},
{
"word": "small",
"opacity": 1
},
{
"word": "bird",
"opacity": 1
},
{
"word": "follows",
"opacity": 1
},
{
"word": "the",
"opacity": 1
},
{
"word": "wind",
"opacity": 1
},
{
"word": "a",
"opacity": 1
},
{
"word": "calm",
"opacity": 1
},
{
"word": "river",
"opacity": 1
},
{
"word": "moves",
"opacity": 1
},
{
"word": "through",
"opacity": 1
},
{
"word": "the",
"opacity": 1
},
{
"word": "city",
"opacity": 1
},
{
"word": "and",
"opacity": 1
},
{
"word": "a",
"opacity": 1
},
{
"word": "small",
"opacity": 1
},
{
"word": "bird",
"opacity": 1
},
{
"word": "follows",
"opacity": 1
},
{
"word": "the",
"opacity": 1
},
{
"word": "wind",
"opacity": 1
}
],
"byId": {
"run-status": 27,
"SPAN": 113,
"live-wpm": 272,
"word-number": 28,
"next-word": 28,
"live-time": 17
},
"cdp": {
"LayoutCount": 301,
"LayoutDuration": 0.066462,
"RecalcStyleCount": 301,
"RecalcStyleDuration": 0.023637,
"ScriptDuration": 0.091447,
"TaskDuration": 0.605251
}
},
{
"phase": "native-typing",
"targetWpm": 160,
"displayWpm": "157",
"durationMs": 10791.30000000028,
"textWrites": 230,
"unchangedTextWrites": 0,
"innerWrites": 0,
"layoutReads": 56,
"layoutReadMs": 10.999999999068677,
"styleReads": 0,
"animations": 0,
"keyCount": 140,
"keyHandlerMedianMs": 0.19999999972060323,
"keyHandlerP95Ms": 0.7999999998137355,
"keyHandlerMaxMs": 1,
"keyToRafP95Ms": 16.600000000093132,
"frameCount": 647,
"framesOver25ms": 0,
"framesOver50ms": 0,
"firstKeyOpacity": [
{
"word": "a",
"opacity": 1
},
{
"word": "calm",
"opacity": 1
},
{
"word": "river",
"opacity": 1
},
{
"word": "moves",
"opacity": 1
},
{
"word": "through",
"opacity": 1
},
{
"word": "the",
"opacity": 1
},
{
"word": "city",
"opacity": 1
},
{
"word": "and",
"opacity": 1
},
{
"word": "a",
"opacity": 1
},
{
"word": "small",
"opacity": 1
},
{
"word": "bird",
"opacity": 1
},
{
"word": "follows",
"opacity": 1
},
{
"word": "the",
"opacity": 1
},
{
"word": "wind",
"opacity": 1
},
{
"word": "a",
"opacity": 1
},
{
"word": "calm",
"opacity": 1
},
{
"word": "river",
"opacity": 1
},
{
"word": "moves",
"opacity": 1
},
{
"word": "through",
"opacity": 1
},
{
"word": "the",
"opacity": 1
},
{
"word": "city",
"opacity": 1
},
{
"word": "and",
"opacity": 1
},
{
"word": "a",
"opacity": 1
},
{
"word": "small",
"opacity": 1
},
{
"word": "bird",
"opacity": 1
},
{
"word": "follows",
"opacity": 1
},
{
"word": "the",
"opacity": 1
},
{
"word": "wind",
"opacity": 1
}
],
"byId": {
"run-status": 27,
"SPAN": 113,
"word-number": 28,
"next-word": 28,
"live-wpm": 24,
"live-time": 10
},
"cdp": {
"LayoutCount": 168,
"LayoutDuration": 0.03789000000000001,
"RecalcStyleCount": 168,
"RecalcStyleDuration": 0.019742999999999997,
"ScriptDuration": 0.05216299999999999,
"TaskDuration": 0.38729800000000003
}
}
]
}
+358
View File
@@ -0,0 +1,358 @@
{
"url": "https://typy.yolopreneur.lol/",
"sourceHash": "0d508e9af9db923759286aefcbf7a2bd0c7ddf583a75488317c15685e3ba2128",
"viewport": {
"width": 1440,
"height": 1000
},
"motion": "no-preference",
"phases": [
{
"phase": "ready-idle",
"durationMs": 1505.8000000002794,
"textWrites": 630,
"unchangedTextWrites": 630,
"innerWrites": 0,
"layoutReads": 0,
"layoutReadMs": 0,
"styleReads": 0,
"animations": 0,
"keyCount": 0,
"keyHandlerMedianMs": 0,
"keyHandlerP95Ms": 0,
"keyHandlerMaxMs": 0,
"keyToRafP95Ms": 0,
"frameCount": 90,
"framesOver25ms": 0,
"framesOver50ms": 0,
"firstKeyOpacity": [],
"byId": {
"live-wpm": 90,
"live-accuracy": 90,
"run-status": 90,
"SPAN": 90,
"time-label": 90,
"live-time": 90,
"time-unit": 90
},
"cdp": {
"LayoutCount": 90,
"LayoutDuration": 0.016385,
"RecalcStyleCount": 90,
"RecalcStyleDuration": 0.002776,
"ScriptDuration": 0.009908,
"TaskDuration": 0.066637
}
},
{
"phase": "native-typing",
"targetWpm": 100,
"displayWpm": "98",
"durationMs": 17134.299999999814,
"textWrites": 8477,
"unchangedTextWrites": 7943,
"innerWrites": 140,
"layoutReads": 280,
"layoutReadMs": 33.799999991897494,
"styleReads": 28,
"animations": 56,
"keyCount": 140,
"keyHandlerMedianMs": 0.5,
"keyHandlerP95Ms": 0.8000000002793968,
"keyHandlerMaxMs": 1.2999999998137355,
"keyToRafP95Ms": 15.800000000279397,
"frameCount": 1027,
"framesOver25ms": 1,
"framesOver50ms": 0,
"firstKeyOpacity": [
{
"word": "a",
"opacity": 0.999415
},
{
"word": "calm",
"opacity": 0.819535
},
{
"word": "river",
"opacity": 0.819348
},
{
"word": "moves",
"opacity": 0.81933
},
{
"word": "through",
"opacity": 0.819312
},
{
"word": "the",
"opacity": 0.819339
},
{
"word": "city",
"opacity": 0.739275
},
{
"word": "and",
"opacity": 0.739571
},
{
"word": "a",
"opacity": 0.7395
},
{
"word": "small",
"opacity": 0.739039
},
{
"word": "bird",
"opacity": 0.73953
},
{
"word": "follows",
"opacity": 0.819312
},
{
"word": "the",
"opacity": 0.739172
},
{
"word": "wind",
"opacity": 0.819276
},
{
"word": "a",
"opacity": 0.738886
},
{
"word": "calm",
"opacity": 0.739449
},
{
"word": "river",
"opacity": 0.81924
},
{
"word": "moves",
"opacity": 0.819222
},
{
"word": "through",
"opacity": 0.819205
},
{
"word": "the",
"opacity": 0.73905
},
{
"word": "city",
"opacity": 0.819169
},
{
"word": "and",
"opacity": 0.739275
},
{
"word": "a",
"opacity": 0.819366
},
{
"word": "small",
"opacity": 0.739254
},
{
"word": "bird",
"opacity": 0.739234
},
{
"word": "follows",
"opacity": 0.739019
},
{
"word": "the",
"opacity": 0.819079
},
{
"word": "wind",
"opacity": 0.738978
}
],
"byId": {
"word-number": 140,
"next-word": 140,
"live-wpm": 1167,
"live-accuracy": 1167,
"run-status": 1167,
"SPAN": 1167,
"time-label": 1167,
"live-time": 1167,
"time-unit": 1167,
"previous-word": 28
},
"cdp": {
"LayoutCount": 1307,
"LayoutDuration": 0.238114,
"RecalcStyleCount": 1363,
"RecalcStyleDuration": 0.064145,
"ScriptDuration": 0.172095,
"TaskDuration": 1.126886
}
},
{
"phase": "native-typing",
"targetWpm": 160,
"displayWpm": "155",
"durationMs": 10818.69999999972,
"textWrites": 5831,
"unchangedTextWrites": 5286,
"innerWrites": 140,
"layoutReads": 280,
"layoutReadMs": 35.599999997299165,
"styleReads": 28,
"animations": 56,
"keyCount": 140,
"keyHandlerMedianMs": 0.5,
"keyHandlerP95Ms": 0.8000000002793968,
"keyHandlerMaxMs": 2.2999999998137355,
"keyToRafP95Ms": 15.799999999813735,
"frameCount": 649,
"framesOver25ms": 0,
"framesOver50ms": 0,
"firstKeyOpacity": [
{
"word": "a",
"opacity": 0.999416
},
{
"word": "calm",
"opacity": 0.549729
},
{
"word": "river",
"opacity": 0.549617
},
{
"word": "moves",
"opacity": 0.54994
},
{
"word": "through",
"opacity": 0.549642
},
{
"word": "the",
"opacity": 0.550002
},
{
"word": "city",
"opacity": 0.549667
},
{
"word": "and",
"opacity": 0.549555
},
{
"word": "a",
"opacity": 0.441878
},
{
"word": "small",
"opacity": 0.549878
},
{
"word": "bird",
"opacity": 0.54958
},
{
"word": "follows",
"opacity": 0.549468
},
{
"word": "the",
"opacity": 0.549828
},
{
"word": "wind",
"opacity": 0.442174
},
{
"word": "a",
"opacity": 0.442053
},
{
"word": "calm",
"opacity": 0.550039
},
{
"word": "river",
"opacity": 0.549928
},
{
"word": "moves",
"opacity": 0.550251
},
{
"word": "through",
"opacity": 0.441999
},
{
"word": "the",
"opacity": 0.441717
},
{
"word": "city",
"opacity": 0.550387
},
{
"word": "and",
"opacity": 0.549654
},
{
"word": "a",
"opacity": 0.441986
},
{
"word": "small",
"opacity": 0.549977
},
{
"word": "bird",
"opacity": 0.549679
},
{
"word": "follows",
"opacity": 0.549567
},
{
"word": "the",
"opacity": 0.549928
},
{
"word": "wind",
"opacity": 0.442281
}
],
"byId": {
"word-number": 140,
"next-word": 140,
"live-wpm": 789,
"live-accuracy": 789,
"run-status": 789,
"SPAN": 789,
"time-label": 789,
"live-time": 789,
"time-unit": 789,
"previous-word": 28
},
"cdp": {
"LayoutCount": 929,
"LayoutDuration": 0.166715,
"RecalcStyleCount": 983,
"RecalcStyleDuration": 0.05587,
"ScriptDuration": 0.094722,
"TaskDuration": 0.8065720000000001
}
}
]
}
@@ -0,0 +1,28 @@
{
"checkedAt": "2026-10-24T23:25:17.465Z",
"base": "http://127.0.0.1:8790",
"health": {
"ok": true,
"rev": "local",
"source_sha": "local",
"storage": "ready",
"quotas": "ready",
"topics": false
},
"score": "317",
"resultUrl": "http://127.0.0.1:8790/r/a433e1dab5e143e3aa76bf1c",
"storedWords": 13,
"storedEvents": 46,
"replayWord": "river",
"raceUrl": "http://127.0.0.1:8790/race/a433e1dab5e143e3aa76bf1c",
"ghostVisible": true,
"og": {
"status": 200,
"bytes": 3962,
"width": 1200,
"height": 630
},
"dailySame": true,
"dailyWords": 300,
"errors": []
}
+57
View File
@@ -0,0 +1,57 @@
{
"checkedAt": "2026-10-24T23:25:22.371Z",
"base": "http://127.0.0.1:8790",
"motion": "no-preference",
"checks": [
{
"name": "Ready stage stays still",
"idleMutations": 0
},
{
"name": "Immediate stationary target and persistent letters",
"word": "river",
"opacity": "1",
"transform": "none",
"animations": 0
},
{
"name": "Normal spaced typing at 160 WPM input cadence",
"advance": "space",
"accuracy": 100
},
{
"name": "Normal spaced typing at 160 WPM input cadence",
"advance": "letter",
"accuracy": 100
},
{
"name": "Long phone word and instant transition fit",
"width": 390,
"before": {
"viewport": 390,
"document": 390
},
"after": {
"word": "next",
"viewport": 390,
"document": 390,
"opacity": "1"
}
},
{
"name": "Long phone word and instant transition fit",
"width": 360,
"before": {
"viewport": 360,
"document": 360
},
"after": {
"word": "next",
"viewport": 360,
"document": 360,
"opacity": "1"
}
}
],
"errors": []
}
+94 -56
View File
@@ -25,13 +25,27 @@ let currentPath = '/';
let routeSerial = 0;
let lastWordKey = '';
let lastWordIndex = -1;
let previousWord = '';
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;
@@ -101,7 +115,8 @@ function beginRun({freshWords = false, focus = false} = {}) {
replay.playing = false;
lastWordKey = '';
lastWordIndex = -1;
previousWord = '';
renderedWord = '';
wordLayoutDirty = true;
lastErrorCount = 0;
$('load-panel').hidden = true;
$('play-panel').hidden = false;
@@ -120,86 +135,103 @@ function beginRun({freshWords = false, focus = false} = {}) {
}
function drawWord(word, buffer, index, nextWord, flags = null) {
const chars = Array.from(word || '');
const typed = Array.from(buffer || '');
const key = word + '|' + buffer + '|' + index + '|' + (flags?.join('') || '');
if (key === lastWordKey) return;
const wordChanged = index !== lastWordIndex;
if (wordChanged && lastWordIndex >= 0 && !reducedMotion) {
$('previous-word').textContent = previousWord;
$('previous-word').style.fontSize = getComputedStyle($('word')).fontSize;
$('previous-word').classList.toggle('long', previousWord.length > 12);
$('previous-word').getAnimations().forEach(animation => animation.cancel());
$('previous-word').animate([{opacity:.18,transform:'translateX(0)'},{opacity:0,transform:'translateX(-35px)'}], {duration:180,easing:'ease-out'});
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 || '');
wordLayoutDirty = true;
}
$('word').classList.toggle('long', chars.length > 12);
$('word').classList.toggle('very-long', chars.length > 22);
$('word').innerHTML = chars.map((char, i) => {
wordSpans.forEach((span, i) => {
let state = '';
if (i < typed.length) state = (flags ? flags[i] : typed[i] === char) ? 'correct' : 'incorrect';
if (i < typed.length) state = (flags ? flags[i] : typed[i] === span.textContent) ? 'correct' : 'incorrect';
if (i === typed.length) state += ' cursor';
return '<span class="' + state + '">' + escapeHTML(char) + '</span>';
}).join('') + (typed.length > chars.length ? '<span class="overflow" aria-hidden="true">+' + (typed.length - chars.length) + '</span>' : '');
$('word').setAttribute('aria-label', 'Word to type: ' + word);
$('word').dataset.word = word;
$('typing-input').setAttribute('aria-label', 'Type the word: ' + word);
// A pasted token may be much longer than a normal word. Measure the actual
// glyphs instead of letting it enlarge a phone's layout viewport.
$('word').style.fontSize = '';
const available = Math.max(1, $('stage').clientWidth - 28);
if ($('word').scrollWidth > available) {
const natural = parseFloat(getComputedStyle($('word')).fontSize);
$('word').style.fontSize = Math.max(8, natural * available / $('word').scrollWidth * .96) + 'px';
}
$('word-number').textContent = String(index + 1).padStart(3, '0');
$('next-word').textContent = nextWord || '';
if (wordChanged && !reducedMotion) {
$('word').getAnimations().forEach(animation => animation.cancel());
$('word').animate([{opacity:.2,transform:'translateX(25px)'},{opacity:1,transform:'translateX(0)'}], {duration:170,easing:'ease-out'});
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 available = Math.max(1, $('stage').clientWidth - 28);
const width = node.scrollWidth;
if (width > available) {
const natural = parseFloat(getComputedStyle(node).fontSize);
node.style.fontSize = Math.max(8, natural * available / width * .96) + 'px';
}
wordLayoutDirty = false;
}
lastWordKey = key;
lastWordIndex = index;
previousWord = word;
renderedWord = word;
}
function renderGame(state) {
drawWord(state.word, state.buffer, state.wordIndex, state.nextWord);
$('live-wpm').textContent = pretty(state.wpm);
$('live-accuracy').textContent = pretty(state.accuracy);
$('status-dot').classList.toggle('active', state.status === 'running');
if (state.status === 'ready') $('run-status').textContent = ghost ? 'your ghost is ready' : source.kind === 'daily' ? 'same words, your rhythm' : 'a fresh start';
else if (state.errorIndex >= 0) $('run-status').textContent = 'a little backspace, then onward';
else if (state.waitingForBeat) $('run-status').textContent = 'right on time · wait for the beat';
else $('run-status').textContent = state.streak >= 3 ? state.streak + ' clean words in a row' : 'one word, then the next';
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');
hintStart.textContent = state.status === 'ready' ? 'type the word to begin' : state.errorIndex >= 0 ? 'backspace to correct' : state.waitingForBeat ? 'hold that rhythm' : 'keep your rhythm';
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') {
$('time-label').textContent = 'remaining'; $('live-time').textContent = Math.ceil(state.remainingMs / 1000); $('time-unit').textContent = 's';
setText('time-label', 'remaining'); setText('live-time', Math.ceil(state.remainingMs / 1000)); setText('time-unit', 's');
} else if (selectedMode === '50') {
$('time-label').textContent = 'remaining'; $('live-time').textContent = Math.max(0, 50 - state.completedWords); $('time-unit').textContent = 'words';
setText('time-label', 'remaining'); setText('live-time', Math.max(0, 50 - state.completedWords)); setText('time-unit', 'words');
} else if (selectedMode === 'tempo') {
$('time-label').textContent = 'beat pace'; $('live-time').textContent = pretty(state.paceWpm); $('time-unit').textContent = 'wpm';
setText('time-label', 'beat pace'); setText('live-time', pretty(state.paceWpm)); setText('time-unit', 'wpm');
} else {
$('time-label').textContent = 'in the flow'; $('live-time').textContent = Math.floor(state.elapsedMs / 1000); $('time-unit').textContent = 's';
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 + ')';
$('tempo-caption').textContent = 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) => dot.classList.toggle('lost', i < state.misses));
$('tempo-lives').setAttribute('aria-label', (3 - state.misses) + ' missed beats remaining');
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;
$('race-you').textContent = state.completedWords;
$('race-them').textContent = completed;
setText('race-you', state.completedWords);
setText('race-them', completed);
const difference = state.completedWords - completed;
$('race-delta').textContent = state.status === 'ready' ? '' : difference === 0 ? 'neck and neck' : Math.abs(difference) + (difference > 0 ? ' ahead' : ' behind');
setText('race-delta', state.status === 'ready' ? '' : difference === 0 ? 'neck and neck' : Math.abs(difference) + (difference > 0 ? ' ahead' : ' behind'));
}
if (state.errors > lastErrorCount && !reducedMotion) {
$('word').animate([{transform:'translateX(0)'},{transform:'translateX(-5px)'},{transform:'translateX(5px)'},{transform:'translateX(0)'}], {duration:160});
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();
}
@@ -597,10 +629,16 @@ document.addEventListener('click', event => {
event.preventDefault(); route(url.pathname);
});
window.addEventListener('popstate', () => route(location.pathname,false));
window.addEventListener('resize', () => { lastWordKey = ''; });
window.addEventListener('resize', () => {
wordLayoutDirty = true;
if (view === 'play' && game) renderGame(game.snapshot(now()));
else if (view === 'replay') renderReplay(replay.position);
});
function frame(time) {
if (view === 'play' && game) renderGame(game.tick(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);
-1
View File
@@ -58,7 +58,6 @@
<div class="stage" id="stage">
<span class="word-number" id="word-number" aria-hidden="true">001</span>
<div class="word-wrap">
<div class="previous-word" id="previous-word" aria-hidden="true"></div>
<div class="word" id="word" aria-live="off" aria-label="Word to type"></div>
</div>
<div class="upcoming" id="upcoming" aria-hidden="true"><span>up next</span><b id="next-word"></b></div>
+2 -2
View File
@@ -1,7 +1,7 @@
:root{--paper:#f5f3ec;--ink:#263d33;--muted:#7b8177;--faint:#b8beb2;--line:#dcded3;--orange:#e36c49;--orange-soft:#f5e0d5;--green-soft:#e7ebdd;--sans:Arial,Helvetica,sans-serif;--mono:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace}
*{box-sizing:border-box}html{min-height:100%;background:var(--paper)}body{margin:0;color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased}button,input,select,textarea{font:inherit;color:inherit}button,a,input,select,textarea{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:default;opacity:.4}button,a,input,select,textarea{outline-offset:5px}button:focus-visible,a:focus-visible{outline:2px solid var(--orange)}button{border:0;background:none}a{color:inherit;text-decoration:none}button,a{touch-action:manipulation}input,textarea,select{border:1px solid var(--line);border-radius:8px;background:#fffdf6;padding:14px;outline:none}input:focus,textarea:focus,select:focus{border-color:var(--ink);box-shadow:0 0 0 2px #263d3312}input::placeholder,textarea::placeholder{color:#969b91}[hidden]{display:none!important}::selection{background:#e8b9a4;color:var(--ink)}.shell{max-width:1280px;margin:auto;padding:0 64px;min-height:100svh;display:flex;flex-direction:column}.masthead{height:112px;display:flex;align-items:center;border-bottom:1px solid var(--line);gap:26px;flex-shrink:0}.brand{font-size:48px;font-weight:800;letter-spacing:-4px;line-height:1;display:flex;align-items:baseline;gap:6px}.brand-dot{width:9px;height:9px;border-radius:50%;background:var(--orange)}.brand-note{font-family:var(--mono);font-size:11px;color:var(--muted);padding-top:11px;letter-spacing:-.2px}.top-nav{margin-left:auto;display:flex;align-items:center;gap:24px;font-size:13px}.top-nav a{display:flex;align-items:center;gap:8px}.top-nav a:hover,.text-button:hover{color:var(--orange)}.sun{color:var(--orange);font-size:23px}.icon-button{width:32px;height:36px;display:grid;place-items:center;padding:0}.help-button{font-family:var(--mono);font-size:17px;width:25px;height:25px;border:1px solid var(--faint);border-radius:50%}main{flex:1}.toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:28px 0 20px}.mode-switch{display:flex;align-items:center;gap:4px;border:1px solid var(--line);border-radius:99px;padding:5px}.mode-switch button{font-family:var(--mono);font-size:12px;line-height:30px;padding:0 16px;border-radius:99px;white-space:nowrap;color:var(--muted);transition:color .16s,background .16s}.mode-switch button[aria-pressed="true"]{background:var(--ink);color:var(--paper)}.mode-switch button:not(:disabled):hover{color:var(--ink);background:var(--green-soft)}.mode-switch button[aria-pressed="true"]:hover{background:#365446;color:var(--paper)}.mode-switch button[data-mode="endless"]{font-size:20px}.mode-divider{height:18px;border-left:1px solid var(--line);margin:0 5px}.tempo-option{display:flex;align-items:center;gap:8px}.tempo-icon{display:flex;align-items:center;gap:2px;height:14px}.tempo-icon i{width:2px;height:6px;background:currentColor}.tempo-icon i:nth-child(2){height:14px}.tempo-icon i:nth-child(3){height:10px}.source-button{font-family:var(--mono);font-size:12px;display:flex;align-items:center;gap:15px;max-width:40%;padding:12px 0 12px 12px}.source-button span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.source-button span:last-child{color:var(--orange);font-size:20px}.play-panel{padding-top:21px}.run-strip{display:flex;align-items:center;justify-content:space-between;gap:16px;font-family:var(--mono);font-size:10px;color:var(--muted);letter-spacing:.3px}.run-status{display:flex;gap:9px;align-items:center}.status-dot{width:6px;height:6px;border-radius:50%;background:var(--faint)}.status-dot.active{background:var(--orange);box-shadow:0 0 0 4px #e36c4914}.best-score{font-size:10px}.metrics{display:flex;justify-content:center;gap:62px;margin:44px 0 4px;user-select:none}.metrics>div{display:flex;align-items:baseline;gap:5px;min-width:102px;position:relative}.metric-label{font-family:var(--mono);font-size:9px;position:absolute;top:-19px;left:0;color:var(--muted)}.metric-value{font-family:var(--mono);font-weight:400;font-size:27px;letter-spacing:-1.5px;font-variant-numeric:tabular-nums}.metric-unit{font-size:10px;color:var(--muted);font-family:var(--mono)}.time-metric .metric-value{color:var(--orange)}.stage{height:310px;position:relative;cursor:text;display:flex;align-items:center;justify-content:center;isolation:isolate;overflow:clip}.word-number{position:absolute;left:0;top:48%;font-family:var(--mono);font-size:10px;color:var(--faint);writing-mode:vertical-rl;transform:rotate(180deg);letter-spacing:2px}.word-wrap{position:relative;width:100%;height:180px;display:grid;place-items:center}.word,.previous-word{font-family:var(--mono);font-size:clamp(50px,8.6vw,114px);font-weight:500;line-height:1.3;letter-spacing:-.075em;white-space:nowrap;color:var(--faint);max-width:98%;text-align:center;font-variant-ligatures:none}.word.long,.previous-word.long{font-size:clamp(25px,5.2vw,68px);letter-spacing:-.06em}.word.very-long{font-size:clamp(19px,3.5vw,46px)}.word{color:#7c8875}.word .correct{color:var(--ink)}.word .incorrect{color:#b24d36;backgrouLine truncated
*{box-sizing:border-box}html{min-height:100%;background:var(--paper)}body{margin:0;color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased}button,input,select,textarea{font:inherit;color:inherit}button,a,input,select,textarea{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:default;opacity:.4}button,a,input,select,textarea{outline-offset:5px}button:focus-visible,a:focus-visible{outline:2px solid var(--orange)}button{border:0;background:none}a{color:inherit;text-decoration:none}button,a{touch-action:manipulation}input,textarea,select{border:1px solid var(--line);border-radius:8px;background:#fffdf6;padding:14px;outline:none}input:focus,textarea:focus,select:focus{border-color:var(--ink);box-shadow:0 0 0 2px #263d3312}input::placeholder,textarea::placeholder{color:#969b91}[hidden]{display:none!important}::selection{background:#e8b9a4;color:var(--ink)}.shell{max-width:1280px;margin:auto;padding:0 64px;min-height:100svh;display:flex;flex-direction:column}.masthead{height:112px;display:flex;align-items:center;border-bottom:1px solid var(--line);gap:26px;flex-shrink:0}.brand{font-size:48px;font-weight:800;letter-spacing:-4px;line-height:1;display:flex;align-items:baseline;gap:6px}.brand-dot{width:9px;height:9px;border-radius:50%;background:var(--orange)}.brand-note{font-family:var(--mono);font-size:11px;color:var(--muted);padding-top:11px;letter-spacing:-.2px}.top-nav{margin-left:auto;display:flex;align-items:center;gap:24px;font-size:13px}.top-nav a{display:flex;align-items:center;gap:8px}.top-nav a:hover,.text-button:hover{color:var(--orange)}.sun{color:var(--orange);font-size:23px}.icon-button{width:32px;height:36px;display:grid;place-items:center;padding:0}.help-button{font-family:var(--mono);font-size:17px;width:25px;height:25px;border:1px solid var(--faint);border-radius:50%}main{flex:1}.toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:28px 0 20px}.mode-switch{display:flex;align-items:center;gap:4px;border:1px solid var(--line);border-radius:99px;padding:5px}.mode-switch button{font-family:var(--mono);font-size:12px;line-height:30px;padding:0 16px;border-radius:99px;white-space:nowrap;color:var(--muted);transition:color .16s,background .16s}.mode-switch button[aria-pressed="true"]{background:var(--ink);color:var(--paper)}.mode-switch button:not(:disabled):hover{color:var(--ink);background:var(--green-soft)}.mode-switch button[aria-pressed="true"]:hover{background:#365446;color:var(--paper)}.mode-switch button[data-mode="endless"]{font-size:20px}.mode-divider{height:18px;border-left:1px solid var(--line);margin:0 5px}.tempo-option{display:flex;align-items:center;gap:8px}.tempo-icon{display:flex;align-items:center;gap:2px;height:14px}.tempo-icon i{width:2px;height:6px;background:currentColor}.tempo-icon i:nth-child(2){height:14px}.tempo-icon i:nth-child(3){height:10px}.source-button{font-family:var(--mono);font-size:12px;display:flex;align-items:center;gap:15px;max-width:40%;padding:12px 0 12px 12px}.source-button span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.source-button span:last-child{color:var(--orange);font-size:20px}.play-panel{padding-top:21px}.run-strip{display:flex;align-items:center;justify-content:space-between;gap:16px;font-family:var(--mono);font-size:10px;color:var(--muted);letter-spacing:.3px}.run-status{display:flex;gap:9px;align-items:center}.status-dot{width:6px;height:6px;border-radius:50%;background:var(--faint)}.status-dot.active{background:var(--orange);box-shadow:0 0 0 4px #e36c4914}.best-score{font-size:10px}.metrics{display:flex;justify-content:center;gap:62px;margin:44px 0 4px;user-select:none}.metrics>div{display:flex;align-items:baseline;gap:5px;min-width:102px;position:relative}.metric-label{font-family:var(--mono);font-size:9px;position:absolute;top:-19px;left:0;color:var(--muted)}.metric-value{font-family:var(--mono);font-weight:400;font-size:27px;letter-spacing:-1.5px;font-variant-numeric:tabular-nums}.metric-unit{font-size:10px;color:var(--muted);font-family:var(--mono)}.time-metric .metric-value{color:var(--orange)}.stage{height:310px;position:relative;cursor:text;display:flex;align-items:center;justify-content:center;isolation:isolate;overflow:clip}.word-number{position:absolute;left:0;top:48%;font-family:var(--mono);font-size:10px;color:var(--faint);writing-mode:vertical-rl;transform:rotate(180deg);letter-spacing:2px}.word-wrap{position:relative;width:100%;height:180px;display:grid;place-items:center}.word{font-family:var(--mono);font-size:clamp(50px,8.6vw,114px);font-weight:500;line-height:1.3;letter-spacing:-.075em;white-space:nowrap;color:var(--faint);max-width:98%;text-align:center;font-variant-ligatures:none}.word.long{font-size:clamp(25px,5.2vw,68px);letter-spacing:-.06em}.word.very-long{font-size:clamp(19px,3.5vw,46px)}.word{color:#7c8875}.word .correct{color:var(--ink)}.word .incorrect{color:#b24d36;background:var(--orange-soft);border-radiusLine truncated
@media(min-height:860px) and (min-width:701px){.stage{height:360px}.metrics{margin-top:64px}.play-panel{padding-top:32px}.input-row{padding-top:30px}.toolbar{padding-top:34px}}
@media(max-width:900px){.shell{padding:0 36px}.brand-note{font-size:9px}.top-nav{gap:18px}.mode-switch button{padding:0 13px}.stage{height:290px}.metrics{gap:40px}.result-main>span:first-child{font-size:95px}.result-grid{gap:30px}.result-detail{padding-left:30px}}
@media(max-width:700px){.shell{padding:0 23px}.masthead{height:84px;gap:10px}.brand{font-size:41px;letter-spacing:-3.5px}.brand-dot{width:7px;height:7px}.brand-note{display:none}.top-nav{gap:19px;font-size:12px}.help-button{display:none}.toolbar{padding:22px 0 16px;flex-direction:column;align-items:stretch;gap:8px}.mode-switch{align-self:center;max-width:100%;gap:2px;padding:4px}.mode-switch button{padding:0 13px;font-size:11px;line-height:30px}.mode-divider{margin:0 3px}.source-button{font-size:10px;max-width:100%;padding:8px 0;align-self:center;gap:11px}.source-button span:last-child{font-size:15px}.play-panel{padding-top:9px}.run-strip{font-size:8px}.best-score{font-size:8px}.metrics{gap:28px;margin:44px 0 0}.metrics>div{min-width:72px}.metric-value{font-size:24px}.metric-label{font-size:8px;top:-18px}.metric-unit{font-size:8px}.stage{height:236px}.word-wrap{height:140px}.word,.previous-word{font-size:clamp(40px,11.1vw,76px);letter-spacing:-.075em}.word.long,.previous-word.long{font-size:clamp(24px,6.4vw,42px)}.word.very-long{font-size:clamp(17px,4.2vw,29px)}.word .cursor:after{bottom:-7px;height:2px}.word-number{font-size:8px;left:0;top:43%}.upcoming{right:1%;bottom:15px;gap:10px}.upcoming b{font-size:19px}.upcoming span{font-size:7px}.input-row{padding-top:22px;min-height:135px}.typing-input{position:static;opacity:1;width:100%;height:49px;border:1px solid var(--line);border-radius:8px;padding:12px 14px;pointer-events:auto;overflow:auto;font-size:16px;text-align:center;background:#eeeee5}.typing-input:focus{background:#fdfcf7}.typing-input::placeholder{font-size:11px;color:var(--muted);font-family:var(--mono)}.run-hint{font-size:9px;gap:8px;margin-top:14px}.run-hint kbd{font-size:8px}.footer{height:auto;min-height:68px;font-size:8px;align-items:center;padding:17px 0;margin-top:18px;gap:10px}.footer>div{gap:10px}.footer a{font-size:8px}#restart-tip{display:none}.footer>.text-button{font-size:8px}.tempo-track{padding-top:0}.tempo-caption{font-size:9px}.ghost-race{margin-top:22px;margin-bottom:-16px;font-size:10px;gap:9px}.result-panel{margin:16px auto 25px}.result-panel h1{font-size:30px;margin:10px 0 20px}.result-grid{gap:25px;align-items:center;margin-bottom:25px}.result-main>span:first-child{font-size:75px;letter-spacing:-6px}.result-main>span:last-child{font-size:10px}.result-detail{padding-left:24px;gap:16px}.result-detail>div{flex-direction:column;gap:5px;align-items:flex-start}.result-detail dt{font-size:8px}.result-detail dd{font-size:13px}.chart-label{font-size:8px}.rhythm-chart svg{height:65px}.slow-words{font-size:9px;gap:15px;align-items:flex-start;padding-bottom:18px}.slow-words>div{gap:10px 15px}.slow-word small{font-size:8px}.result-actions{gap:8px;margin-top:20px}.result-actions .button{flex:1;font-size:10px;gap:8px;padding:10px 12px;min-height:44px}.result-actions>.text-button{margin-left:0;flex-basis:100%;text-align:left;margin-top:2px}.eyebrow{font-size:9px}.new-best{font-size:8px;padding:5px 7px}.result-note{font-size:10px}.replay-controls{gap:10px;margin-top:20px}.replay-time{font-size:9px}.replay-controls>.text-button{flex-basis:100%;text-align:center}.dialog{padding:30px 24px}.dialog h2{font-size:26px;margin-bottom:22px}.source-tabs{gap:20px}.source-tabs button{font-size:10px}.load-panel{min-height:430px;padding:25px 10px}.load-panel h1{font-size:27px}.load-panel>p:last-of-type{font-size:12px}.load-panel .button{font-size:11px;padding:10px 14px}.toast{bottom:16px;font-size:11px}.button-row{flex-wrap:wrap;justify-content:center}}
@media(max-width:700px){.shell{padding:0 23px}.masthead{height:84px;gap:10px}.brand{font-size:41px;letter-spacing:-3.5px}.brand-dot{width:7px;height:7px}.brand-note{display:none}.top-nav{gap:19px;font-size:12px}.help-button{display:none}.toolbar{padding:22px 0 16px;flex-direction:column;align-items:stretch;gap:8px}.mode-switch{align-self:center;max-width:100%;gap:2px;padding:4px}.mode-switch button{padding:0 13px;font-size:11px;line-height:30px}.mode-divider{margin:0 3px}.source-button{font-size:10px;max-width:100%;padding:8px 0;align-self:center;gap:11px}.source-button span:last-child{font-size:15px}.play-panel{padding-top:9px}.run-strip{font-size:8px}.best-score{font-size:8px}.metrics{gap:28px;margin:44px 0 0}.metrics>div{min-width:72px}.metric-value{font-size:24px}.metric-label{font-size:8px;top:-18px}.metric-unit{font-size:8px}.stage{height:236px}.word-wrap{height:140px}.word{font-size:clamp(40px,11.1vw,76px);letter-spacing:-.075em}.word.long{font-size:clamp(24px,6.4vw,42px)}.word.very-long{font-size:clamp(17px,4.2vw,29px)}.word .cursor:after{bottom:-7px;height:2px}.word-number{font-size:8px;left:0;top:43%}.upcoming{top:calc(50% + 55px);gap:10px}.upcoming b{font-size:23px}.upcoming span{font-size:7px}.input-row{padding-top:22px;min-height:135px}.typing-input{position:static;opacity:1;width:100%;height:49px;border:1px solid var(--line);border-radius:8px;padding:12px 14px;pointer-events:auto;overflow:auto;font-size:16px;text-align:center;background:#eeeee5}.typing-input:focus{background:#fdfcf7}.typing-input::placeholder{font-size:11px;color:var(--muted);font-family:var(--mono)}.run-hint{font-size:9px;gap:8px;margin-top:14px}.run-hint kbd{font-size:8px}.footer{height:auto;min-height:68px;font-size:8px;align-items:center;padding:17px 0;margin-top:18px;gap:10px}.footer>div{gap:10px}.footer a{font-size:8px}#restart-tip{display:none}.footer>.text-button{font-size:8px}.tempo-track{padding-top:0}.tempo-caption{font-size:9px}.ghost-race{margin-top:22px;margin-bottom:-16px;font-size:10px;gap:9px}.result-panel{margin:16px auto 25px}.result-panel h1{font-size:30px;margin:10px 0 20px}.result-grid{gap:25px;align-items:center;margin-bottom:25px}.result-main>span:first-child{font-size:75px;letter-spacing:-6px}.result-main>span:last-child{font-size:10px}.result-detail{padding-left:24px;gap:16px}.result-detail>div{flex-direction:column;gap:5px;align-items:flex-start}.result-detail dt{font-size:8px}.result-detail dd{font-size:13px}.chart-label{font-size:8px}.rhythm-chart svg{height:65px}.slow-words{font-size:9px;gap:15px;align-items:flex-start;padding-bottom:18px}.slow-words>div{gap:10px 15px}.slow-word small{font-size:8px}.result-actions{gap:8px;margin-top:20px}.result-actions .button{flex:1;font-size:10px;gap:8px;padding:10px 12px;min-height:44px}.result-actions>.text-button{margin-left:0;flex-basis:100%;text-align:left;margin-top:2px}.eyebrow{font-size:9px}.new-best{font-size:8px;padding:5px 7px}.result-note{font-size:10px}.replay-controls{gap:10px;margin-top:20px}.replay-time{font-size:9px}.replay-controls>.text-button{flex-basis:100%;text-align:center}.dialog{padding:30px 24px}.dialog h2{font-size:26px;margin-bottom:22px}.source-tabs{gap:20px}.source-tabs button{font-size:10px}.load-panel{min-height:430px;padding:25px 10px}.load-panel h1{font-size:27px}.load-panel>p:last-of-type{font-size:12px}.load-panel .button{font-size:11px;padding:10px 14px}.toast{bottom:16px;font-size:11px}.button-row{flex-wrap:wrap;justify-content:center}}
@media(max-width:370px){.shell{padding:0 17px}.mode-switch button{padding:0 10px}.tempo-option{gap:5px}.top-nav{gap:12px}.metrics{gap:22px}.stage{height:205px}.source-tabs{gap:16px}.footer a{display:none}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
+1 -1
View File
@@ -1 +1 @@
export const ASSETS = {"/app.js":{"body":"import { TypingGame } from '/game.js';\nimport { generateWords, normalizePassage } from '/words.js';\n\nconst $ = id => document.getElementById(id);\nconst escapeHTML = value => String(value ?? '').replace(/[&<>\"']/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[char]));\nconst now = () => performance.now();\nconst pretty = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value))) : '0';\nconst round1 = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value) * 10) / 10) : '0';\nconst modeNames = {'30':'30 seconds','60':'60 seconds','50':'50 words','endless':'endless','tempo':'tempo'};\nconst readLocal = (key, fallback) => { try { return JSON.parse(localStorage.getItem('typy.' + key)) ?? fallback; } catch { return fallback; } };\nconst writeLocal = (key, value) => { try { localStorage.setItem('typy.' + key, JSON.stringify(value)); } catch {} };\nconst settings = {...{advance:'space',preview:true}, ...readLocal('settings', {})};\nconst bests = readLocal('bests', {});\nconst reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;\nlet selectedMode = '30';\nlet source = {kind:'common'};\nlet passage = [];\nlet game = null;\nlet ghost = null;\nlet view = 'play';\nlet currentResult = null;\nlet shareId = null;\nlet shareIntent = 'result';\nlet currentPath = '/';\nlet routeSerial = 0;\nlet lastWordKey = '';\nlet lastWordIndex = -1;\nlet previousWord = '';\nlet lastErrorCount = 0;\nlet replay = {playing:false,position:0,anchor:0,anchorPosition:0};\nlet toastTimer;\nlet transitioning = false;\nlet isComposing = false;\n\nfunction toast(message) {\n clearTimeout(toastTimer);\n $('toast').textContent = message;\n $('toast').hidden = false;\n toastTimer = setTimeout(() => { $('toast').hidden = true; }, 4500);\n}\n\nasync function api(path, options = {}) {\n const response = await fetch(path, {...options, headers:{...(options.body ? {'Content-Type':'application/json'} : {}), ...(options.headers || {})}});\n let body;\n try { body = await response.json(); } catch { throw new Error('The server could not answer. Please try again.'); }\n if (!response.ok) {\n const message = typeof body.error === 'string' ? body.error : body.error?.message || body.message;\n const error = new Error(message || 'Something interrupted that request. Please try again.');\n error.status = response.status;\n throw error;\n }\n return body;\n}\n\nfunction sourceName() {\n if (ghost) return 'racing ' + (currentResult?.name || 'a friendly ghost');\n if (source.kind === 'topic') return (source.topic || '').replaceAll('-', ' ');\n if (source.kind === 'daily') return 'the daily · ' + (source.day || 'today');\n if (source.kind === 'paste') return 'your own words';\n return 'common words';\n}\n\nfunction localBestKey() { return selectedMode; }\nfunction updateOptions() {\n const locked = source.kind === 'daily' || Boolean(ghost);\n const effectiveAdvance = ghost ? currentResult.run.advance : settings.advance;\n $('mode-switch').classList.toggle('locked', locked);\n document.querySelectorAll('[data-mode]').forEach(button => {\n button.setAttribute('aria-pressed', String(button.dataset.mode === selectedMode));\n button.disabled = locked || view === 'replay';\n button.title = locked ? (ghost ? 'A race uses the original run’s mode.' : 'The daily is a shared 60-second run.') : '';\n });\n $('source-label').textContent = sourceName();\n $('advance-hint').innerHTML = selectedMode === 'tempo' ? 'finish before the beat' : effectiveAdvance === 'letter' ? 'last letter → next word' : '<kbd>space</kbd> next word';\n $('upcoming').hidden = !settings.preview;\n $('advance-setting').value = effectiveAdvance;\n $('advance-setting').disabled = Boolean(ghost);\n $('preview-setting').checked = settings.preview;\n $('footer-board').hidden = source.kind !== 'daily';\n $('daily-link').setAttribute('aria-current', source.kind === 'daily' ? 'page' : 'false');\n $('best-score').textContent = bests[localBestKey()] ? 'device best ' + pretty(bests[localBestKey()]) + ' wpm' : '';\n}\n\nfunction focusTyping() {\n if (view !== 'play' || document.querySelector('dialog[open]')) return;\n $('typing-input').focus({preventScroll:true});\n}\n\nfunction newCommonPassage() {\n const words = generateWords(300);\n words[0] = 'breathe';\n return words;\n}\n\nfunction beginRun({freshWords = false, focus = false} = {}) {\n transitioning = false;\n if (freshWords && source.kind === 'common' && !ghost) passage = newCommonPassage();\n if (!passage.length) passage = newCommonPassage();\n game = new TypingGame({words:passage, mode:selectedMode, advance:ghost ? currentResult.run.advance : settings.advance, paceWpm:readLocal('lastAverage', 40)});\n view = 'play';\n replay.playing = false;\n lastWordKey = '';\n lastWordIndex = -1;\n previousWord = '';\n lastErrorCount = 0;\n $('load-panel').hidden = true;\n $('plaLine truncated
export const ASSETS = {"/app.js":{"body":"import { TypingGame } from '/game.js';\nimport { generateWords, normalizePassage } from '/words.js';\n\nconst $ = id => document.getElementById(id);\nconst escapeHTML = value => String(value ?? '').replace(/[&<>\"']/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[char]));\nconst now = () => performance.now();\nconst pretty = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value))) : '0';\nconst round1 = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value) * 10) / 10) : '0';\nconst modeNames = {'30':'30 seconds','60':'60 seconds','50':'50 words','endless':'endless','tempo':'tempo'};\nconst readLocal = (key, fallback) => { try { return JSON.parse(localStorage.getItem('typy.' + key)) ?? fallback; } catch { return fallback; } };\nconst writeLocal = (key, value) => { try { localStorage.setItem('typy.' + key, JSON.stringify(value)); } catch {} };\nconst settings = {...{advance:'space',preview:true}, ...readLocal('settings', {})};\nconst bests = readLocal('bests', {});\nconst reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;\nlet selectedMode = '30';\nlet source = {kind:'common'};\nlet passage = [];\nlet game = null;\nlet ghost = null;\nlet view = 'play';\nlet currentResult = null;\nlet shareId = null;\nlet shareIntent = 'result';\nlet currentPath = '/';\nlet routeSerial = 0;\nlet lastWordKey = '';\nlet lastWordIndex = -1;\nlet renderedWord = '';\nlet wordSpans = [];\nlet overflowSpan = null;\nlet wordLayoutDirty = true;\nlet errorAnimation = null;\nlet lastClockPaint = 0;\nlet lastErrorCount = 0;\nlet replay = {playing:false,position:0,anchor:0,anchorPosition:0};\nlet toastTimer;\nlet transitioning = false;\nlet isComposing = false;\n\nfunction setText(id, value) {\n const node = $(id), text = String(value);\n if (node.textContent !== text) node.textContent = text;\n}\n\nfunction setClass(node, name, enabled) {\n if (node.classList.contains(name) !== enabled) node.classList.toggle(name, enabled);\n}\n\nfunction toast(message) {\n clearTimeout(toastTimer);\n $('toast').textContent = message;\n $('toast').hidden = false;\n toastTimer = setTimeout(() => { $('toast').hidden = true; }, 4500);\n}\n\nasync function api(path, options = {}) {\n const response = await fetch(path, {...options, headers:{...(options.body ? {'Content-Type':'application/json'} : {}), ...(options.headers || {})}});\n let body;\n try { body = await response.json(); } catch { throw new Error('The server could not answer. Please try again.'); }\n if (!response.ok) {\n const message = typeof body.error === 'string' ? body.error : body.error?.message || body.message;\n const error = new Error(message || 'Something interrupted that request. Please try again.');\n error.status = response.status;\n throw error;\n }\n return body;\n}\n\nfunction sourceName() {\n if (ghost) return 'racing ' + (currentResult?.name || 'a friendly ghost');\n if (source.kind === 'topic') return (source.topic || '').replaceAll('-', ' ');\n if (source.kind === 'daily') return 'the daily · ' + (source.day || 'today');\n if (source.kind === 'paste') return 'your own words';\n return 'common words';\n}\n\nfunction localBestKey() { return selectedMode; }\nfunction updateOptions() {\n const locked = source.kind === 'daily' || Boolean(ghost);\n const effectiveAdvance = ghost ? currentResult.run.advance : settings.advance;\n $('mode-switch').classList.toggle('locked', locked);\n document.querySelectorAll('[data-mode]').forEach(button => {\n button.setAttribute('aria-pressed', String(button.dataset.mode === selectedMode));\n button.disabled = locked || view === 'replay';\n button.title = locked ? (ghost ? 'A race uses the original run’s mode.' : 'The daily is a shared 60-second run.') : '';\n });\n $('source-label').textContent = sourceName();\n $('advance-hint').innerHTML = selectedMode === 'tempo' ? 'finish before the beat' : effectiveAdvance === 'letter' ? 'last letter → next word' : '<kbd>space</kbd> next word';\n $('upcoming').hidden = !settings.preview;\n $('advance-setting').value = effectiveAdvance;\n $('advance-setting').disabled = Boolean(ghost);\n $('preview-setting').checked = settings.preview;\n $('footer-board').hidden = source.kind !== 'daily';\n $('daily-link').setAttribute('aria-current', source.kind === 'daily' ? 'page' : 'false');\n $('best-score').textContent = bests[localBestKey()] ? 'device best ' + pretty(bests[localBestKey()]) + ' wpm' : '';\n}\n\nfunction focusTyping() {\n if (view !== 'play' || document.querySelector('dialog[open]')) return;\n $('typing-input').focus({preventScroll:true});\n}\n\nfunction newCommonPassage() {\n const words = generateWords(300);\n words[0] = 'breathe';\n return words;\n}\n\nfunction beginRun({freshWords = false, focus = false} = {}) {\n transitioning = false;\n if (freshWords && source.kind === 'common' && !ghost) passage = newCommonLine truncated
+10
View File
@@ -112,6 +112,7 @@ export class TypingGame {
this.bestStreak = 0;
this.misses = 0;
this.waitingForBeat = false;
this.allowOptionalSeparator = false;
this.beatDeadline = null;
this.beatDurationMs = null;
this.finishedReason = null;
@@ -192,6 +193,7 @@ export class TypingGame {
this.wordErrors = 0;
this.typedEnd = null;
this.waitingForBeat = false;
this.allowOptionalSeparator = completed && !isBeat && this.advance === 'letter';
this.wordStartedAt = now;
if (this.mode === '50' && this.completedWords >= 50) {
this._finish(now, 'words', false);
@@ -245,6 +247,14 @@ export class TypingGame {
this._start(now);
}
if (this.waitingForBeat) return this._snapshot(now);
// Automatic advancement already earned one normalized separator. Let a
// typist press that space out of habit without corrupting the next word.
// Only the very next printable key gets this allowance; extra or mid-word
// spaces still need correction, and an ignored separator earns no output.
if (this.allowOptionalSeparator) {
this.allowOptionalSeparator = false;
if (key === ' ') return this._snapshot(now);
}
if (key === ' ' && this.buffer.join('') === this.word) {
if (this.mode === 'tempo') {
this.waitingForBeat = true;
+1 -1
View File
@@ -1,4 +1,4 @@
const TEMPLATE = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover\">\n <meta name=\"theme-color\" content=\"#f5f3ec\">\n <!--OG-->\n <link rel=\"icon\" href=\"/favicon.svg\" type=\"image/svg+xml\">\n <link rel=\"stylesheet\" href=\"/style.css\">\n <script type=\"module\" src=\"/app.js\"></script>\n</head>\n<body>\n <div class=\"shell\">\n <header class=\"masthead\">\n <a class=\"brand\" href=\"/\" aria-label=\"typy home\">typy<span class=\"brand-dot\" aria-hidden=\"true\"></span></a>\n <span class=\"brand-note\">a little more in the moment.</span>\n <nav class=\"top-nav\" aria-label=\"Main\">\n <a href=\"/daily\" id=\"daily-link\"><span class=\"sun\" aria-hidden=\"true\">✳</span> the daily</a>\n <button class=\"icon-button\" id=\"settings-open\" aria-label=\"Typing settings\" title=\"Typing settings\">\n <svg width=\"21\" height=\"21\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" aria-hidden=\"true\"><path d=\"M4 7h16M4 17h16\"/><circle cx=\"9\" cy=\"7\" r=\"3\" fill=\"var(--paper)\"/><circle cx=\"15\" cy=\"17\" r=\"3\" fill=\"var(--paper)\"/></svg>\n </button>\n <button class=\"icon-button help-button\" id=\"help-open\" aria-label=\"How to play\">?</button>\n </nav>\n </header>\n\n <main id=\"main\">\n <section class=\"toolbar\" aria-label=\"Run options\">\n <div class=\"mode-switch\" id=\"mode-switch\" aria-label=\"Run length\">\n <button data-mode=\"30\" aria-pressed=\"true\">30s</button>\n <button data-mode=\"60\" aria-pressed=\"false\">60s</button>\n <button data-mode=\"50\" aria-pressed=\"false\">50 words</button>\n <button data-mode=\"endless\" aria-pressed=\"false\" aria-label=\"Endless\">∞</button>\n <span class=\"mode-divider\" aria-hidden=\"true\"></span>\n <button data-mode=\"tempo\" class=\"tempo-option\" aria-pressed=\"false\"><span class=\"tempo-icon\" aria-hidden=\"true\"><i></i><i></i><i></i></span>tempo</button>\n </div>\n <button class=\"source-button\" id=\"source-open\"><span id=\"source-label\">common words</span><span aria-hidden=\"true\">↗</span></button>\n </section>\n\n <section id=\"load-panel\" class=\"load-panel\" hidden aria-live=\"polite\">\n <div class=\"spinner\" id=\"loading-spinner\" aria-hidden=\"true\"></div>\n <p class=\"eyebrow\" id=\"load-kicker\">one moment</p>\n <h1 id=\"load-title\">finding the words…</h1>\n <p id=\"load-description\"></p>\n <div class=\"button-row\" id=\"load-actions\" hidden><button class=\"button primary\" id=\"load-retry\">try again</button><button class=\"button\" id=\"load-common\">use common words</button></div>\n </section>\n\n <section id=\"play-panel\" class=\"play-panel\" aria-label=\"Typing stage\">\n <div class=\"run-strip\">\n <div class=\"run-status\"><span class=\"status-dot\" id=\"status-dot\"></span><span id=\"run-status\">a fresh start</span></div>\n <div id=\"best-score\" class=\"best-score\"></div>\n </div>\n <div class=\"metrics\" aria-label=\"Live score\">\n <div><span class=\"metric-label\">pace</span><span class=\"metric-value\" id=\"live-wpm\">0</span><span class=\"metric-unit\">wpm</span></div>\n <div><span class=\"metric-label\">accuracy</span><span class=\"metric-value\" id=\"live-accuracy\">100</span><span class=\"metric-unit\">%</span></div>\n <div class=\"time-metric\"><span class=\"metric-label\" id=\"time-label\">remaining</span><span class=\"metric-value\" id=\"live-time\">30</span><span class=\"metric-unit\" id=\"time-unit\">s</span></div>\n </div>\n <div id=\"ghost-race\" class=\"ghost-race\" hidden aria-live=\"off\"><span>you <b id=\"race-you\">0</b></span><span class=\"race-separator\">/</span><span class=\"ghost-label\">ghost <b id=\"race-them\">0</b></span><span id=\"race-delta\"></span></div>\n <div class=\"stage\" id=\"stage\">\n <span class=\"word-number\" id=\"word-number\" aria-hidden=\"true\">001</span>\n <div class=\"word-wrap\">\n <div class=\"previous-word\" id=\"previous-word\" aria-hidden=\"true\"></div>\n <div class=\"word\" id=\"word\" aria-live=\"off\" aria-label=\"Word to type\"></div>\n </div>\n <div class=\"upcoming\" id=\"upcoming\" aria-hidden=\"true\"><span>up next</span><b id=\"next-word\"></b></div>\n </div>\n <div id=\"tempo-track\" class=\"tempo-track\" hidden><div class=\"tempo-rail\"><div id=\"tempo-progress\"></div></div><div class=\"tempo-caption\"><span id=\"tempo-caption\">find the beat</span><span id=\"tempo-lives\" aria-label=\"Three beats remaining\"><i></i><i></i><i></i></span></div></div>\n <div class=\"input-row\" id=\"input-row\">\n <input Line truncated
const TEMPLATE = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover\">\n <meta name=\"theme-color\" content=\"#f5f3ec\">\n <!--OG-->\n <link rel=\"icon\" href=\"/favicon.svg\" type=\"image/svg+xml\">\n <link rel=\"stylesheet\" href=\"/style.css\">\n <script type=\"module\" src=\"/app.js\"></script>\n</head>\n<body>\n <div class=\"shell\">\n <header class=\"masthead\">\n <a class=\"brand\" href=\"/\" aria-label=\"typy home\">typy<span class=\"brand-dot\" aria-hidden=\"true\"></span></a>\n <span class=\"brand-note\">a little more in the moment.</span>\n <nav class=\"top-nav\" aria-label=\"Main\">\n <a href=\"/daily\" id=\"daily-link\"><span class=\"sun\" aria-hidden=\"true\">✳</span> the daily</a>\n <button class=\"icon-button\" id=\"settings-open\" aria-label=\"Typing settings\" title=\"Typing settings\">\n <svg width=\"21\" height=\"21\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" aria-hidden=\"true\"><path d=\"M4 7h16M4 17h16\"/><circle cx=\"9\" cy=\"7\" r=\"3\" fill=\"var(--paper)\"/><circle cx=\"15\" cy=\"17\" r=\"3\" fill=\"var(--paper)\"/></svg>\n </button>\n <button class=\"icon-button help-button\" id=\"help-open\" aria-label=\"How to play\">?</button>\n </nav>\n </header>\n\n <main id=\"main\">\n <section class=\"toolbar\" aria-label=\"Run options\">\n <div class=\"mode-switch\" id=\"mode-switch\" aria-label=\"Run length\">\n <button data-mode=\"30\" aria-pressed=\"true\">30s</button>\n <button data-mode=\"60\" aria-pressed=\"false\">60s</button>\n <button data-mode=\"50\" aria-pressed=\"false\">50 words</button>\n <button data-mode=\"endless\" aria-pressed=\"false\" aria-label=\"Endless\">∞</button>\n <span class=\"mode-divider\" aria-hidden=\"true\"></span>\n <button data-mode=\"tempo\" class=\"tempo-option\" aria-pressed=\"false\"><span class=\"tempo-icon\" aria-hidden=\"true\"><i></i><i></i><i></i></span>tempo</button>\n </div>\n <button class=\"source-button\" id=\"source-open\"><span id=\"source-label\">common words</span><span aria-hidden=\"true\">↗</span></button>\n </section>\n\n <section id=\"load-panel\" class=\"load-panel\" hidden aria-live=\"polite\">\n <div class=\"spinner\" id=\"loading-spinner\" aria-hidden=\"true\"></div>\n <p class=\"eyebrow\" id=\"load-kicker\">one moment</p>\n <h1 id=\"load-title\">finding the words…</h1>\n <p id=\"load-description\"></p>\n <div class=\"button-row\" id=\"load-actions\" hidden><button class=\"button primary\" id=\"load-retry\">try again</button><button class=\"button\" id=\"load-common\">use common words</button></div>\n </section>\n\n <section id=\"play-panel\" class=\"play-panel\" aria-label=\"Typing stage\">\n <div class=\"run-strip\">\n <div class=\"run-status\"><span class=\"status-dot\" id=\"status-dot\"></span><span id=\"run-status\">a fresh start</span></div>\n <div id=\"best-score\" class=\"best-score\"></div>\n </div>\n <div class=\"metrics\" aria-label=\"Live score\">\n <div><span class=\"metric-label\">pace</span><span class=\"metric-value\" id=\"live-wpm\">0</span><span class=\"metric-unit\">wpm</span></div>\n <div><span class=\"metric-label\">accuracy</span><span class=\"metric-value\" id=\"live-accuracy\">100</span><span class=\"metric-unit\">%</span></div>\n <div class=\"time-metric\"><span class=\"metric-label\" id=\"time-label\">remaining</span><span class=\"metric-value\" id=\"live-time\">30</span><span class=\"metric-unit\" id=\"time-unit\">s</span></div>\n </div>\n <div id=\"ghost-race\" class=\"ghost-race\" hidden aria-live=\"off\"><span>you <b id=\"race-you\">0</b></span><span class=\"race-separator\">/</span><span class=\"ghost-label\">ghost <b id=\"race-them\">0</b></span><span id=\"race-delta\"></span></div>\n <div class=\"stage\" id=\"stage\">\n <span class=\"word-number\" id=\"word-number\" aria-hidden=\"true\">001</span>\n <div class=\"word-wrap\">\n <div class=\"word\" id=\"word\" aria-live=\"off\" aria-label=\"Word to type\"></div>\n </div>\n <div class=\"upcoming\" id=\"upcoming\" aria-hidden=\"true\"><span>up next</span><b id=\"next-word\"></b></div>\n </div>\n <div id=\"tempo-track\" class=\"tempo-track\" hidden><div class=\"tempo-rail\"><div id=\"tempo-progress\"></div></div><div class=\"tempo-caption\"><span id=\"tempo-caption\">find the beat</span><span id=\"tempo-lives\" aria-label=\"Three beats remaining\"><i></i><i></i><i></i></span></div></div>\n <div class=\"input-row\" id=\"input-row\">\n <input id=\"typing-input\" class=\"typing-input\" type=\"text\" inputmode=\"text\" enterkeyhint=\"Line truncated
const escape = (s) => String(s ?? "").replace(/[&<>"\x27]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","\x27":"&#39;"}[c]));
export function renderPage(meta = {}) {
const title = escape(meta.title || "typy — one word at a time");
+73
View File
@@ -62,6 +62,79 @@ test('last-letter mode advances immediately with the same word output scoring',
assert.equal(letter.result(1100).stats.bestStreak, 1);
});
test('last-letter mode accepts one habitual separator without an extra attempt or output', () => {
const game = new TypingGame({ words: ['cat', 'dog'], mode: 'endless', advance: 'letter' });
type(game, 'cat', 0);
const before = game.result(300);
const state = game.press(' ', 400);
const after = game.result(400);
assert.equal(state.word, 'dog');
assert.equal(state.buffer, '');
assert.equal(state.errorIndex, -1);
assert.equal(state.correctChars, 4);
assert.equal(state.keystrokes, 3);
assert.equal(state.errors, 0);
assert.deepEqual(after.events, before.events);
type(game, 'dog ', 400);
game.finish(1100);
const result = game.result(1100);
assert.equal(result.stats.completedWords, 2);
assert.equal(result.stats.correctChars, 8);
assert.equal(result.stats.keystrokes, 6);
assert.equal(result.stats.accuracy, 100);
assert.equal(result.stats.bestStreak, 2);
assert.deepEqual(summarizeRun(result), result.stats);
});
test('last-letter separator tolerance does not hide extra or mid-word spaces', () => {
for (const nextKeys of [' ', 'd ']) {
const game = new TypingGame({ words: ['cat', 'dog'], mode: 'endless', advance: 'letter' });
type(game, 'cat', 0);
type(game, nextKeys, 300);
const state = game.snapshot(500);
assert.equal(state.wordIndex, 1);
assert.equal(state.buffer, nextKeys === ' ' ? ' ' : 'd ');
assert.equal(state.errors, 1);
assert.equal(state.errorIndex, nextKeys === ' ' ? 0 : 1);
game.press('Backspace', 600);
type(game, nextKeys === ' ' ? 'dog' : 'og', 600);
game.finish(1500);
const result = game.result(1500);
assert.equal(result.stats.completedWords, 2);
assert.equal(result.stats.correctChars, 8);
assert.equal(result.stats.errors, 1);
assert.equal(result.stats.accuracy, 85.7);
assert.equal(result.stats.bestStreak, 1);
assert.deepEqual(summarizeRun(result), result.stats);
}
});
for (const advance of ['space', 'letter']) {
for (const targetWpm of [100, 160]) {
test(`${advance} advancement scores steady ${targetWpm} WPM spaced typing accurately`, () => {
for (const mode of ['30', '60']) {
const game = new TypingGame({ words: ['test'], mode, advance });
const durationMs = Number(mode) * 1000;
const intervalMs = 12000 / targetWpm;
const outputChars = durationMs / intervalMs;
const input = 'test '.repeat(outputChars / 5);
for (let i = 0; i < input.length; i++) game.press(input[i], i * intervalMs);
const result = game.result(durationMs);
assert.equal(result.durationMs, durationMs);
assert.equal(result.finishedReason, 'time');
assert.equal(result.stats.wpm, targetWpm);
assert.equal(result.stats.correctChars, outputChars);
assert.equal(result.stats.completedWords, outputChars / 5);
assert.equal(result.stats.keystrokes, outputChars * 4 / 5);
assert.equal(result.stats.accuracy, 100);
assert.equal(result.stats.errors, 0);
assert.equal(game.snapshot(durationMs).wpm, targetWpm);
assert.deepEqual(summarizeRun(result), result.stats);
}
});
}
}
test('WPM measures net output and accuracy measures attempts, without deletion farming', () => {
const game = new TypingGame({ words: ['cat'], mode: 'endless' });
game.press('c', 0);
+81
View File
@@ -0,0 +1,81 @@
// Optional real-browser regressions for fast typing. Motion stays enabled.
// Start npm start, then run CHROME=/path/to/chrome node test/responsiveness.mjs.
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {writeFile, mkdir} from 'node:fs/promises';
const require = createRequire(import.meta.url);
const {chromium} = require(process.env.CODEX_PRIMARY_RUNTIME_NODE_MODULES
? process.env.CODEX_PRIMARY_RUNTIME_NODE_MODULES + '/playwright' : 'playwright');
const base = (process.env.TYPY_BASE_URL || 'http://127.0.0.1:8787').replace(/\/$/, '');
const browser = await chromium.launch({executablePath:process.env.CHROME || chromium.executablePath(),headless:true,args:['--no-sandbox']});
const checks = [];
const errors = [];
try {
const page = await browser.newPage({viewport:{width:1440,height:1000},reducedMotion:'no-preference',ignoreHTTPSErrors:process.env.TYPY_IGNORE_HTTPS_ERRORS === '1'});
page.on('pageerror',error=>errors.push(error.message));
await page.goto(base + '/');
await page.locator('#word[data-word]').waitFor();
const idleMutations = await page.evaluate(async () => {
let changes = 0;
const observer = new MutationObserver(rows => { changes += rows.length; });
observer.observe(document.getElementById('play-panel'), {subtree:true,childList:true,attributes:true,characterData:true});
await new Promise(resolve => setTimeout(resolve, 400));
observer.disconnect();
return changes;
});
assert.equal(idleMutations,0,'The ready stage must not continuously redraw itself.');
checks.push({name:'Ready stage stays still',idleMutations});
async function paste(text, advance = 'space') {
await page.locator('[data-mode=endless]').click();
await page.locator('#settings-open').click();
await page.locator('#advance-setting').selectOption(advance);
await page.locator('#settings-done').click();
await page.locator('#source-open').click();
await page.locator('#tab-paste').click();
await page.locator('#paste-input').fill(text);
await page.locator('#source-paste button[type=submit]').click();
}
await paste('calm river');
await page.evaluate(() => { window.firstLetter = document.querySelector('#word span'); });
await page.keyboard.type('c');
assert.equal(await page.evaluate(() => window.firstLetter === document.querySelector('#word span')),true,'A key must update existing letters, not replace the word.');
assert.equal(await page.locator('#word .correct').count(),1);
await page.keyboard.type('alm ');
const transition = await page.evaluate(() => {
const word = document.getElementById('word'), style = getComputedStyle(word);
return {word:word.dataset.word,opacity:style.opacity,transform:style.transform,animations:word.getAnimations().length};
});
assert.deepEqual(transition,{word:'river',opacity:'1',transform:'none',animations:0},'The next word must be fully readable immediately, with normal motion settings.');
await page.keyboard.type('r');
assert.equal(await page.locator('#word .correct').count(),1);
checks.push({name:'Immediate stationary target and persistent letters',...transition});
for (const advance of ['space','letter']) {
await paste('a calm river moves through the city',advance);
await page.keyboard.type('a calm river moves through the city ',{delay:75});
assert.equal(await page.locator('#word').getAttribute('data-word'),'a');
assert.equal(await page.locator('#live-accuracy').textContent(),'100');
assert.equal(await page.locator('#word .incorrect').count(),0);
checks.push({name:'Normal spaced typing at 160 WPM input cadence',advance,accuracy:100});
}
for (const width of [390,360]) {
await page.setViewportSize({width,height:844});
await paste('a'.repeat(64) + ' next','letter');
const before = await page.evaluate(() => ({viewport:innerWidth,document:document.documentElement.scrollWidth}));
assert.ok(before.document <= before.viewport + 1);
await page.locator('#typing-input').fill('a'.repeat(64));
const after = await page.evaluate(() => ({word:document.getElementById('word').dataset.word,viewport:innerWidth,document:document.documentElement.scrollWidth,opacity:getComputedStyle(document.getElementById('word')).opacity}));
assert.equal(after.word,'next');
assert.equal(after.opacity,'1');
assert.ok(after.document <= after.viewport + 1);
checks.push({name:'Long phone word and instant transition fit',width,before,after});
}
assert.deepEqual(errors,[]);
const report = {checkedAt:new Date().toISOString(),base,motion:'no-preference',checks,errors};
await mkdir('evidence',{recursive:true});
await writeFile('evidence/responsiveness.json',JSON.stringify(report,null,2) + '\n');
console.log(JSON.stringify(report,null,2));
} finally { await browser.close(); }