From 7ef789f29415dafcab3912fef14b11cdf1f0dcc1 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Sat, 24 Oct 2026 23:27:50 +0000 Subject: [PATCH] fix(typy): make word transitions immediate and fast typing forgiving --- typy/README.md | 6 +- typy/evidence/performance-after.json | 342 +++++++++++++++++ typy/evidence/performance-before.json | 358 ++++++++++++++++++ typy/evidence/responsiveness-integration.json | 28 ++ typy/evidence/responsiveness.json | 57 +++ typy/public/app.js | 150 +++++--- typy/public/index.html | 1 - typy/public/style.css | 4 +- typy/src/assets.js | 2 +- typy/src/game.js | 10 + typy/src/page.js | 2 +- typy/test/game.test.js | 73 ++++ typy/test/responsiveness.mjs | 81 ++++ 13 files changed, 1052 insertions(+), 62 deletions(-) create mode 100644 typy/evidence/performance-after.json create mode 100644 typy/evidence/performance-before.json create mode 100644 typy/evidence/responsiveness-integration.json create mode 100644 typy/evidence/responsiveness.json create mode 100644 typy/test/responsiveness.mjs diff --git a/typy/README.md b/typy/README.md index a94977f..5c472ef 100644 --- a/typy/README.md +++ b/typy/README.md @@ -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 diff --git a/typy/evidence/performance-after.json b/typy/evidence/performance-after.json new file mode 100644 index 0000000..29801df --- /dev/null +++ b/typy/evidence/performance-after.json @@ -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 + } + } + ] +} \ No newline at end of file diff --git a/typy/evidence/performance-before.json b/typy/evidence/performance-before.json new file mode 100644 index 0000000..1096a70 --- /dev/null +++ b/typy/evidence/performance-before.json @@ -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 + } + } + ] +} \ No newline at end of file diff --git a/typy/evidence/responsiveness-integration.json b/typy/evidence/responsiveness-integration.json new file mode 100644 index 0000000..8ec7a61 --- /dev/null +++ b/typy/evidence/responsiveness-integration.json @@ -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": [] +} diff --git a/typy/evidence/responsiveness.json b/typy/evidence/responsiveness.json new file mode 100644 index 0000000..1fe30ef --- /dev/null +++ b/typy/evidence/responsiveness.json @@ -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": [] +} diff --git a/typy/public/app.js b/typy/public/app.js index 809bfe4..9cdf0d7 100644 --- a/typy/public/app.js +++ b/typy/public/app.js @@ -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 '' + escapeHTML(char) + ''; - }).join('') + (typed.length > chars.length ? '' : ''); - $('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); diff --git a/typy/public/index.html b/typy/public/index.html index 3d407e8..ca18115 100644 --- a/typy/public/index.html +++ b/typy/public/index.html @@ -58,7 +58,6 @@
-
diff --git a/typy/public/style.css b/typy/public/style.css index dc4acc6..0db6932 100644 --- a/typy/public/style.css +++ b/typy/public/style.css @@ -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;background:var(--orange-soft);border-radius:4px}.word .cursor{position:relative}.word .cursor:after{content:"";position:absolute;left:4px;right:1px;bottom:-10px;height:3px;background:var(--orange)}.word .overflow{color:var(--orange);font-size:.55em;vertical-align:middle;margin-left:10px}.previous-word{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none;opacity:0;color:var(--ink)}.upcoming{position:absolute;right:8%;bottom:15px;display:flex;align-items:baseline;gap:13px;color:var(--faint);max-width:80%}.upcoming span{font-family:var(--mono);font-size:8px;color:var(--muted);white-space:nowrap}.upcoming b{font-family:var(--mono);font-size:23px;letter-spacing:-1px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.input-row{text-align:center;min-height:93px;padding-top:20px}.typing-input{position:absolute;width:1px;height:1px;padding:0;opacity:0;overflow:hidden;border:0;pointer-events:none}.run-hint{display:flex;align-items:center;justify-content:center;gap:12px;color:var(--muted);font-size:11px;font-family:var(--mono);line-height:2;margin:15px 0}.hint-start{color:var(--ink)}.hint-separator{color:var(--faint)}kbd{font-size:9px;font-weight:400;font-family:var(--mono);padding:3px 6px;border:1px solid #d8dbcf;border-radius:4px;color:var(--muted);white-space:nowrap}.text-button{font-family:var(--mono);font-size:11px;padding:7px 0;line-height:1.5}.footer{border-top:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:16px;height:77px;flex-shrink:0;color:var(--muted);font-family:var(--mono);font-size:10px;margin-top:25px}.footer>div{display:flex;align-items:center;gap:26px}.footer a{font-size:9px}.footer a:hover{color:var(--ink)}.footer kbd{font-size:8px;margin-right:4px}.tempo-track{max-width:460px;margin:0 auto 0;padding-top:10px}.tempo-rail{height:3px;border-radius:5px;background:var(--line);overflow:hidden}.tempo-rail>div{height:100%;width:100%;background:var(--orange);transform-origin:left center}.tempo-caption{display:flex;justify-content:space-between;margin-top:11px;font-family:var(--mono);font-size:10px;color:var(--muted)}#tempo-lives{display:flex;gap:6px;align-items:center}#tempo-lives i{width:7px;height:7px;border-radius:50%;border:1px solid var(--orange);background:var(--orange)}#tempo-lives i.lost{background:transparent;opacity:.35}.ghost-race{display:flex;align-items:center;justify-content:center;gap:12px;font-family:var(--mono);font-size:11px;margin-top:26px;margin-bottom:-20px}.ghost-race b{font-weight:500}.ghost-label{color:var(--muted)}.race-separator{color:var(--line)}#race-delta{color:var(--orange);font-size:9px;min-width:70px}.load-panel{min-height:510px;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;padding:50px 20px}.load-panel h1{font-size:32px;font-weight:400;letter-spacing:-1.2px}.load-panel>p:last-of-type{max-width:500px;font-size:14px;line-height:1.7;color:var(--muted)}.spinner{width:22px;height:22px;border:2px solid var(--line);border-top-color:var(--orange);border-radius:50%;animation:spin 1s linear infinite;margin-bottom:20px}@keyframes spin{to{transform:rotate(360deg)}}.button-row{display:flex;gap:12px;margin-top:16px}.button{display:inline-flex;align-items:center;justify-content:center;gap:20px;min-height:46px;border:1px solid var(--line);padding:11px 22px;border-radius:7px;font-size:12px;transition:background .15s,transform .15s;white-space:nowrap}.button:not(:disabled):hover{background:var(--green-soft)}.button:not(:disabled):active{transform:translateY(1px)}.button.primary{background:var(--ink);color:var(--paper);border-color:var(--ink)}.button.primary:not(:disabled):hover{background:#385447}.button.small{min-height:36px;padding:8px 14px}.button.full{width:100%}.result-panel{max-width:820px;margin:27px auto 35px}.result-heading{display:flex;align-items:center;gap:20px;justify-content:space-between}.eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.2px;color:var(--muted);line-height:1.5}.result-panel h1{font-size:38px;letter-spacing:-1.6px;font-weight:400;margin:14px 0 30px}.new-best{font-family:var(--mono);font-size:9px;color:var(--orange);border:1px solid #e36c4933;padding:6px 9px;border-radius:4px;white-space:nowrap}.result-grid{display:flex;gap:55px;align-items:center;margin-bottom:30px}.result-main{display:flex;flex-direction:column;gap:2px;flex:1}.result-main>span:first-child{font-family:var(--mono);font-size:108px;line-height:1.15;letter-spacing:-8px;font-weight:400}.result-main>span:last-child{font-size:12px;color:var(--muted);padding-left:3px}.result-detail{flex:1.15;display:flex;flex-direction:column;gap:20px;margin:0;border-left:1px solid var(--line);padding-left:42px}.result-detail>div{display:flex;align-items:baseline;gap:20px;justify-content:space-between}.result-detail dt{font-family:var(--mono);font-size:10px;color:var(--muted)}.result-detail dd{font-family:var(--mono);font-size:16px;margin:0;text-align:right;white-space:nowrap}.rhythm-chart{border-top:1px solid var(--line);padding-top:14px}.chart-label{display:flex;justify-content:space-between;font-family:var(--mono);font-size:9px;color:var(--muted)}.rhythm-chart svg{width:100%;height:86px;margin-top:12px;overflow:visible}.slow-words{display:flex;gap:24px;align-items:center;font-family:var(--mono);font-size:10px;padding:8px 0 24px;border-bottom:1px solid var(--line)}.slow-words>span{color:var(--muted);white-space:nowrap}.slow-words>div{display:flex;gap:20px;flex-wrap:wrap}.slow-word{display:flex;gap:7px}.slow-word small{color:var(--muted);font-size:9px;align-self:center}.result-actions{display:flex;align-items:center;gap:10px;margin-top:24px;flex-wrap:wrap}.result-actions>.text-button{margin-left:auto}.result-note{font-size:11px;color:var(--muted);line-height:1.7;margin-top:16px;min-height:18px}.daily-board-button{color:var(--orange)}.replay-controls{display:flex;align-items:center;gap:15px;margin:28px auto 25px;max-width:800px;flex-wrap:wrap}.replay-controls input{flex:1;min-width:100px;padding:0;border:0;box-shadow:none;accent-color:var(--orange)}.replay-time{font-size:10px;font-family:var(--mono);color:var(--muted)}.replay-controls>.text-button{margin-left:auto}.dialog{border:1px solid var(--line);background:var(--paper);color:var(--ink);border-radius:15px;padding:38px;width:min(480px,calc(100vw - 32px));max-height:calc(100svh - 32px);box-shadow:0 25px 100px #263d3320;overflow:auto}.dialog.wide{width:min(590px,calc(100vw - 32px))}.dialog::backdrop{background:#172b2445;backdrop-filter:blur(6px)}.dialog-close{position:absolute;top:15px;right:17px;font-size:27px;font-weight:300;color:var(--muted);width:32px;height:32px;line-height:1}.dialog h2{font-size:29px;font-weight:400;letter-spacing:-1px;line-height:1.2;margin:15px 0 28px}.dialog p{line-height:1.7}.dialog label{display:block;font-size:12px;margin-bottom:12px;line-height:1.7}.dialog input[type=text],.dialog input[type=url],.dialog textarea{width:100%;font-size:14px}.dialog textarea{resize:vertical;min-height:130px;line-height:1.65}.dialog-note{font-size:11px;color:var(--muted);line-height:1.75;margin:18px 0 24px}.dialog .setting-row{display:flex;align-items:center;gap:15px;justify-content:space-between;padding:20px 0;border-bottom:1px solid var(--line);margin:0}.setting-row b{font-size:13px;font-weight:500}.setting-row small{display:block;color:var(--muted);font-size:10px;margin-top:5px}.setting-row select{font-size:11px;padding:10px;max-width:125px;flex-shrink:0}.setting-row .switch{appearance:none;width:38px;height:23px;padding:3px;border-radius:99px;background:var(--line);border:0;position:relative;cursor:pointer;flex-shrink:0}.setting-row .switch:before{content:"";display:block;width:17px;height:17px;border-radius:50%;background:var(--paper);box-shadow:0 1px 3px #0002;transition:transform .15s}.setting-row .switch:checked{background:var(--ink)}.setting-row .switch:checked:before{transform:translateX(15px)}.source-tabs{display:flex;gap:22px;border-bottom:1px solid var(--line);margin-bottom:26px}.source-tabs button{padding:0 0 12px;font-family:var(--mono);font-size:11px;color:var(--muted);position:relative}.source-tabs button[aria-selected=true]{color:var(--ink)}.source-tabs button[aria-selected=true]:after{content:"";position:absolute;bottom:-1px;left:0;right:0;height:2px;background:var(--orange)}.field-with-button{display:flex;gap:8px}.field-with-button input{min-width:0;flex:1}.field-with-button .button{padding:10px 17px}.topic-chips{display:flex;flex-wrap:wrap;gap:7px;margin:13px 0 0}.topic-chips button{font-family:var(--mono);font-size:9px;border:1px solid var(--line);padding:7px 10px;border-radius:99px}.topic-chips button:hover{background:var(--green-soft)}.paste-footer{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:14px}.paste-footer>span{font-family:var(--mono);font-size:10px;color:var(--muted)}.optional{color:var(--muted);font-size:10px}.inline-error{color:#a33b27;font-size:12px;padding:12px;background:var(--orange-soft);border-radius:6px}.help-steps p{display:flex;gap:20px;font-size:13px;padding-bottom:16px;border-bottom:1px solid var(--line)}.help-steps span{font-family:var(--mono);color:var(--orange);font-size:10px;padding-top:4px}.board-list{list-style:none;padding:0;margin:0}.board-list li{display:grid;grid-template-columns:24px minmax(0,1fr) 65px 50px;gap:12px;border-top:1px solid var(--line);padding:15px 0;font-family:var(--mono);font-size:11px;align-items:center}.board-list li>span:first-child{color:var(--muted);font-size:9px}.board-list a{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.board-list a:hover{color:var(--orange)}.board-list small{color:var(--muted);text-align:right}.board-list strong{text-align:right;font-weight:400}.empty-board{font-size:13px;padding:25px 0;color:var(--muted)}.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--ink);color:var(--paper);font-size:12px;line-height:1.5;padding:13px 23px;border-radius:8px;z-index:20;box-shadow:0 7px 30px #263d3320;max-width:calc(100vw - 32px);text-align:center}.mode-switch.locked{opacity:.7} +*{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-radius:4px}.word .cursor{position:relative}.word .cursor:after{content:"";position:absolute;left:4px;right:1px;bottom:-10px;height:3px;background:var(--orange)}.word .overflow{color:var(--orange);font-size:.55em;vertical-align:middle;margin-left:10px}.upcoming{position:absolute;left:0;right:0;top:calc(50% + 90px);display:flex;align-items:baseline;justify-content:center;gap:12px;color:#7c8875;max-width:100%;padding:0 24px}.upcoming span{font-family:var(--mono);font-size:8px;color:var(--muted);white-space:nowrap}.upcoming b{font-family:var(--mono);font-size:27px;letter-spacing:-1px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.input-row{text-align:center;min-height:93px;padding-top:20px}.typing-input{position:absolute;width:1px;height:1px;padding:0;opacity:0;overflow:hidden;border:0;pointer-events:none}.run-hint{display:flex;align-items:center;justify-content:center;gap:12px;color:var(--muted);font-size:11px;font-family:var(--mono);line-height:2;margin:15px 0}.hint-start{color:var(--ink)}.hint-separator{color:var(--faint)}kbd{font-size:9px;font-weight:400;font-family:var(--mono);padding:3px 6px;border:1px solid #d8dbcf;border-radius:4px;color:var(--muted);white-space:nowrap}.text-button{font-family:var(--mono);font-size:11px;padding:7px 0;line-height:1.5}.footer{border-top:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:16px;height:77px;flex-shrink:0;color:var(--muted);font-family:var(--mono);font-size:10px;margin-top:25px}.footer>div{display:flex;align-items:center;gap:26px}.footer a{font-size:9px}.footer a:hover{color:var(--ink)}.footer kbd{font-size:8px;margin-right:4px}.tempo-track{max-width:460px;margin:0 auto 0;padding-top:10px}.tempo-rail{height:3px;border-radius:5px;background:var(--line);overflow:hidden}.tempo-rail>div{height:100%;width:100%;background:var(--orange);transform-origin:left center}.tempo-caption{display:flex;justify-content:space-between;margin-top:11px;font-family:var(--mono);font-size:10px;color:var(--muted)}#tempo-lives{display:flex;gap:6px;align-items:center}#tempo-lives i{width:7px;height:7px;border-radius:50%;border:1px solid var(--orange);background:var(--orange)}#tempo-lives i.lost{background:transparent;opacity:.35}.ghost-race{display:flex;align-items:center;justify-content:center;gap:12px;font-family:var(--mono);font-size:11px;margin-top:26px;margin-bottom:-20px}.ghost-race b{font-weight:500}.ghost-label{color:var(--muted)}.race-separator{color:var(--line)}#race-delta{color:var(--orange);font-size:9px;min-width:70px}.load-panel{min-height:510px;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;padding:50px 20px}.load-panel h1{font-size:32px;font-weight:400;letter-spacing:-1.2px}.load-panel>p:last-of-type{max-width:500px;font-size:14px;line-height:1.7;color:var(--muted)}.spinner{width:22px;height:22px;border:2px solid var(--line);border-top-color:var(--orange);border-radius:50%;animation:spin 1s linear infinite;margin-bottom:20px}@keyframes spin{to{transform:rotate(360deg)}}.button-row{display:flex;gap:12px;margin-top:16px}.button{display:inline-flex;align-items:center;justify-content:center;gap:20px;min-height:46px;border:1px solid var(--line);padding:11px 22px;border-radius:7px;font-size:12px;transition:background .15s,transform .15s;white-space:nowrap}.button:not(:disabled):hover{background:var(--green-soft)}.button:not(:disabled):active{transform:translateY(1px)}.button.primary{background:var(--ink);color:var(--paper);border-color:var(--ink)}.button.primary:not(:disabled):hover{background:#385447}.button.small{min-height:36px;padding:8px 14px}.button.full{width:100%}.result-panel{max-width:820px;margin:27px auto 35px}.result-heading{display:flex;align-items:center;gap:20px;justify-content:space-between}.eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.2px;color:var(--muted);line-height:1.5}.result-panel h1{font-size:38px;letter-spacing:-1.6px;font-weight:400;margin:14px 0 30px}.new-best{font-family:var(--mono);font-size:9px;color:var(--orange);border:1px solid #e36c4933;padding:6px 9px;border-radius:4px;white-space:nowrap}.result-grid{display:flex;gap:55px;align-items:center;margin-bottom:30px}.result-main{display:flex;flex-direction:column;gap:2px;flex:1}.result-main>span:first-child{font-family:var(--mono);font-size:108px;line-height:1.15;letter-spacing:-8px;font-weight:400}.result-main>span:last-child{font-size:12px;color:var(--muted);padding-left:3px}.result-detail{flex:1.15;display:flex;flex-direction:column;gap:20px;margin:0;border-left:1px solid var(--line);padding-left:42px}.result-detail>div{display:flex;align-items:baseline;gap:20px;justify-content:space-between}.result-detail dt{font-family:var(--mono);font-size:10px;color:var(--muted)}.result-detail dd{font-family:var(--mono);font-size:16px;margin:0;text-align:right;white-space:nowrap}.rhythm-chart{border-top:1px solid var(--line);padding-top:14px}.chart-label{display:flex;justify-content:space-between;font-family:var(--mono);font-size:9px;color:var(--muted)}.rhythm-chart svg{width:100%;height:86px;margin-top:12px;overflow:visible}.slow-words{display:flex;gap:24px;align-items:center;font-family:var(--mono);font-size:10px;padding:8px 0 24px;border-bottom:1px solid var(--line)}.slow-words>span{color:var(--muted);white-space:nowrap}.slow-words>div{display:flex;gap:20px;flex-wrap:wrap}.slow-word{display:flex;gap:7px}.slow-word small{color:var(--muted);font-size:9px;align-self:center}.result-actions{display:flex;align-items:center;gap:10px;margin-top:24px;flex-wrap:wrap}.result-actions>.text-button{margin-left:auto}.result-note{font-size:11px;color:var(--muted);line-height:1.7;margin-top:16px;min-height:18px}.daily-board-button{color:var(--orange)}.replay-controls{display:flex;align-items:center;gap:15px;margin:28px auto 25px;max-width:800px;flex-wrap:wrap}.replay-controls input{flex:1;min-width:100px;padding:0;border:0;box-shadow:none;accent-color:var(--orange)}.replay-time{font-size:10px;font-family:var(--mono);color:var(--muted)}.replay-controls>.text-button{margin-left:auto}.dialog{border:1px solid var(--line);background:var(--paper);color:var(--ink);border-radius:15px;padding:38px;width:min(480px,calc(100vw - 32px));max-height:calc(100svh - 32px);box-shadow:0 25px 100px #263d3320;overflow:auto}.dialog.wide{width:min(590px,calc(100vw - 32px))}.dialog::backdrop{background:#172b2445;backdrop-filter:blur(6px)}.dialog-close{position:absolute;top:15px;right:17px;font-size:27px;font-weight:300;color:var(--muted);width:32px;height:32px;line-height:1}.dialog h2{font-size:29px;font-weight:400;letter-spacing:-1px;line-height:1.2;margin:15px 0 28px}.dialog p{line-height:1.7}.dialog label{display:block;font-size:12px;margin-bottom:12px;line-height:1.7}.dialog input[type=text],.dialog input[type=url],.dialog textarea{width:100%;font-size:14px}.dialog textarea{resize:vertical;min-height:130px;line-height:1.65}.dialog-note{font-size:11px;color:var(--muted);line-height:1.75;margin:18px 0 24px}.dialog .setting-row{display:flex;align-items:center;gap:15px;justify-content:space-between;padding:20px 0;border-bottom:1px solid var(--line);margin:0}.setting-row b{font-size:13px;font-weight:500}.setting-row small{display:block;color:var(--muted);font-size:10px;margin-top:5px}.setting-row select{font-size:11px;padding:10px;max-width:125px;flex-shrink:0}.setting-row .switch{appearance:none;width:38px;height:23px;padding:3px;border-radius:99px;background:var(--line);border:0;position:relative;cursor:pointer;flex-shrink:0}.setting-row .switch:before{content:"";display:block;width:17px;height:17px;border-radius:50%;background:var(--paper);box-shadow:0 1px 3px #0002;transition:transform .15s}.setting-row .switch:checked{background:var(--ink)}.setting-row .switch:checked:before{transform:translateX(15px)}.source-tabs{display:flex;gap:22px;border-bottom:1px solid var(--line);margin-bottom:26px}.source-tabs button{padding:0 0 12px;font-family:var(--mono);font-size:11px;color:var(--muted);position:relative}.source-tabs button[aria-selected=true]{color:var(--ink)}.source-tabs button[aria-selected=true]:after{content:"";position:absolute;bottom:-1px;left:0;right:0;height:2px;background:var(--orange)}.field-with-button{display:flex;gap:8px}.field-with-button input{min-width:0;flex:1}.field-with-button .button{padding:10px 17px}.topic-chips{display:flex;flex-wrap:wrap;gap:7px;margin:13px 0 0}.topic-chips button{font-family:var(--mono);font-size:9px;border:1px solid var(--line);padding:7px 10px;border-radius:99px}.topic-chips button:hover{background:var(--green-soft)}.paste-footer{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:14px}.paste-footer>span{font-family:var(--mono);font-size:10px;color:var(--muted)}.optional{color:var(--muted);font-size:10px}.inline-error{color:#a33b27;font-size:12px;padding:12px;background:var(--orange-soft);border-radius:6px}.help-steps p{display:flex;gap:20px;font-size:13px;padding-bottom:16px;border-bottom:1px solid var(--line)}.help-steps span{font-family:var(--mono);color:var(--orange);font-size:10px;padding-top:4px}.board-list{list-style:none;padding:0;margin:0}.board-list li{display:grid;grid-template-columns:24px minmax(0,1fr) 65px 50px;gap:12px;border-top:1px solid var(--line);padding:15px 0;font-family:var(--mono);font-size:11px;align-items:center}.board-list li>span:first-child{color:var(--muted);font-size:9px}.board-list a{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.board-list a:hover{color:var(--orange)}.board-list small{color:var(--muted);text-align:right}.board-list strong{text-align:right;font-weight:400}.empty-board{font-size:13px;padding:25px 0;color:var(--muted)}.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--ink);color:var(--paper);font-size:12px;line-height:1.5;padding:13px 23px;border-radius:8px;z-index:20;box-shadow:0 7px 30px #263d3320;max-width:calc(100vw - 32px);text-align:center}.mode-switch.locked{opacity:.7} @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}} diff --git a/typy/src/assets.js b/typy/src/assets.js index 139e00d..30934cc 100644 --- a/typy/src/assets.js +++ b/typy/src/assets.js @@ -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 => ({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[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' : 'space 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 $('play-panel').hidden = false;\n $('result-panel').hidden = true;\n $('replay-controls').hidden = true;\n $('input-row').hidden = false;\n $('typing-input').value = '';\n $('typing-input').disabled = false;\n $('ghost-race').hidden = !ghost;\n $('finish-run').hidden = selectedMode !== 'endless' && selectedMode !== 'tempo';\n $('tempo-track').hidden = selectedMode !== 'tempo';\n $('restart-tip').hidden = false;\n updateOptions();\n renderGame(game.snapshot(now()));\n if (focus || innerWidth > 700) focusTyping();\n}\n\nfunction drawWord(word, buffer, index, nextWord, flags = null) {\n const chars = Array.from(word || '');\n const typed = Array.from(buffer || '');\n const key = word + '|' + buffer + '|' + index + '|' + (flags?.join('') || '');\n if (key === lastWordKey) return;\n const wordChanged = index !== lastWordIndex;\n if (wordChanged && lastWordIndex >= 0 && !reducedMotion) {\n $('previous-word').textContent = previousWord;\n $('previous-word').style.fontSize = getComputedStyle($('word')).fontSize;\n $('previous-word').classList.toggle('long', previousWord.length > 12);\n $('previous-word').getAnimations().forEach(animation => animation.cancel());\n $('previous-word').animate([{opacity:.18,transform:'translateX(0)'},{opacity:0,transform:'translateX(-35px)'}], {duration:180,easing:'ease-out'});\n }\n $('word').classList.toggle('long', chars.length > 12);\n $('word').classList.toggle('very-long', chars.length > 22);\n $('word').innerHTML = chars.map((char, i) => {\n let state = '';\n if (i < typed.length) state = (flags ? flags[i] : typed[i] === char) ? 'correct' : 'incorrect';\n if (i === typed.length) state += ' cursor';\n return '' + escapeHTML(char) + '';\n }).join('') + (typed.length > chars.length ? '+' + (typed.length - chars.length) + '' : '');\n $('word').setAttribute('aria-label', 'Word to type: ' + word);\n $('word').dataset.word = word;\n $('typing-input').setAttribute('aria-label', 'Type the word: ' + word);\n // A pasted token may be much longer than a normal word. Measure the actual\n // glyphs instead of letting it enlarge a phone's layout viewport.\n $('word').style.fontSize = '';\n const available = Math.max(1, $('stage').clientWidth - 28);\n if ($('word').scrollWidth > available) {\n const natural = parseFloat(getComputedStyle($('word')).fontSize);\n $('word').style.fontSize = Math.max(8, natural * available / $('word').scrollWidth * .96) + 'px';\n }\n $('word-number').textContent = String(index + 1).padStart(3, '0');\n $('next-word').textContent = nextWord || '';\n if (wordChanged && !reducedMotion) {\n $('word').getAnimations().forEach(animation => animation.cancel());\n $('word').animate([{opacity:.2,transform:'translateX(25px)'},{opacity:1,transform:'translateX(0)'}], {duration:170,easing:'ease-out'});\n }\n lastWordKey = key;\n lastWordIndex = index;\n previousWord = word;\n}\n\nfunction renderGame(state) {\n drawWord(state.word, state.buffer, state.wordIndex, state.nextWord);\n $('live-wpm').textContent = pretty(state.wpm);\n $('live-accuracy').textContent = pretty(state.accuracy);\n $('status-dot').classList.toggle('active', state.status === 'running');\n if (state.status === 'ready') $('run-status').textContent = ghost ? 'your ghost is ready' : source.kind === 'daily' ? 'same words, your rhythm' : 'a fresh start';\n else if (state.errorIndex >= 0) $('run-status').textContent = 'a little backspace, then onward';\n else if (state.waitingForBeat) $('run-status').textContent = 'right on time · wait for the beat';\n else $('run-status').textContent = state.streak >= 3 ? state.streak + ' clean words in a row' : 'one word, then the next';\n const hintStart = $('run-hint').querySelector('.hint-start');\n hintStart.textContent = state.status === 'ready' ? 'type the word to begin' : state.errorIndex >= 0 ? 'backspace to correct' : state.waitingForBeat ? 'hold that rhythm' : 'keep your rhythm';\n if (selectedMode === '30' || selectedMode === '60') {\n $('time-label').textContent = 'remaining'; $('live-time').textContent = Math.ceil(state.remainingMs / 1000); $('time-unit').textContent = 's';\n } else if (selectedMode === '50') {\n $('time-label').textContent = 'remaining'; $('live-time').textContent = Math.max(0, 50 - state.completedWords); $('time-unit').textContent = 'words';\n } else if (selectedMode === 'tempo') {\n $('time-label').textContent = 'beat pace'; $('live-time').textContent = pretty(state.paceWpm); $('time-unit').textContent = 'wpm';\n } else {\n $('time-label').textContent = 'in the flow'; $('live-time').textContent = Math.floor(state.elapsedMs / 1000); $('time-unit').textContent = 's';\n }\n if (selectedMode === 'tempo') {\n const fraction = state.beatDurationMs ? Math.max(0, state.beatRemainingMs / state.beatDurationMs) : 1;\n $('tempo-progress').style.transform = 'scaleX(' + fraction + ')';\n $('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';\n Array.from($('tempo-lives').children).forEach((dot, i) => dot.classList.toggle('lost', i < state.misses));\n $('tempo-lives').setAttribute('aria-label', (3 - state.misses) + ' missed beats remaining');\n }\n if (ghost) {\n const completed = ghost.wordTimings.filter(timing => timing.completed && timing.end <= state.elapsedMs).length;\n $('race-you').textContent = state.completedWords;\n $('race-them').textContent = completed;\n const difference = state.completedWords - completed;\n $('race-delta').textContent = state.status === 'ready' ? '' : difference === 0 ? 'neck and neck' : Math.abs(difference) + (difference > 0 ? ' ahead' : ' behind');\n }\n if (state.errors > lastErrorCount && !reducedMotion) {\n $('word').animate([{transform:'translateX(0)'},{transform:'translateX(-5px)'},{transform:'translateX(5px)'},{transform:'translateX(0)'}], {duration:160});\n }\n lastErrorCount = state.errors;\n if (state.status === 'finished' && !transitioning) finishDisplay();\n}\n\nfunction press(key) {\n if (view !== 'play' || !game || document.querySelector('dialog[open]')) return;\n renderGame(game.press(key, now()));\n}\n\nfunction finishDisplay() {\n transitioning = true;\n const run = game.result(now());\n const previousBest = Number(bests[selectedMode] || 0);\n const qualifies = run.finishedReason !== 'manual' || selectedMode === 'endless' || selectedMode === 'tempo';\n const isBest = qualifies && run.stats.completedWords > 0 && run.stats.wpm > previousBest;\n if (isBest) { bests[selectedMode] = run.stats.wpm; writeLocal('bests', bests); }\n if (run.durationMs >= 5000 && run.stats.wpm > 0) writeLocal('lastAverage', Math.max(10, Math.min(200, run.stats.wpm)));\n const ghostStats = ghost?.stats;\n currentResult = {run,source:{...source},name:'',local:true,isBest,ghostStats};\n shareId = null;\n ghost = null;\n $('typing-input').blur();\n displayResult(currentResult);\n}\n\nfunction displayResult(result) {\n view = 'result';\n replay.playing = false;\n currentResult = result;\n const run = result.run;\n const stats = run.stats;\n selectedMode = run.mode;\n source = result.source || {kind:'common'};\n passage = [...run.words];\n $('load-panel').hidden = true; $('play-panel').hidden = true; $('result-panel').hidden = false;\n $('result-kicker').textContent = (result.name ? result.name + ' · ' : '') + modeNames[run.mode] + (run.finishedReason === 'manual' && ['30','60','50'].includes(run.mode) ? ' · finished early' : ', well spent');\n $('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.';\n $('result-wpm').textContent = pretty(stats.wpm);\n $('result-accuracy').textContent = round1(stats.accuracy) + '%';\n $('result-streak').textContent = stats.bestStreak + (stats.bestStreak === 1 ? ' word' : ' words');\n $('result-words').textContent = stats.completedWords;\n $('new-best').hidden = !result.isBest;\n $('again-button').innerHTML = result.local ? 'go again ↵' : 'try these words ↵';\n $('share-button').innerHTML = shareId ? 'copy result ↗' : 'share result ↗';\n $('race-button').textContent = result.local ? 'challenge a friend →' : 'race this →';\n $('replay-button').disabled = !run.events?.length;\n $('share-button').disabled = !run.events?.length || run.replayTruncated;\n $('race-button').disabled = !run.events?.length || run.replayTruncated;\n $('board-open').hidden = source.kind !== 'daily';\n 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.';\n else if (source.kind === 'daily' && result.local) $('result-note').textContent = 'Share your result to add it to today’s board. A name is optional.';\n 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.';\n 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.';\n const slowest = stats.slowest?.slice(0, 3) || [];\n $('slow-words').innerHTML = slowest.length ? slowest.map(row => '' + escapeHTML(row.word) + '' + round1(row.ms / 1000) + 's').join('') : 'your next run starts here';\n const points = stats.sparkline || [];\n if (points.length) {\n const max = Math.max(20, ...points);\n const coords = points.map((value, i) => [(i / Math.max(1, points.length - 1)) * 896 + 2, 83 - (Math.min(value, max) / max) * 70]);\n const path = coords.map((point, i) => (i ? 'L' : 'M') + point[0].toFixed(1) + ',' + point[1].toFixed(1)).join(' ');\n $('speed-chart').innerHTML = '' + coords.filter((_, i) => i === 0 || i === coords.length - 1).map(point => '').join('');\n } else $('speed-chart').innerHTML = '';\n updateOptions();\n $('mode-switch').querySelectorAll('button').forEach(button => { button.disabled = false; });\n}\n\nfunction showLoading(title, description = '') {\n view = 'loading';\n replay.playing = false;\n $('play-panel').hidden = true; $('result-panel').hidden = true; $('load-panel').hidden = false;\n $('load-kicker').textContent = 'one moment';\n $('load-title').textContent = title; $('load-description').textContent = description;\n $('load-actions').hidden = true; $('loading-spinner').hidden = false;\n}\n\nfunction showRouteError(error) {\n view = 'loading';\n $('load-panel').hidden = false; $('play-panel').hidden = true; $('result-panel').hidden = true;\n $('loading-spinner').hidden = true; $('load-actions').hidden = false;\n $('load-kicker').textContent = error.status === 429 ? 'a little breathing room' : 'a small interruption';\n $('load-title').textContent = error.status === 404 ? 'that rhythm is missing.' : 'the words couldn’t land.';\n $('load-description').textContent = error.message;\n}\n\nasync function route(path, push = true) {\n const serial = ++routeSerial;\n document.querySelectorAll('dialog[open]').forEach(dialog => dialog.close());\n if (push) history.pushState({}, '', path);\n currentPath = path;\n ghost = null; shareId = null;\n if (path === '/') {\n source = {kind:'common'};\n currentResult = null;\n passage = newCommonPassage();\n beginRun();\n document.title = 'typy — one word at a time';\n return;\n }\n try {\n if (path === '/daily' || path === '/daily/') {\n showLoading('today’s words, for everyone.');\n const response = await api('/api/daily');\n if (serial !== routeSerial) return;\n passage = response.words;\n source = {kind:'daily',day:response.day};\n selectedMode = '60';\n currentResult = null;\n beginRun();\n document.title = 'the daily — typy';\n } else if (/^\\/(r|race)\\/[a-zA-Z0-9_-]+\\/?$/.test(path)) {\n const [,kind,id] = path.split('/');\n showLoading(kind === 'race' ? 'a ghost is taking its place.' : 'finding that rhythm.');\n const response = await api('/api/results/' + encodeURIComponent(id));\n if (serial !== routeSerial) return;\n const result = {...response,local:false};\n if (!result.run?.words?.length || !result.run.stats) throw new Error('This replay is incomplete. Please try another result.');\n currentResult = result;\n shareId = result.id || id;\n source = result.source || {kind:'common'};\n passage = [...result.run.words];\n selectedMode = result.run.mode;\n if (kind === 'race') {\n ghost = result.run;\n beginRun();\n } else displayResult(result);\n document.title = pretty(result.run.stats.wpm) + ' wpm — typy';\n } else {\n const topic = decodeURIComponent(path).replace(/^\\/|\\/$/g, '');\n if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(topic)) throw Object.assign(new Error('Try a short topic with letters and hyphens, such as /react-hooks.'), {status:404});\n source = {kind:'topic',topic};\n updateOptions();\n showLoading('a little about ' + topic.replaceAll('-', ' ') + '.', 'Making a short passage for your next run.');\n const response = await api('/api/passage?topic=' + encodeURIComponent(topic));\n if (serial !== routeSerial) return;\n passage = response.words;\n source = {kind:'topic',topic:response.topic || topic};\n currentResult = null;\n beginRun();\n document.title = topic.replaceAll('-', ' ') + ' — typy';\n }\n } catch (error) { if (serial === routeSerial) showRouteError(error); }\n}\n\nfunction startReplay() {\n if (!currentResult?.run) return;\n view = 'replay';\n ghost = null;\n lastWordKey = ''; lastWordIndex = -1;\n $('result-panel').hidden = true; $('play-panel').hidden = false;\n $('input-row').hidden = true; $('replay-controls').hidden = false;\n $('ghost-race').hidden = true; $('tempo-track').hidden = true;\n $('run-status').textContent = 'a rhythm, replayed';\n $('status-dot').classList.add('active');\n $('replay-scrubber').max = Math.max(1, currentResult.run.durationMs);\n $('replay-scrubber').value = '0';\n $('time-label').textContent = 'replay'; $('time-unit').textContent = 's';\n replay = {playing:true,position:0,anchor:now(),anchorPosition:0};\n $('replay-play').textContent = 'pause';\n updateOptions();\n renderReplay(0);\n}\n\nfunction clock(ms) {\n const seconds = Math.max(0, Math.floor(ms / 1000));\n return Math.floor(seconds / 60) + ':' + String(seconds % 60).padStart(2, '0');\n}\n\nfunction renderReplay(position) {\n const run = currentResult.run;\n let timing = run.wordTimings[0];\n for (const candidate of run.wordTimings) {\n if (candidate.start <= position) timing = candidate;\n else break;\n }\n const index = timing?.index || 0;\n const word = run.words[index % run.words.length];\n const flags = [];\n for (const event of run.events) {\n if (event.t > position) break;\n if (event.index !== index) continue;\n if (event.type === 'hit') flags.push(true);\n else if (event.type === 'miss') flags.push(false);\n else if (event.type === 'backspace') flags.pop();\n }\n const characters = Array.from(word);\n const buffer = flags.map((flag, i) => flag ? characters[i] || '' : '·').join('');\n drawWord(word, buffer, index, run.words[(index + 1) % run.words.length], flags);\n $('live-wpm').textContent = pretty(run.stats.wpm);\n $('live-accuracy').textContent = pretty(run.stats.accuracy);\n $('live-time').textContent = Math.floor(position / 1000);\n $('replay-scrubber').value = String(position);\n $('replay-time').textContent = clock(position) + ' / ' + clock(run.durationMs);\n}\n\nfunction openShare(intent = 'result') {\n if (!currentResult) return;\n shareIntent = intent;\n $('share-kicker').textContent = source.kind === 'daily' ? 'put your rhythm on the board' : 'a good rhythm is worth sharing';\n $('share-title').textContent = intent === 'race' ? 'a friendly challenge.' : 'leave a little ghost.';\n $('share-error').hidden = true;\n $('share-name').value = readLocal('name', '');\n $('share-create').hidden = Boolean(shareId);\n $('share-ready').hidden = !shareId;\n if (shareId) fillShareLink();\n $('share-dialog').showModal();\n}\n\nfunction fillShareLink() {\n $('share-url').value = location.origin + (shareIntent === 'race' ? '/race/' : '/r/') + shareId;\n $('share-url-label').textContent = shareIntent === 'race' ? 'Your challenge link' : 'Your result link';\n $('share-ready-note').textContent = shareIntent === 'race' ? 'Same words, same mode. Your friend races your original timing.' : 'Anyone with this link can watch your replay or race your ghost.';\n $('copy-link').textContent = 'copy';\n}\n\nasync function saveResult() {\n if (!currentResult || shareId) return;\n const button = $('save-result');\n button.disabled = true; button.textContent = 'saving your rhythm…';\n $('share-error').hidden = true;\n try {\n const name = $('share-name').value.trim();\n const response = await api('/api/results', {method:'POST',body:JSON.stringify({run:currentResult.run,source:currentResult.source,name})});\n if (!response.id) throw new Error('The server did not return a share link. Please try again.');\n shareId = response.id;\n currentResult = {...currentResult,id:shareId,name,boardPublished:response.boardPublished};\n writeLocal('name', name);\n $('share-create').hidden = true; $('share-ready').hidden = false;\n fillShareLink();\n history.replaceState({}, '', '/r/' + shareId);\n currentPath = '/r/' + shareId;\n $('share-button').innerHTML = 'copy result ↗';\n $('result-note').textContent = source.kind === 'daily'\n ? response.boardPublished === false\n ? 'Your replay is shared, but the daily board could not update. Your result link still works.'\n : 'Your result is shared. The daily board may take a moment to refresh.'\n : 'Your replay is ready to share.';\n if (source.kind === 'daily' && response.boardPublished === false) toast('Result saved. The daily board could not update this time.');\n } catch (error) {\n $('share-error').textContent = error.message;\n $('share-error').hidden = false;\n } finally { button.disabled = false; button.textContent = 'create share link ↗'; }\n}\n\nasync function copyShareLink() {\n try { await navigator.clipboard.writeText($('share-url').value); $('copy-link').textContent = 'copied ✓'; }\n catch { $('share-url').focus(); $('share-url').select(); toast('The link is selected. Copy it with your keyboard or press and hold.'); }\n}\n\nasync function loadBoard() {\n const day = source.day || new Date().toISOString().slice(0, 10);\n $('board-date').textContent = day + ' · midnight to midnight UTC';\n $('board-content').innerHTML = '

finding today’s rhythms…

';\n try {\n const response = await api('/api/board?day=' + encodeURIComponent(day));\n const entries = response.entries || [];\n $('board-content').innerHTML = entries.length ? '
    ' + entries.map((entry, i) => '
  1. ' + String(i + 1).padStart(2, '0') + '' + escapeHTML(entry.name || 'anonymous') + '' + pretty(entry.wpm) + ' wpm' + round1(entry.accuracy) + '%
  2. ').join('') + '
' : '

No shared runs yet. Set today’s rhythm.

';\n } catch (error) { $('board-content').innerHTML = '

' + escapeHTML(error.message) + '

'; }\n}\n\nfunction openBoard() { $('board-dialog').showModal(); loadBoard(); }\nfunction topicRoute(topic) {\n const slug = topic.trim().toLowerCase().normalize('NFKD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 64).replace(/-$/, '');\n if (!slug || ['api','r','race','daily'].includes(slug)) { toast('Try a few words about a topic, such as “the deep sea”.'); return; }\n route('/' + slug);\n}\n\nfunction restart({freshWords = false} = {}) {\n if (view === 'loading') return;\n if (view === 'result' && !currentResult?.local) {\n source = {...currentResult.source}; passage = [...currentResult.run.words]; selectedMode = currentResult.run.mode;\n }\n if (view === 'replay') replay.playing = false;\n // A local retry gets a clean address instead of replacing somebody else's result.\n if (view !== 'play') {\n const target = source.kind === 'daily' ? '/daily' : source.kind === 'topic' ? '/' + source.topic : '/';\n history.replaceState({}, '', target); currentPath = target;\n shareId = null;\n }\n beginRun({freshWords,focus:true});\n}\n\ndocument.addEventListener('keydown', event => {\n if (event.isComposing || event.ctrlKey || event.metaKey || event.altKey) return;\n if (document.querySelector('dialog[open]')) return;\n const target = event.target;\n if (target !== $('typing-input') && target.closest('input,textarea,select,[contenteditable=true]')) return;\n // Enter/Space must activate a focused control before they can be game\n // shortcuts. This keeps every toolbar and result action keyboard-accessible.\n if (target.closest('button,a') && (event.key === 'Enter' || event.key === ' ')) return;\n if (event.key === 'Escape') { event.preventDefault(); restart(); return; }\n if (view === 'result' && event.key === 'Enter') { event.preventDefault(); restart({freshWords:source.kind === 'common'}); return; }\n if (view !== 'play') return;\n if (event.key === 'Enter' && (selectedMode === 'endless' || selectedMode === 'tempo') && game.status === 'running') {\n event.preventDefault(); game.finish(now()); finishDisplay(); return;\n }\n if (target !== $('typing-input') && target.closest('button,a')) return;\n if (event.key === 'Backspace' || Array.from(event.key).length === 1) {\n event.preventDefault(); press(event.key);\n }\n});\n\nconst input = $('typing-input');\ninput.addEventListener('compositionstart', () => { isComposing = true; });\ninput.addEventListener('compositionend', event => {\n isComposing = false;\n const text = event.data || input.value;\n input.value = '';\n for (const char of text) press(char);\n});\ninput.addEventListener('beforeinput', event => {\n if (event.inputType === 'deleteContentBackward') { event.preventDefault(); press('Backspace'); }\n if (event.inputType === 'insertFromPaste') { event.preventDefault(); toast('Paste a passage in the words menu, then type it here.'); }\n});\ninput.addEventListener('input', event => {\n if (isComposing || event.isComposing) return;\n const value = input.value;\n input.value = '';\n for (const char of value) press(char);\n});\ninput.addEventListener('paste', event => { event.preventDefault(); toast('Use the words menu to choose a pasted passage.'); });\n$('stage').addEventListener('click', focusTyping);\n$('finish-run').addEventListener('click', () => { if (game?.status === 'running') { game.finish(now()); finishDisplay(); } else toast('Type the first word to start your run.'); });\n\ndocument.querySelectorAll('[data-mode]').forEach(button => button.addEventListener('click', () => {\n selectedMode = button.dataset.mode;\n if (source.kind === 'daily' || ghost || view === 'result') {\n ghost = null; source = {kind:'common'}; passage = newCommonPassage(); currentResult = null;\n history.pushState({}, '', '/'); currentPath = '/';\n }\n beginRun({freshWords:source.kind === 'common',focus:true});\n}));\n$('settings-open').addEventListener('click', () => { updateOptions(); $('settings-dialog').showModal(); });\n$('settings-done').addEventListener('click', () => {\n const changed = settings.advance !== $('advance-setting').value || settings.preview !== $('preview-setting').checked;\n if (!ghost) settings.advance = $('advance-setting').value;\n settings.preview = $('preview-setting').checked; writeLocal('settings',settings);\n $('settings-dialog').close();\n if (changed && view === 'play') beginRun({focus:true}); else updateOptions();\n});\n$('source-open').addEventListener('click', () => $('source-dialog').showModal());\n['topic','paste','common'].forEach(tab => $('tab-' + tab).addEventListener('click', () => {\n ['topic','paste','common'].forEach(id => { $('tab-' + id).setAttribute('aria-selected',String(id === tab)); $('source-' + id).hidden = id !== tab; });\n if (tab === 'topic') $('topic-input').focus();\n else if (tab === 'paste') $('paste-input').focus();\n}));\n$('source-topic').addEventListener('submit', event => { event.preventDefault(); topicRoute($('topic-input').value); });\ndocument.querySelectorAll('[data-topic]').forEach(button => button.addEventListener('click', () => topicRoute(button.dataset.topic)));\n$('paste-input').addEventListener('input', () => { const count = normalizePassage($('paste-input').value).length; $('paste-count').textContent = count + ' / 300 words'; });\n$('source-paste').addEventListener('submit', event => {\n event.preventDefault();\n const words = normalizePassage($('paste-input').value);\n if (!words.length) { toast('Add a few words first.'); return; }\n routeSerial++; source = {kind:'paste'}; passage = words; ghost = null; currentResult = null; shareId = null;\n $('source-dialog').close(); history.pushState({}, '', '/'); currentPath = '/'; beginRun({focus:true});\n});\n$('common-start').addEventListener('click', () => route('/'));\n$('load-common').addEventListener('click', () => route('/'));\n$('load-retry').addEventListener('click', () => route(currentPath,false));\n$('help-open').addEventListener('click', () => $('help-dialog').showModal());\n$('help-done').addEventListener('click', () => { $('help-dialog').close(); focusTyping(); });\n$('again-button').addEventListener('click', () => restart({freshWords:source.kind === 'common'}));\n$('replay-button').addEventListener('click', startReplay);\n$('replay-back').addEventListener('click', () => displayResult(currentResult));\n$('replay-play').addEventListener('click', () => {\n if (replay.position >= currentResult.run.durationMs) replay.position = 0;\n replay.playing = !replay.playing; replay.anchor = now(); replay.anchorPosition = replay.position;\n $('replay-play').textContent = replay.playing ? 'pause' : 'play';\n});\n$('replay-scrubber').addEventListener('input', () => {\n replay.position = Number($('replay-scrubber').value); replay.anchor = now(); replay.anchorPosition = replay.position;\n renderReplay(replay.position);\n});\n$('share-button').addEventListener('click', () => openShare('result'));\n$('race-button').addEventListener('click', () => {\n if (currentResult?.local) openShare('race');\n else if (shareId) route('/race/' + shareId);\n});\n$('save-result').addEventListener('click', saveResult);\n$('copy-link').addEventListener('click', copyShareLink);\n$('share-url').addEventListener('click', () => $('share-url').select());\n$('board-open').addEventListener('click', openBoard);\n$('footer-board').addEventListener('click', openBoard);\n$('board-refresh').addEventListener('click', loadBoard);\ndocument.querySelectorAll('dialog').forEach(dialog => {\n dialog.addEventListener('click', event => {\n if (event.target !== dialog) return;\n const rect = dialog.getBoundingClientRect();\n if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) dialog.close();\n });\n});\ndocument.addEventListener('click', event => {\n const anchor = event.target.closest('a');\n if (!anchor || anchor.target || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;\n const url = new URL(anchor.href);\n if (url.origin !== location.origin) return;\n event.preventDefault(); route(url.pathname);\n});\nwindow.addEventListener('popstate', () => route(location.pathname,false));\nwindow.addEventListener('resize', () => { lastWordKey = ''; });\n\nfunction frame(time) {\n if (view === 'play' && game) renderGame(game.tick(time));\n else if (view === 'replay' && replay.playing) {\n replay.position = Math.min(currentResult.run.durationMs, replay.anchorPosition + time - replay.anchor);\n renderReplay(replay.position);\n if (replay.position >= currentResult.run.durationMs) { replay.playing = false; $('replay-play').textContent = 'play again'; }\n }\n requestAnimationFrame(frame);\n}\nroute(location.pathname,false);\nrequestAnimationFrame(frame);\n","type":"text/javascript; charset=utf-8"},"/style.css":{"body":":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}\n*{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;background:var(--orange-soft);border-radius:4px}.word .cursor{position:relative}.word .cursor:after{content:\"\";position:absolute;left:4px;right:1px;bottom:-10px;height:3px;background:var(--orange)}.word .overflow{color:var(--orange);font-size:.55em;vertical-align:middle;margin-left:10px}.previous-word{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none;opacity:0;color:var(--ink)}.upcoming{position:absolute;right:8%;bottom:15px;display:flex;align-items:baseline;gap:13px;color:var(--faint);max-width:80%}.upcoming span{font-family:var(--mono);font-size:8px;color:var(--muted);white-space:nowrap}.upcoming b{font-family:var(--mono);font-size:23px;letter-spacing:-1px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.input-row{text-align:center;min-height:93px;padding-top:20px}.typing-input{position:absolute;width:1px;height:1px;padding:0;opacity:0;overflow:hidden;border:0;pointer-events:none}.run-hint{display:flex;align-items:center;justify-content:center;gap:12px;color:var(--muted);font-size:11px;font-family:var(--mono);line-height:2;margin:15px 0}.hint-start{color:var(--ink)}.hint-separator{color:var(--faint)}kbd{font-size:9px;font-weight:400;font-family:var(--mono);padding:3px 6px;border:1px solid #d8dbcf;border-radius:4px;color:var(--muted);white-space:nowrap}.text-button{font-family:var(--mono);font-size:11px;padding:7px 0;line-height:1.5}.footer{border-top:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:16px;height:77px;flex-shrink:0;color:var(--muted);font-family:var(--mono);font-size:10px;margin-top:25px}.footer>div{display:flex;align-items:center;gap:26px}.footer a{font-size:9px}.footer a:hover{color:var(--ink)}.footer kbd{font-size:8px;margin-right:4px}.tempo-track{max-width:460px;margin:0 auto 0;padding-top:10px}.tempo-rail{height:3px;border-radius:5px;background:var(--line);overflow:hidden}.tempo-rail>div{height:100%;width:100%;background:var(--orange);transform-origin:left center}.tempo-caption{display:flex;justify-content:space-between;margin-top:11px;font-family:var(--mono);font-size:10px;color:var(--muted)}#tempo-lives{display:flex;gap:6px;align-items:center}#tempo-lives i{width:7px;height:7px;border-radius:50%;border:1px solid var(--orange);background:var(--orange)}#tempo-lives i.lost{background:transparent;opacity:.35}.ghost-race{display:flex;align-items:center;justify-content:center;gap:12px;font-family:var(--mono);font-size:11px;margin-top:26px;margin-bottom:-20px}.ghost-race b{font-weight:500}.ghost-label{color:var(--muted)}.race-separator{color:var(--line)}#race-delta{color:var(--orange);font-size:9px;min-width:70px}.load-panel{min-height:510px;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;padding:50px 20px}.load-panel h1{font-size:32px;font-weight:400;letter-spacing:-1.2px}.load-panel>p:last-of-type{max-width:500px;font-size:14px;line-height:1.7;color:var(--muted)}.spinner{width:22px;height:22px;border:2px solid var(--line);border-top-color:var(--orange);border-radius:50%;animation:spin 1s linear infinite;margin-bottom:20px}@keyframes spin{to{transform:rotate(360deg)}}.button-row{display:flex;gap:12px;margin-top:16px}.button{display:inline-flex;align-items:center;justify-content:center;gap:20px;min-height:46px;border:1px solid var(--line);padding:11px 22px;border-radius:7px;font-size:12px;transition:background .15s,transform .15s;white-space:nowrap}.button:not(:disabled):hover{background:var(--green-soft)}.button:not(:disabled):active{transform:translateY(1px)}.button.primary{background:var(--ink);color:var(--paper);border-color:var(--ink)}.button.primary:not(:disabled):hover{background:#385447}.button.small{min-height:36px;padding:8px 14px}.button.full{width:100%}.result-panel{max-width:820px;margin:27px auto 35px}.result-heading{display:flex;align-items:center;gap:20px;justify-content:space-between}.eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.2px;color:var(--muted);line-height:1.5}.result-panel h1{font-size:38px;letter-spacing:-1.6px;font-weight:400;margin:14px 0 30px}.new-best{font-family:var(--mono);font-size:9px;color:var(--orange);border:1px solid #e36c4933;padding:6px 9px;border-radius:4px;white-space:nowrap}.result-grid{display:flex;gap:55px;align-items:center;margin-bottom:30px}.result-main{display:flex;flex-direction:column;gap:2px;flex:1}.result-main>span:first-child{font-family:var(--mono);font-size:108px;line-height:1.15;letter-spacing:-8px;font-weight:400}.result-main>span:last-child{font-size:12px;color:var(--muted);padding-left:3px}.result-detail{flex:1.15;display:flex;flex-direction:column;gap:20px;margin:0;border-left:1px solid var(--line);padding-left:42px}.result-detail>div{display:flex;align-items:baseline;gap:20px;justify-content:space-between}.result-detail dt{font-family:var(--mono);font-size:10px;color:var(--muted)}.result-detail dd{font-family:var(--mono);font-size:16px;margin:0;text-align:right;white-space:nowrap}.rhythm-chart{border-top:1px solid var(--line);padding-top:14px}.chart-label{display:flex;justify-content:space-between;font-family:var(--mono);font-size:9px;color:var(--muted)}.rhythm-chart svg{width:100%;height:86px;margin-top:12px;overflow:visible}.slow-words{display:flex;gap:24px;align-items:center;font-family:var(--mono);font-size:10px;padding:8px 0 24px;border-bottom:1px solid var(--line)}.slow-words>span{color:var(--muted);white-space:nowrap}.slow-words>div{display:flex;gap:20px;flex-wrap:wrap}.slow-word{display:flex;gap:7px}.slow-word small{color:var(--muted);font-size:9px;align-self:center}.result-actions{display:flex;align-items:center;gap:10px;margin-top:24px;flex-wrap:wrap}.result-actions>.text-button{margin-left:auto}.result-note{font-size:11px;color:var(--muted);line-height:1.7;margin-top:16px;min-height:18px}.daily-board-button{color:var(--orange)}.replay-controls{display:flex;align-items:center;gap:15px;margin:28px auto 25px;max-width:800px;flex-wrap:wrap}.replay-controls input{flex:1;min-width:100px;padding:0;border:0;box-shadow:none;accent-color:var(--orange)}.replay-time{font-size:10px;font-family:var(--mono);color:var(--muted)}.replay-controls>.text-button{margin-left:auto}.dialog{border:1px solid var(--line);background:var(--paper);color:var(--ink);border-radius:15px;padding:38px;width:min(480px,calc(100vw - 32px));max-height:calc(100svh - 32px);box-shadow:0 25px 100px #263d3320;overflow:auto}.dialog.wide{width:min(590px,calc(100vw - 32px))}.dialog::backdrop{background:#172b2445;backdrop-filter:blur(6px)}.dialog-close{position:absolute;top:15px;right:17px;font-size:27px;font-weight:300;color:var(--muted);width:32px;height:32px;line-height:1}.dialog h2{font-size:29px;font-weight:400;letter-spacing:-1px;line-height:1.2;margin:15px 0 28px}.dialog p{line-height:1.7}.dialog label{display:block;font-size:12px;margin-bottom:12px;line-height:1.7}.dialog input[type=text],.dialog input[type=url],.dialog textarea{width:100%;font-size:14px}.dialog textarea{resize:vertical;min-height:130px;line-height:1.65}.dialog-note{font-size:11px;color:var(--muted);line-height:1.75;margin:18px 0 24px}.dialog .setting-row{display:flex;align-items:center;gap:15px;justify-content:space-between;padding:20px 0;border-bottom:1px solid var(--line);margin:0}.setting-row b{font-size:13px;font-weight:500}.setting-row small{display:block;color:var(--muted);font-size:10px;margin-top:5px}.setting-row select{font-size:11px;padding:10px;max-width:125px;flex-shrink:0}.setting-row .switch{appearance:none;width:38px;height:23px;padding:3px;border-radius:99px;background:var(--line);border:0;position:relative;cursor:pointer;flex-shrink:0}.setting-row .switch:before{content:\"\";display:block;width:17px;height:17px;border-radius:50%;background:var(--paper);box-shadow:0 1px 3px #0002;transition:transform .15s}.setting-row .switch:checked{background:var(--ink)}.setting-row .switch:checked:before{transform:translateX(15px)}.source-tabs{display:flex;gap:22px;border-bottom:1px solid var(--line);margin-bottom:26px}.source-tabs button{padding:0 0 12px;font-family:var(--mono);font-size:11px;color:var(--muted);position:relative}.source-tabs button[aria-selected=true]{color:var(--ink)}.source-tabs button[aria-selected=true]:after{content:\"\";position:absolute;bottom:-1px;left:0;right:0;height:2px;background:var(--orange)}.field-with-button{display:flex;gap:8px}.field-with-button input{min-width:0;flex:1}.field-with-button .button{padding:10px 17px}.topic-chips{display:flex;flex-wrap:wrap;gap:7px;margin:13px 0 0}.topic-chips button{font-family:var(--mono);font-size:9px;border:1px solid var(--line);padding:7px 10px;border-radius:99px}.topic-chips button:hover{background:var(--green-soft)}.paste-footer{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:14px}.paste-footer>span{font-family:var(--mono);font-size:10px;color:var(--muted)}.optional{color:var(--muted);font-size:10px}.inline-error{color:#a33b27;font-size:12px;padding:12px;background:var(--orange-soft);border-radius:6px}.help-steps p{display:flex;gap:20px;font-size:13px;padding-bottom:16px;border-bottom:1px solid var(--line)}.help-steps span{font-family:var(--mono);color:var(--orange);font-size:10px;padding-top:4px}.board-list{list-style:none;padding:0;margin:0}.board-list li{display:grid;grid-template-columns:24px minmax(0,1fr) 65px 50px;gap:12px;border-top:1px solid var(--line);padding:15px 0;font-family:var(--mono);font-size:11px;align-items:center}.board-list li>span:first-child{color:var(--muted);font-size:9px}.board-list a{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.board-list a:hover{color:var(--orange)}.board-list small{color:var(--muted);text-align:right}.board-list strong{text-align:right;font-weight:400}.empty-board{font-size:13px;padding:25px 0;color:var(--muted)}.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--ink);color:var(--paper);font-size:12px;line-height:1.5;padding:13px 23px;border-radius:8px;z-index:20;box-shadow:0 7px 30px #263d3320;max-width:calc(100vw - 32px);text-align:center}.mode-switch.locked{opacity:.7}\n@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}}\n@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}}\n@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}}\n@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}}\n@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}\n","type":"text/css; charset=utf-8"},"/favicon.svg":{"body":"\n","type":"image/svg+xml"},"/game.js":{"body":"import { generateWords, normalizePassage } from './words.js';\n\nexport const MODES = ['30', '60', '50', 'endless', 'tempo'];\nexport const MAX_REPLAY_EVENTS = 30000;\nexport const MAX_REPLAY_WORDS = 6000;\n\nconst clamp = (value, low, high) => Math.max(low, Math.min(high, value));\nconst rounded = value => Math.round(value * 10) / 10;\nconst lengthOf = word => Array.from(word || '').length;\nconst prefixLength = flags => {\n const index = flags.indexOf(false);\n return index < 0 ? flags.length : index;\n};\nconst speed = (chars, durationMs) => durationMs > 0\n ? rounded(chars * 12000 / Math.max(1000, durationMs)) : 0;\n\n/**\n * Recompute public metrics from privacy-safe replay outcomes and word timings.\n * A hit/miss records whether a key matched, never what the visitor typed.\n * Fully complete words count one normalized separator in either advance setting.\n * Backspacing never earns output; accuracy still reflects every typing attempt.\n */\nexport function summarizeRun(run) {\n const words = Array.isArray(run.words) ? run.words : [];\n const events = Array.isArray(run.events) ? run.events : [];\n const timings = Array.isArray(run.wordTimings) ? run.wordTimings : [];\n const durationMs = Math.max(0, Number(run.durationMs) || 0);\n const states = new Map();\n let hits = 0;\n let errors = 0;\n for (const event of events) {\n if (!states.has(event.index)) states.set(event.index, { flags: [], errors: 0 });\n const state = states.get(event.index);\n if (event.type === 'hit') {\n state.flags.push(true);\n hits++;\n } else if (event.type === 'miss') {\n state.flags.push(false);\n state.errors++;\n errors++;\n } else if (event.type === 'backspace') {\n state.flags.pop();\n }\n }\n let correctChars = 0;\n let completedWords = 0;\n let streak = 0;\n let bestStreak = 0;\n const wordSpeeds = [];\n for (let index = 0; index < timings.length; index++) {\n const timing = timings[index];\n const word = words.length ? words[timing.index % words.length] : '';\n const state = states.get(timing.index) || { flags: [], errors: 0 };\n // The backend also validates timing structure. Here a claimed completion must\n // agree with replayed character outcomes before it can earn output.\n const complete = timing.completed === true && state.flags.length === lengthOf(word)\n && prefixLength(state.flags) === lengthOf(word) && lengthOf(word) > 0;\n if (complete) {\n correctChars += lengthOf(word) + 1;\n completedWords++;\n streak = state.errors === 0 ? streak + 1 : 0;\n bestStreak = Math.max(bestStreak, streak);\n const stop = Number.isFinite(timing.typedEnd) ? timing.typedEnd : timing.end;\n const ms = stop - timing.start;\n // A one-letter first word in automatic mode can finish at t=0. There is\n // no measured interval for that word, so don't invent an infinite spike.\n if (ms > 0) wordSpeeds.push({ word, index: timing.index, ms: Math.round(ms), wpm: rounded((lengthOf(word) + 1) * 12000 / ms) });\n } else {\n streak = 0;\n if (index === timings.length - 1 && timing.missed !== true) {\n correctChars += Math.min(lengthOf(word), prefixLength(state.flags));\n }\n }\n }\n const slowest = [...wordSpeeds].sort((a, b) => a.wpm - b.wpm || b.ms - a.ms).slice(0, 5);\n // Preserve the shape of long runs without sending hundreds of graph columns.\n const bucketSize = Math.max(1, Math.ceil(wordSpeeds.length / 80));\n const sparkline = [];\n for (let i = 0; i < wordSpeeds.length; i += bucketSize) {\n const bucket = wordSpeeds.slice(i, i + bucketSize);\n sparkline.push(rounded(bucket.reduce((sum, row) => sum + row.wpm, 0) / bucket.length));\n }\n return {\n wpm: speed(correctChars, durationMs),\n accuracy: hits + errors ? rounded(hits * 100 / (hits + errors)) : 100,\n bestStreak, correctChars, keystrokes: hits + errors, errors,\n completedWords, slowest, sparkline\n };\n}\n\nexport class TypingGame {\n constructor({ words, mode = '30', advance = 'space', paceWpm = 40 } = {}) {\n const supplied = Array.isArray(words) ? normalizePassage(words.join(' ')) : normalizePassage(words);\n this.words = supplied.length ? supplied : generateWords();\n this.mode = MODES.includes(String(mode)) ? String(mode) : '30';\n this.advance = advance === 'letter' ? 'letter' : 'space';\n this.paceWpm = clamp(Number(paceWpm) || 40, 10, 200);\n this.status = 'ready';\n this.wordIndex = 0;\n this.buffer = [];\n this.startedAt = null;\n this.endedAt = null;\n this.lastAt = null;\n this.wordStartedAt = null;\n this.typedEnd = null;\n this.wordErrors = 0;\n this.hits = 0;\n this.errors = 0;\n this.completedWords = 0;\n this.completedChars = 0;\n this.streak = 0;\n this.bestStreak = 0;\n this.misses = 0;\n this.waitingForBeat = false;\n this.beatDeadline = null;\n this.beatDurationMs = null;\n this.finishedReason = null;\n this.events = [];\n this.wordTimings = [];\n this.replayTruncated = false;\n }\n\n get word() { return this.words[this.wordIndex % this.words.length]; }\n\n _now(nowMs) {\n const proposed = Number.isFinite(nowMs) ? nowMs : globalThis.performance?.now?.() ?? Date.now();\n this.lastAt = this.lastAt === null ? proposed : Math.max(this.lastAt, proposed);\n return this.lastAt;\n }\n\n _relative(time) { return Math.max(0, Math.round(time - this.startedAt)); }\n\n _event(type, now) {\n this.events.push({ t: this._relative(now), type, index: this.wordIndex });\n if (this.events.length > MAX_REPLAY_EVENTS) {\n this.events.splice(0, this.events.length - MAX_REPLAY_EVENTS);\n this.replayTruncated = true;\n }\n }\n\n _flags() {\n const target = Array.from(this.word);\n return this.buffer.map((character, index) => character === target[index]);\n }\n\n _prefix() { return Math.min(lengthOf(this.word), prefixLength(this._flags())); }\n\n _setBeat(now) {\n this.beatDurationMs = clamp((lengthOf(this.word) + 1) * 12000 / this.paceWpm, 350, 15000);\n this.beatDeadline = now + this.beatDurationMs;\n }\n\n _start(now) {\n this.status = 'running';\n this.startedAt = now;\n this.wordStartedAt = now;\n if (this.mode === 'tempo') this._setBeat(now);\n }\n\n _recordWord(now, completed, missed = false) {\n const timing = {\n index: this.wordIndex,\n start: this._relative(this.wordStartedAt),\n end: this._relative(now),\n completed,\n errors: this.wordErrors\n };\n if (this.typedEnd !== null) timing.typedEnd = this._relative(this.typedEnd);\n if (missed) timing.missed = true;\n this.wordTimings.push(timing);\n if (this.wordTimings.length > MAX_REPLAY_WORDS) {\n this.wordTimings.splice(0, this.wordTimings.length - MAX_REPLAY_WORDS);\n this.replayTruncated = true;\n }\n }\n\n _advance(now, completed, isBeat = false) {\n this._event(isBeat ? 'beat' : 'advance', now);\n this._recordWord(now, completed, isBeat && !completed);\n if (completed) {\n this.completedWords++;\n this.completedChars += lengthOf(this.word) + 1;\n this.streak = this.wordErrors === 0 ? this.streak + 1 : 0;\n this.bestStreak = Math.max(this.bestStreak, this.streak);\n if (isBeat) this.paceWpm = Math.min(240, this.paceWpm * 1.035);\n } else {\n this.streak = 0;\n this.misses++;\n }\n this.wordIndex++;\n this.buffer = [];\n this.wordErrors = 0;\n this.typedEnd = null;\n this.waitingForBeat = false;\n this.wordStartedAt = now;\n if (this.mode === '50' && this.completedWords >= 50) {\n this._finish(now, 'words', false);\n } else if (this.mode === 'tempo' && this.misses >= 3) {\n this._finish(now, 'misses', false);\n } else if (isBeat) {\n this._setBeat(now);\n }\n }\n\n _finish(now, reason, closeCurrent = true) {\n if (this.status === 'finished') return;\n if (this.startedAt !== null && closeCurrent) this._recordWord(now, false);\n this.status = 'finished';\n this.endedAt = now;\n this.finishedReason = reason;\n }\n\n tick(nowMs) {\n const now = this._now(nowMs);\n if (this.status === 'running') {\n if (this.mode === '30' || this.mode === '60') {\n const deadline = this.startedAt + Number(this.mode) * 1000;\n if (now >= deadline) this._finish(deadline, 'time');\n } else if (this.mode === 'tempo') {\n // Catch up from a suspended tab. No human gains extra time by hiding it.\n while (this.status === 'running' && now >= this.beatDeadline) {\n const deadline = this.beatDeadline;\n this._advance(deadline, this.waitingForBeat, true);\n }\n }\n }\n return this._snapshot(now);\n }\n\n press(key, nowMs) {\n const now = this._now(nowMs);\n this.tick(now);\n if (this.status === 'finished' || typeof key !== 'string') return this._snapshot(now);\n if (key === 'Backspace') {\n if (this.status === 'running' && !this.waitingForBeat && this.buffer.length) {\n this.buffer.pop();\n this.typedEnd = null;\n this._event('backspace', now);\n }\n return this._snapshot(now);\n }\n if (Array.from(key).length !== 1 || /[\\r\\n\\t\\u0000-\\u001f\\u007f]/u.test(key)) return this._snapshot(now);\n if (this.status === 'ready') {\n if (/\\s/u.test(key)) return this._snapshot(now);\n this._start(now);\n }\n if (this.waitingForBeat) return this._snapshot(now);\n if (key === ' ' && this.buffer.join('') === this.word) {\n if (this.mode === 'tempo') {\n this.waitingForBeat = true;\n } else {\n this._advance(now, true);\n }\n return this._snapshot(now);\n }\n // The small bound prevents an accidentally held key growing a buffer forever.\n // Ignored overflow presses are not logged or included in accuracy.\n if (this.buffer.length >= 4096) return this._snapshot(now);\n const target = Array.from(this.word);\n const correct = key === target[this.buffer.length];\n this.buffer.push(key);\n if (correct) this.hits++;\n else {\n this.errors++;\n this.wordErrors++;\n this.streak = 0;\n }\n this._event(correct ? 'hit' : 'miss', now);\n if (this.buffer.join('') === this.word) {\n this.typedEnd = now;\n if (this.mode === 'tempo') this.waitingForBeat = true;\n else if (this.advance === 'letter') this._advance(now, true);\n }\n return this._snapshot(now);\n }\n\n finish(nowMs) {\n const now = this._now(nowMs);\n this.tick(now);\n if (this.status !== 'finished') this._finish(now, 'manual');\n return this._snapshot(now);\n }\n\n _snapshot(now) {\n const stop = this.endedAt ?? now;\n const elapsedMs = this.startedAt === null ? 0 : Math.max(0, stop - this.startedAt);\n const flags = this._flags();\n const errorIndex = flags.indexOf(false);\n const correctPrefix = this._prefix();\n const remainingMs = this.mode === '30' || this.mode === '60'\n ? Math.max(0, Number(this.mode) * 1000 - elapsedMs) : null;\n return {\n status: this.status, mode: this.mode, advance: this.advance,\n wordIndex: this.wordIndex, word: this.word,\n nextWord: this.words[(this.wordIndex + 1) % this.words.length],\n buffer: this.buffer.join(''), correctPrefix, errorIndex,\n startedAt: this.startedAt, elapsedMs, remainingMs,\n wpm: speed(this.completedChars + correctPrefix, elapsedMs),\n accuracy: this.hits + this.errors ? rounded(this.hits * 100 / (this.hits + this.errors)) : 100,\n bestStreak: this.bestStreak, streak: this.streak, misses: this.misses,\n paceWpm: rounded(this.paceWpm),\n beatRemainingMs: this.beatDeadline === null ? null : Math.max(0, this.beatDeadline - now),\n beatDurationMs: this.beatDurationMs,\n waitingForBeat: this.waitingForBeat,\n completedWords: this.completedWords, finishedReason: this.finishedReason,\n correctChars: this.completedChars + correctPrefix,\n keystrokes: this.hits + this.errors, errors: this.errors,\n replayTruncated: this.replayTruncated\n };\n }\n\n snapshot(nowMs) { return this.tick(nowMs); }\n\n result(nowMs) {\n const state = this.tick(nowMs);\n const wordTimings = this.wordTimings.map(timing => ({ ...timing }));\n // Useful for previews or manual save callers, without mutating a running game.\n if (this.status === 'running') {\n const timing = {\n index: this.wordIndex, start: this._relative(this.wordStartedAt),\n end: Math.round(state.elapsedMs), completed: false, errors: this.wordErrors\n };\n if (this.typedEnd !== null) timing.typedEnd = this._relative(this.typedEnd);\n wordTimings.push(timing);\n }\n const run = {\n version: 1, mode: this.mode, advance: this.advance,\n words: [...this.words], durationMs: Math.round(state.elapsedMs),\n finishedReason: this.finishedReason,\n events: this.events.map(event => ({ ...event })), wordTimings,\n replayTruncated: this.replayTruncated\n };\n run.stats = summarizeRun(run);\n if (this.replayTruncated) {\n // The full run still has correct local results. The server refuses partial\n // replay uploads instead of pretending the retained tail is the full run.\n Object.assign(run.stats, {\n wpm: state.wpm, accuracy: state.accuracy, bestStreak: state.bestStreak,\n correctChars: state.correctChars, keystrokes: state.keystrokes,\n errors: state.errors, completedWords: state.completedWords\n });\n }\n return run;\n }\n}\n","type":"text/javascript; charset=utf-8"},"/words.js":{"body":"/** Small, frequency-biased English vocabulary. No network or content tracking. */\nexport const MAX_WORDS = 300;\nexport const MAX_WORD_LENGTH = 64;\n\nconst bands = [\n [6, 'the be to of and a in that have i it for not on with he as you do at this but his by from they we say her she or an will my one all would there their what so up out if about who get which go me when make can like time no just him know take people into year your good some could them see other than then now look only come its over think also back after use two how our work first well way even new want because any these give day most us is are was were has had did been does may more'],\n [3, 'find here thing tell many through long down own world still life hand old right big place every great where help same home small number part turn need start little keep last read word run next move sound light water night day line play write point live around between without under again while before each never always thought change open close end air room story read try left feel let put mean call ask too state set three much really must high something both family leave course problem might different group begin seem country school talk show hear during house question answer study name book city eye few white today enough hard real kind public program follow head face stop once person door stand side sure such friend idea often together learn watch grow walk food hour money mother father reason morning front voice music across moment body love letter black clear bring young fact five become best least happen away large power picture line heart mind system form early right child example simple paper easy ready rhythm quiet quick fast space slow steady type finger beat sharp green blue red warm round smooth clean bright'],\n [1, 'river garden window silver ocean forest mountain summer winter spring autumn cloud stone bird moon sun rain wind tree leaf flower field coast island bridge path trail earth planet star sky shape circle square wonder dream hope memory future history science nature human language rhythm balance pattern signal planet curious delight gentle focus breathe listen calm flow spark glow fresh tiny brave clever useful true build draw smile travel explore create discover enjoy repeat practice improve notice understand imagine believe connect choose solve share follow pause rise drift shine carry reach rest repair shape learn design measure gather arrange search welcome begin finish steady quiet simple vivid mellow nimble honest warm cool sound silence orange yellow purple violet copper velvet cotton glass wood metal honey coffee tea bread apple lemon peach grape mint seed root branch shell pebble feather lantern paper pencil brush canvas poem color phrase melody canvas motion orbit puzzle magic chance wonder narrow wide deep shallow local global daily common subtle sudden patient playful perfect careful simple solid supple clear distinct fluent rapid active bright welcome hello thanks together tomorrow yesterday present minute second Monday Tuesday Wednesday Thursday Friday Saturday Sunday January February March April May June July August September October November December']\n];\n\nconst weightedWords = bands.flatMap(([weight, text]) =>\n text.split(/\\s+/).flatMap(word => Array(weight).fill(word.toLowerCase()))\n);\n\nexport function seededRandom(seed) {\n let hash = 2166136261;\n for (const char of String(seed)) hash = Math.imul(hash ^ char.codePointAt(0), 16777619) >>> 0;\n return () => {\n hash = (hash + 0x6D2B79F5) >>> 0;\n let value = hash;\n value = Math.imul(value ^ value >>> 15, value | 1);\n value ^= value + Math.imul(value ^ value >>> 7, value | 61);\n return ((value ^ value >>> 14) >>> 0) / 4294967296;\n };\n}\n\nexport function generateWords(count = MAX_WORDS, seed) {\n const size = Math.max(1, Math.min(MAX_WORDS, Math.trunc(Number(count) || MAX_WORDS)));\n const random = seed === undefined ? Math.random : seededRandom(seed);\n const words = [];\n for (let index = 0; index < size; index++) {\n let next = weightedWords[Math.floor(random() * weightedWords.length)];\n // Immediate repeats are distracting; repeat the draw once, not forever.\n if (next === words[index - 1]) next = weightedWords[Math.floor(random() * weightedWords.length)];\n words.push(next);\n }\n return words;\n}\n\n/** Normalize visible passage text; return at most 300 words, each at most 64 code points. */\nexport function normalizePassage(text) {\n return String(text ?? '').normalize('NFKC')\n .replace(/[\\u2018\\u2019]/g, \"'\")\n .replace(/[\\u201C\\u201D]/g, '\"')\n .replace(/[\\u2010-\\u2014]/g, '-')\n .replace(/[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F\\u200B-\\u200F\\u202A-\\u202E\\u2066-\\u2069\\uFEFF]/g, '')\n .trim().split(/\\s+/u).filter(Boolean).slice(0, MAX_WORDS)\n .map(word => Array.from(word).slice(0, MAX_WORD_LENGTH).join(''));\n}\n","type":"text/javascript; charset=utf-8"}}; +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 => ({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[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' : 'space 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 renderedWord = '';\n wordLayoutDirty = true;\n lastErrorCount = 0;\n $('load-panel').hidden = true;\n $('play-panel').hidden = false;\n $('result-panel').hidden = true;\n $('replay-controls').hidden = true;\n $('input-row').hidden = false;\n $('typing-input').value = '';\n $('typing-input').disabled = false;\n $('ghost-race').hidden = !ghost;\n $('finish-run').hidden = selectedMode !== 'endless' && selectedMode !== 'tempo';\n $('tempo-track').hidden = selectedMode !== 'tempo';\n $('restart-tip').hidden = false;\n updateOptions();\n renderGame(game.snapshot(now()));\n if (focus || innerWidth > 700) focusTyping();\n}\n\nfunction drawWord(word, buffer, index, nextWord, flags = null) {\n const key = word + '|' + buffer + '|' + index + '|' + (flags?.join('') || '');\n if (key === lastWordKey && !wordLayoutDirty) return;\n const node = $('word');\n const typed = Array.from(buffer || '');\n const wordChanged = index !== lastWordIndex || word !== renderedWord;\n if (wordChanged) {\n // The next target is readable immediately. Animation must never consume\n // part of the interval in which the player is already typing this word.\n errorAnimation?.cancel();\n const chars = Array.from(word || '');\n node.classList.toggle('long', chars.length > 12);\n node.classList.toggle('very-long', chars.length > 22);\n wordSpans = chars.map(char => {\n const span = document.createElement('span');\n span.textContent = char;\n return span;\n });\n overflowSpan = document.createElement('span');\n overflowSpan.className = 'overflow';\n overflowSpan.setAttribute('aria-hidden', 'true');\n overflowSpan.hidden = true;\n node.replaceChildren(...wordSpans, overflowSpan);\n node.setAttribute('aria-label', 'Word to type: ' + word);\n node.dataset.word = word;\n $('typing-input').setAttribute('aria-label', 'Type the word: ' + word);\n setText('word-number', String(index + 1).padStart(3, '0'));\n setText('next-word', nextWord || '');\n wordLayoutDirty = true;\n }\n wordSpans.forEach((span, i) => {\n let state = '';\n if (i < typed.length) state = (flags ? flags[i] : typed[i] === span.textContent) ? 'correct' : 'incorrect';\n if (i === typed.length) state += ' cursor';\n if (span.className !== state) span.className = state;\n });\n const overflow = Math.max(0, typed.length - wordSpans.length);\n if (overflowSpan.hidden !== !overflow) overflowSpan.hidden = !overflow;\n if (overflow && overflowSpan.textContent !== '+' + overflow) overflowSpan.textContent = '+' + overflow;\n // Fit a long pasted word once, on word/viewport changes. Letter feedback does\n // not change glyph widths and must not force a layout read on every key.\n if (wordLayoutDirty) {\n node.style.fontSize = '';\n const available = Math.max(1, $('stage').clientWidth - 28);\n const width = node.scrollWidth;\n if (width > available) {\n const natural = parseFloat(getComputedStyle(node).fontSize);\n node.style.fontSize = Math.max(8, natural * available / width * .96) + 'px';\n }\n wordLayoutDirty = false;\n }\n lastWordKey = key;\n lastWordIndex = index;\n renderedWord = word;\n}\n\nfunction renderGame(state) {\n drawWord(state.word, state.buffer, state.wordIndex, state.nextWord);\n setText('live-wpm', pretty(state.wpm));\n setText('live-accuracy', pretty(state.accuracy));\n setClass($('status-dot'), 'active', state.status === 'running');\n if (state.status === 'ready') setText('run-status', ghost ? 'your ghost is ready' : source.kind === 'daily' ? 'same words, your rhythm' : 'a fresh start');\n else if (state.errorIndex >= 0) setText('run-status', 'a little backspace, then onward');\n else if (state.waitingForBeat) setText('run-status', 'right on time · wait for the beat');\n else setText('run-status', state.streak >= 3 ? state.streak + ' clean words in a row' : 'one word, then the next');\n const hintStart = $('run-hint').querySelector('.hint-start');\n const hint = state.status === 'ready' ? 'type the word to begin' : state.errorIndex >= 0 ? 'backspace to correct' : state.waitingForBeat ? 'hold that rhythm' : 'keep your rhythm';\n if (hintStart.textContent !== hint) hintStart.textContent = hint;\n if (selectedMode === '30' || selectedMode === '60') {\n setText('time-label', 'remaining'); setText('live-time', Math.ceil(state.remainingMs / 1000)); setText('time-unit', 's');\n } else if (selectedMode === '50') {\n setText('time-label', 'remaining'); setText('live-time', Math.max(0, 50 - state.completedWords)); setText('time-unit', 'words');\n } else if (selectedMode === 'tempo') {\n setText('time-label', 'beat pace'); setText('live-time', pretty(state.paceWpm)); setText('time-unit', 'wpm');\n } else {\n setText('time-label', 'in the flow'); setText('live-time', Math.floor(state.elapsedMs / 1000)); setText('time-unit', 's');\n }\n if (selectedMode === 'tempo') {\n const fraction = state.beatDurationMs ? Math.max(0, state.beatRemainingMs / state.beatDurationMs) : 1;\n $('tempo-progress').style.transform = 'scaleX(' + fraction + ')';\n 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');\n Array.from($('tempo-lives').children).forEach((dot, i) => setClass(dot, 'lost', i < state.misses));\n const label = (3 - state.misses) + ' missed beats remaining';\n if ($('tempo-lives').getAttribute('aria-label') !== label) $('tempo-lives').setAttribute('aria-label', label);\n }\n if (ghost) {\n const completed = ghost.wordTimings.filter(timing => timing.completed && timing.end <= state.elapsedMs).length;\n setText('race-you', state.completedWords);\n setText('race-them', completed);\n const difference = state.completedWords - completed;\n setText('race-delta', state.status === 'ready' ? '' : difference === 0 ? 'neck and neck' : Math.abs(difference) + (difference > 0 ? ' ahead' : ' behind'));\n }\n if (state.errors > lastErrorCount && !reducedMotion) {\n errorAnimation?.cancel();\n errorAnimation = $('word').animate([{transform:'translateX(0)'},{transform:'translateX(-3px)'},{transform:'translateX(3px)'},{transform:'translateX(0)'}], {duration:90});\n }\n lastErrorCount = state.errors;\n lastClockPaint = now();\n if (state.status === 'finished' && !transitioning) finishDisplay();\n}\n\nfunction press(key) {\n if (view !== 'play' || !game || document.querySelector('dialog[open]')) return;\n renderGame(game.press(key, now()));\n}\n\nfunction finishDisplay() {\n transitioning = true;\n const run = game.result(now());\n const previousBest = Number(bests[selectedMode] || 0);\n const qualifies = run.finishedReason !== 'manual' || selectedMode === 'endless' || selectedMode === 'tempo';\n const isBest = qualifies && run.stats.completedWords > 0 && run.stats.wpm > previousBest;\n if (isBest) { bests[selectedMode] = run.stats.wpm; writeLocal('bests', bests); }\n if (run.durationMs >= 5000 && run.stats.wpm > 0) writeLocal('lastAverage', Math.max(10, Math.min(200, run.stats.wpm)));\n const ghostStats = ghost?.stats;\n currentResult = {run,source:{...source},name:'',local:true,isBest,ghostStats};\n shareId = null;\n ghost = null;\n $('typing-input').blur();\n displayResult(currentResult);\n}\n\nfunction displayResult(result) {\n view = 'result';\n replay.playing = false;\n currentResult = result;\n const run = result.run;\n const stats = run.stats;\n selectedMode = run.mode;\n source = result.source || {kind:'common'};\n passage = [...run.words];\n $('load-panel').hidden = true; $('play-panel').hidden = true; $('result-panel').hidden = false;\n $('result-kicker').textContent = (result.name ? result.name + ' · ' : '') + modeNames[run.mode] + (run.finishedReason === 'manual' && ['30','60','50'].includes(run.mode) ? ' · finished early' : ', well spent');\n $('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.';\n $('result-wpm').textContent = pretty(stats.wpm);\n $('result-accuracy').textContent = round1(stats.accuracy) + '%';\n $('result-streak').textContent = stats.bestStreak + (stats.bestStreak === 1 ? ' word' : ' words');\n $('result-words').textContent = stats.completedWords;\n $('new-best').hidden = !result.isBest;\n $('again-button').innerHTML = result.local ? 'go again ↵' : 'try these words ↵';\n $('share-button').innerHTML = shareId ? 'copy result ↗' : 'share result ↗';\n $('race-button').textContent = result.local ? 'challenge a friend →' : 'race this →';\n $('replay-button').disabled = !run.events?.length;\n $('share-button').disabled = !run.events?.length || run.replayTruncated;\n $('race-button').disabled = !run.events?.length || run.replayTruncated;\n $('board-open').hidden = source.kind !== 'daily';\n 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.';\n else if (source.kind === 'daily' && result.local) $('result-note').textContent = 'Share your result to add it to today’s board. A name is optional.';\n 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.';\n 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.';\n const slowest = stats.slowest?.slice(0, 3) || [];\n $('slow-words').innerHTML = slowest.length ? slowest.map(row => '' + escapeHTML(row.word) + '' + round1(row.ms / 1000) + 's').join('') : 'your next run starts here';\n const points = stats.sparkline || [];\n if (points.length) {\n const max = Math.max(20, ...points);\n const coords = points.map((value, i) => [(i / Math.max(1, points.length - 1)) * 896 + 2, 83 - (Math.min(value, max) / max) * 70]);\n const path = coords.map((point, i) => (i ? 'L' : 'M') + point[0].toFixed(1) + ',' + point[1].toFixed(1)).join(' ');\n $('speed-chart').innerHTML = '' + coords.filter((_, i) => i === 0 || i === coords.length - 1).map(point => '').join('');\n } else $('speed-chart').innerHTML = '';\n updateOptions();\n $('mode-switch').querySelectorAll('button').forEach(button => { button.disabled = false; });\n}\n\nfunction showLoading(title, description = '') {\n view = 'loading';\n replay.playing = false;\n $('play-panel').hidden = true; $('result-panel').hidden = true; $('load-panel').hidden = false;\n $('load-kicker').textContent = 'one moment';\n $('load-title').textContent = title; $('load-description').textContent = description;\n $('load-actions').hidden = true; $('loading-spinner').hidden = false;\n}\n\nfunction showRouteError(error) {\n view = 'loading';\n $('load-panel').hidden = false; $('play-panel').hidden = true; $('result-panel').hidden = true;\n $('loading-spinner').hidden = true; $('load-actions').hidden = false;\n $('load-kicker').textContent = error.status === 429 ? 'a little breathing room' : 'a small interruption';\n $('load-title').textContent = error.status === 404 ? 'that rhythm is missing.' : 'the words couldn’t land.';\n $('load-description').textContent = error.message;\n}\n\nasync function route(path, push = true) {\n const serial = ++routeSerial;\n document.querySelectorAll('dialog[open]').forEach(dialog => dialog.close());\n if (push) history.pushState({}, '', path);\n currentPath = path;\n ghost = null; shareId = null;\n if (path === '/') {\n source = {kind:'common'};\n currentResult = null;\n passage = newCommonPassage();\n beginRun();\n document.title = 'typy — one word at a time';\n return;\n }\n try {\n if (path === '/daily' || path === '/daily/') {\n showLoading('today’s words, for everyone.');\n const response = await api('/api/daily');\n if (serial !== routeSerial) return;\n passage = response.words;\n source = {kind:'daily',day:response.day};\n selectedMode = '60';\n currentResult = null;\n beginRun();\n document.title = 'the daily — typy';\n } else if (/^\\/(r|race)\\/[a-zA-Z0-9_-]+\\/?$/.test(path)) {\n const [,kind,id] = path.split('/');\n showLoading(kind === 'race' ? 'a ghost is taking its place.' : 'finding that rhythm.');\n const response = await api('/api/results/' + encodeURIComponent(id));\n if (serial !== routeSerial) return;\n const result = {...response,local:false};\n if (!result.run?.words?.length || !result.run.stats) throw new Error('This replay is incomplete. Please try another result.');\n currentResult = result;\n shareId = result.id || id;\n source = result.source || {kind:'common'};\n passage = [...result.run.words];\n selectedMode = result.run.mode;\n if (kind === 'race') {\n ghost = result.run;\n beginRun();\n } else displayResult(result);\n document.title = pretty(result.run.stats.wpm) + ' wpm — typy';\n } else {\n const topic = decodeURIComponent(path).replace(/^\\/|\\/$/g, '');\n if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(topic)) throw Object.assign(new Error('Try a short topic with letters and hyphens, such as /react-hooks.'), {status:404});\n source = {kind:'topic',topic};\n updateOptions();\n showLoading('a little about ' + topic.replaceAll('-', ' ') + '.', 'Making a short passage for your next run.');\n const response = await api('/api/passage?topic=' + encodeURIComponent(topic));\n if (serial !== routeSerial) return;\n passage = response.words;\n source = {kind:'topic',topic:response.topic || topic};\n currentResult = null;\n beginRun();\n document.title = topic.replaceAll('-', ' ') + ' — typy';\n }\n } catch (error) { if (serial === routeSerial) showRouteError(error); }\n}\n\nfunction startReplay() {\n if (!currentResult?.run) return;\n view = 'replay';\n ghost = null;\n lastWordKey = ''; lastWordIndex = -1;\n $('result-panel').hidden = true; $('play-panel').hidden = false;\n $('input-row').hidden = true; $('replay-controls').hidden = false;\n $('ghost-race').hidden = true; $('tempo-track').hidden = true;\n $('run-status').textContent = 'a rhythm, replayed';\n $('status-dot').classList.add('active');\n $('replay-scrubber').max = Math.max(1, currentResult.run.durationMs);\n $('replay-scrubber').value = '0';\n $('time-label').textContent = 'replay'; $('time-unit').textContent = 's';\n replay = {playing:true,position:0,anchor:now(),anchorPosition:0};\n $('replay-play').textContent = 'pause';\n updateOptions();\n renderReplay(0);\n}\n\nfunction clock(ms) {\n const seconds = Math.max(0, Math.floor(ms / 1000));\n return Math.floor(seconds / 60) + ':' + String(seconds % 60).padStart(2, '0');\n}\n\nfunction renderReplay(position) {\n const run = currentResult.run;\n let timing = run.wordTimings[0];\n for (const candidate of run.wordTimings) {\n if (candidate.start <= position) timing = candidate;\n else break;\n }\n const index = timing?.index || 0;\n const word = run.words[index % run.words.length];\n const flags = [];\n for (const event of run.events) {\n if (event.t > position) break;\n if (event.index !== index) continue;\n if (event.type === 'hit') flags.push(true);\n else if (event.type === 'miss') flags.push(false);\n else if (event.type === 'backspace') flags.pop();\n }\n const characters = Array.from(word);\n const buffer = flags.map((flag, i) => flag ? characters[i] || '' : '·').join('');\n drawWord(word, buffer, index, run.words[(index + 1) % run.words.length], flags);\n $('live-wpm').textContent = pretty(run.stats.wpm);\n $('live-accuracy').textContent = pretty(run.stats.accuracy);\n $('live-time').textContent = Math.floor(position / 1000);\n $('replay-scrubber').value = String(position);\n $('replay-time').textContent = clock(position) + ' / ' + clock(run.durationMs);\n}\n\nfunction openShare(intent = 'result') {\n if (!currentResult) return;\n shareIntent = intent;\n $('share-kicker').textContent = source.kind === 'daily' ? 'put your rhythm on the board' : 'a good rhythm is worth sharing';\n $('share-title').textContent = intent === 'race' ? 'a friendly challenge.' : 'leave a little ghost.';\n $('share-error').hidden = true;\n $('share-name').value = readLocal('name', '');\n $('share-create').hidden = Boolean(shareId);\n $('share-ready').hidden = !shareId;\n if (shareId) fillShareLink();\n $('share-dialog').showModal();\n}\n\nfunction fillShareLink() {\n $('share-url').value = location.origin + (shareIntent === 'race' ? '/race/' : '/r/') + shareId;\n $('share-url-label').textContent = shareIntent === 'race' ? 'Your challenge link' : 'Your result link';\n $('share-ready-note').textContent = shareIntent === 'race' ? 'Same words, same mode. Your friend races your original timing.' : 'Anyone with this link can watch your replay or race your ghost.';\n $('copy-link').textContent = 'copy';\n}\n\nasync function saveResult() {\n if (!currentResult || shareId) return;\n const button = $('save-result');\n button.disabled = true; button.textContent = 'saving your rhythm…';\n $('share-error').hidden = true;\n try {\n const name = $('share-name').value.trim();\n const response = await api('/api/results', {method:'POST',body:JSON.stringify({run:currentResult.run,source:currentResult.source,name})});\n if (!response.id) throw new Error('The server did not return a share link. Please try again.');\n shareId = response.id;\n currentResult = {...currentResult,id:shareId,name,boardPublished:response.boardPublished};\n writeLocal('name', name);\n $('share-create').hidden = true; $('share-ready').hidden = false;\n fillShareLink();\n history.replaceState({}, '', '/r/' + shareId);\n currentPath = '/r/' + shareId;\n $('share-button').innerHTML = 'copy result ↗';\n $('result-note').textContent = source.kind === 'daily'\n ? response.boardPublished === false\n ? 'Your replay is shared, but the daily board could not update. Your result link still works.'\n : 'Your result is shared. The daily board may take a moment to refresh.'\n : 'Your replay is ready to share.';\n if (source.kind === 'daily' && response.boardPublished === false) toast('Result saved. The daily board could not update this time.');\n } catch (error) {\n $('share-error').textContent = error.message;\n $('share-error').hidden = false;\n } finally { button.disabled = false; button.textContent = 'create share link ↗'; }\n}\n\nasync function copyShareLink() {\n try { await navigator.clipboard.writeText($('share-url').value); $('copy-link').textContent = 'copied ✓'; }\n catch { $('share-url').focus(); $('share-url').select(); toast('The link is selected. Copy it with your keyboard or press and hold.'); }\n}\n\nasync function loadBoard() {\n const day = source.day || new Date().toISOString().slice(0, 10);\n $('board-date').textContent = day + ' · midnight to midnight UTC';\n $('board-content').innerHTML = '

finding today’s rhythms…

';\n try {\n const response = await api('/api/board?day=' + encodeURIComponent(day));\n const entries = response.entries || [];\n $('board-content').innerHTML = entries.length ? '
    ' + entries.map((entry, i) => '
  1. ' + String(i + 1).padStart(2, '0') + '' + escapeHTML(entry.name || 'anonymous') + '' + pretty(entry.wpm) + ' wpm' + round1(entry.accuracy) + '%
  2. ').join('') + '
' : '

No shared runs yet. Set today’s rhythm.

';\n } catch (error) { $('board-content').innerHTML = '

' + escapeHTML(error.message) + '

'; }\n}\n\nfunction openBoard() { $('board-dialog').showModal(); loadBoard(); }\nfunction topicRoute(topic) {\n const slug = topic.trim().toLowerCase().normalize('NFKD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 64).replace(/-$/, '');\n if (!slug || ['api','r','race','daily'].includes(slug)) { toast('Try a few words about a topic, such as “the deep sea”.'); return; }\n route('/' + slug);\n}\n\nfunction restart({freshWords = false} = {}) {\n if (view === 'loading') return;\n if (view === 'result' && !currentResult?.local) {\n source = {...currentResult.source}; passage = [...currentResult.run.words]; selectedMode = currentResult.run.mode;\n }\n if (view === 'replay') replay.playing = false;\n // A local retry gets a clean address instead of replacing somebody else's result.\n if (view !== 'play') {\n const target = source.kind === 'daily' ? '/daily' : source.kind === 'topic' ? '/' + source.topic : '/';\n history.replaceState({}, '', target); currentPath = target;\n shareId = null;\n }\n beginRun({freshWords,focus:true});\n}\n\ndocument.addEventListener('keydown', event => {\n if (event.isComposing || event.ctrlKey || event.metaKey || event.altKey) return;\n if (document.querySelector('dialog[open]')) return;\n const target = event.target;\n if (target !== $('typing-input') && target.closest('input,textarea,select,[contenteditable=true]')) return;\n // Enter/Space must activate a focused control before they can be game\n // shortcuts. This keeps every toolbar and result action keyboard-accessible.\n if (target.closest('button,a') && (event.key === 'Enter' || event.key === ' ')) return;\n if (event.key === 'Escape') { event.preventDefault(); restart(); return; }\n if (view === 'result' && event.key === 'Enter') { event.preventDefault(); restart({freshWords:source.kind === 'common'}); return; }\n if (view !== 'play') return;\n if (event.key === 'Enter' && (selectedMode === 'endless' || selectedMode === 'tempo') && game.status === 'running') {\n event.preventDefault(); game.finish(now()); finishDisplay(); return;\n }\n if (target !== $('typing-input') && target.closest('button,a')) return;\n if (event.key === 'Backspace' || Array.from(event.key).length === 1) {\n event.preventDefault(); press(event.key);\n }\n});\n\nconst input = $('typing-input');\ninput.addEventListener('compositionstart', () => { isComposing = true; });\ninput.addEventListener('compositionend', event => {\n isComposing = false;\n const text = event.data || input.value;\n input.value = '';\n for (const char of text) press(char);\n});\ninput.addEventListener('beforeinput', event => {\n if (event.inputType === 'deleteContentBackward') { event.preventDefault(); press('Backspace'); }\n if (event.inputType === 'insertFromPaste') { event.preventDefault(); toast('Paste a passage in the words menu, then type it here.'); }\n});\ninput.addEventListener('input', event => {\n if (isComposing || event.isComposing) return;\n const value = input.value;\n input.value = '';\n for (const char of value) press(char);\n});\ninput.addEventListener('paste', event => { event.preventDefault(); toast('Use the words menu to choose a pasted passage.'); });\n$('stage').addEventListener('click', focusTyping);\n$('finish-run').addEventListener('click', () => { if (game?.status === 'running') { game.finish(now()); finishDisplay(); } else toast('Type the first word to start your run.'); });\n\ndocument.querySelectorAll('[data-mode]').forEach(button => button.addEventListener('click', () => {\n selectedMode = button.dataset.mode;\n if (source.kind === 'daily' || ghost || view === 'result') {\n ghost = null; source = {kind:'common'}; passage = newCommonPassage(); currentResult = null;\n history.pushState({}, '', '/'); currentPath = '/';\n }\n beginRun({freshWords:source.kind === 'common',focus:true});\n}));\n$('settings-open').addEventListener('click', () => { updateOptions(); $('settings-dialog').showModal(); });\n$('settings-done').addEventListener('click', () => {\n const changed = settings.advance !== $('advance-setting').value || settings.preview !== $('preview-setting').checked;\n if (!ghost) settings.advance = $('advance-setting').value;\n settings.preview = $('preview-setting').checked; writeLocal('settings',settings);\n $('settings-dialog').close();\n if (changed && view === 'play') beginRun({focus:true}); else updateOptions();\n});\n$('source-open').addEventListener('click', () => $('source-dialog').showModal());\n['topic','paste','common'].forEach(tab => $('tab-' + tab).addEventListener('click', () => {\n ['topic','paste','common'].forEach(id => { $('tab-' + id).setAttribute('aria-selected',String(id === tab)); $('source-' + id).hidden = id !== tab; });\n if (tab === 'topic') $('topic-input').focus();\n else if (tab === 'paste') $('paste-input').focus();\n}));\n$('source-topic').addEventListener('submit', event => { event.preventDefault(); topicRoute($('topic-input').value); });\ndocument.querySelectorAll('[data-topic]').forEach(button => button.addEventListener('click', () => topicRoute(button.dataset.topic)));\n$('paste-input').addEventListener('input', () => { const count = normalizePassage($('paste-input').value).length; $('paste-count').textContent = count + ' / 300 words'; });\n$('source-paste').addEventListener('submit', event => {\n event.preventDefault();\n const words = normalizePassage($('paste-input').value);\n if (!words.length) { toast('Add a few words first.'); return; }\n routeSerial++; source = {kind:'paste'}; passage = words; ghost = null; currentResult = null; shareId = null;\n $('source-dialog').close(); history.pushState({}, '', '/'); currentPath = '/'; beginRun({focus:true});\n});\n$('common-start').addEventListener('click', () => route('/'));\n$('load-common').addEventListener('click', () => route('/'));\n$('load-retry').addEventListener('click', () => route(currentPath,false));\n$('help-open').addEventListener('click', () => $('help-dialog').showModal());\n$('help-done').addEventListener('click', () => { $('help-dialog').close(); focusTyping(); });\n$('again-button').addEventListener('click', () => restart({freshWords:source.kind === 'common'}));\n$('replay-button').addEventListener('click', startReplay);\n$('replay-back').addEventListener('click', () => displayResult(currentResult));\n$('replay-play').addEventListener('click', () => {\n if (replay.position >= currentResult.run.durationMs) replay.position = 0;\n replay.playing = !replay.playing; replay.anchor = now(); replay.anchorPosition = replay.position;\n $('replay-play').textContent = replay.playing ? 'pause' : 'play';\n});\n$('replay-scrubber').addEventListener('input', () => {\n replay.position = Number($('replay-scrubber').value); replay.anchor = now(); replay.anchorPosition = replay.position;\n renderReplay(replay.position);\n});\n$('share-button').addEventListener('click', () => openShare('result'));\n$('race-button').addEventListener('click', () => {\n if (currentResult?.local) openShare('race');\n else if (shareId) route('/race/' + shareId);\n});\n$('save-result').addEventListener('click', saveResult);\n$('copy-link').addEventListener('click', copyShareLink);\n$('share-url').addEventListener('click', () => $('share-url').select());\n$('board-open').addEventListener('click', openBoard);\n$('footer-board').addEventListener('click', openBoard);\n$('board-refresh').addEventListener('click', loadBoard);\ndocument.querySelectorAll('dialog').forEach(dialog => {\n dialog.addEventListener('click', event => {\n if (event.target !== dialog) return;\n const rect = dialog.getBoundingClientRect();\n if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) dialog.close();\n });\n});\ndocument.addEventListener('click', event => {\n const anchor = event.target.closest('a');\n if (!anchor || anchor.target || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;\n const url = new URL(anchor.href);\n if (url.origin !== location.origin) return;\n event.preventDefault(); route(url.pathname);\n});\nwindow.addEventListener('popstate', () => route(location.pathname,false));\nwindow.addEventListener('resize', () => {\n wordLayoutDirty = true;\n if (view === 'play' && game) renderGame(game.snapshot(now()));\n else if (view === 'replay') renderReplay(replay.position);\n});\n\nfunction frame(time) {\n // Input renders synchronously. Only the clock needs periodic refreshes; an\n // idle stage does no DOM work, while tempo keeps its smooth beat indicator.\n if (view === 'play' && game?.status === 'running' && (selectedMode === 'tempo' || time - lastClockPaint >= 100)) renderGame(game.tick(time));\n else if (view === 'replay' && replay.playing) {\n replay.position = Math.min(currentResult.run.durationMs, replay.anchorPosition + time - replay.anchor);\n renderReplay(replay.position);\n if (replay.position >= currentResult.run.durationMs) { replay.playing = false; $('replay-play').textContent = 'play again'; }\n }\n requestAnimationFrame(frame);\n}\nroute(location.pathname,false);\nrequestAnimationFrame(frame);\n","type":"text/javascript; charset=utf-8"},"/style.css":{"body":":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}\n*{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-radius:4px}.word .cursor{position:relative}.word .cursor:after{content:\"\";position:absolute;left:4px;right:1px;bottom:-10px;height:3px;background:var(--orange)}.word .overflow{color:var(--orange);font-size:.55em;vertical-align:middle;margin-left:10px}.upcoming{position:absolute;left:0;right:0;top:calc(50% + 90px);display:flex;align-items:baseline;justify-content:center;gap:12px;color:#7c8875;max-width:100%;padding:0 24px}.upcoming span{font-family:var(--mono);font-size:8px;color:var(--muted);white-space:nowrap}.upcoming b{font-family:var(--mono);font-size:27px;letter-spacing:-1px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.input-row{text-align:center;min-height:93px;padding-top:20px}.typing-input{position:absolute;width:1px;height:1px;padding:0;opacity:0;overflow:hidden;border:0;pointer-events:none}.run-hint{display:flex;align-items:center;justify-content:center;gap:12px;color:var(--muted);font-size:11px;font-family:var(--mono);line-height:2;margin:15px 0}.hint-start{color:var(--ink)}.hint-separator{color:var(--faint)}kbd{font-size:9px;font-weight:400;font-family:var(--mono);padding:3px 6px;border:1px solid #d8dbcf;border-radius:4px;color:var(--muted);white-space:nowrap}.text-button{font-family:var(--mono);font-size:11px;padding:7px 0;line-height:1.5}.footer{border-top:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:16px;height:77px;flex-shrink:0;color:var(--muted);font-family:var(--mono);font-size:10px;margin-top:25px}.footer>div{display:flex;align-items:center;gap:26px}.footer a{font-size:9px}.footer a:hover{color:var(--ink)}.footer kbd{font-size:8px;margin-right:4px}.tempo-track{max-width:460px;margin:0 auto 0;padding-top:10px}.tempo-rail{height:3px;border-radius:5px;background:var(--line);overflow:hidden}.tempo-rail>div{height:100%;width:100%;background:var(--orange);transform-origin:left center}.tempo-caption{display:flex;justify-content:space-between;margin-top:11px;font-family:var(--mono);font-size:10px;color:var(--muted)}#tempo-lives{display:flex;gap:6px;align-items:center}#tempo-lives i{width:7px;height:7px;border-radius:50%;border:1px solid var(--orange);background:var(--orange)}#tempo-lives i.lost{background:transparent;opacity:.35}.ghost-race{display:flex;align-items:center;justify-content:center;gap:12px;font-family:var(--mono);font-size:11px;margin-top:26px;margin-bottom:-20px}.ghost-race b{font-weight:500}.ghost-label{color:var(--muted)}.race-separator{color:var(--line)}#race-delta{color:var(--orange);font-size:9px;min-width:70px}.load-panel{min-height:510px;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;padding:50px 20px}.load-panel h1{font-size:32px;font-weight:400;letter-spacing:-1.2px}.load-panel>p:last-of-type{max-width:500px;font-size:14px;line-height:1.7;color:var(--muted)}.spinner{width:22px;height:22px;border:2px solid var(--line);border-top-color:var(--orange);border-radius:50%;animation:spin 1s linear infinite;margin-bottom:20px}@keyframes spin{to{transform:rotate(360deg)}}.button-row{display:flex;gap:12px;margin-top:16px}.button{display:inline-flex;align-items:center;justify-content:center;gap:20px;min-height:46px;border:1px solid var(--line);padding:11px 22px;border-radius:7px;font-size:12px;transition:background .15s,transform .15s;white-space:nowrap}.button:not(:disabled):hover{background:var(--green-soft)}.button:not(:disabled):active{transform:translateY(1px)}.button.primary{background:var(--ink);color:var(--paper);border-color:var(--ink)}.button.primary:not(:disabled):hover{background:#385447}.button.small{min-height:36px;padding:8px 14px}.button.full{width:100%}.result-panel{max-width:820px;margin:27px auto 35px}.result-heading{display:flex;align-items:center;gap:20px;justify-content:space-between}.eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.2px;color:var(--muted);line-height:1.5}.result-panel h1{font-size:38px;letter-spacing:-1.6px;font-weight:400;margin:14px 0 30px}.new-best{font-family:var(--mono);font-size:9px;color:var(--orange);border:1px solid #e36c4933;padding:6px 9px;border-radius:4px;white-space:nowrap}.result-grid{display:flex;gap:55px;align-items:center;margin-bottom:30px}.result-main{display:flex;flex-direction:column;gap:2px;flex:1}.result-main>span:first-child{font-family:var(--mono);font-size:108px;line-height:1.15;letter-spacing:-8px;font-weight:400}.result-main>span:last-child{font-size:12px;color:var(--muted);padding-left:3px}.result-detail{flex:1.15;display:flex;flex-direction:column;gap:20px;margin:0;border-left:1px solid var(--line);padding-left:42px}.result-detail>div{display:flex;align-items:baseline;gap:20px;justify-content:space-between}.result-detail dt{font-family:var(--mono);font-size:10px;color:var(--muted)}.result-detail dd{font-family:var(--mono);font-size:16px;margin:0;text-align:right;white-space:nowrap}.rhythm-chart{border-top:1px solid var(--line);padding-top:14px}.chart-label{display:flex;justify-content:space-between;font-family:var(--mono);font-size:9px;color:var(--muted)}.rhythm-chart svg{width:100%;height:86px;margin-top:12px;overflow:visible}.slow-words{display:flex;gap:24px;align-items:center;font-family:var(--mono);font-size:10px;padding:8px 0 24px;border-bottom:1px solid var(--line)}.slow-words>span{color:var(--muted);white-space:nowrap}.slow-words>div{display:flex;gap:20px;flex-wrap:wrap}.slow-word{display:flex;gap:7px}.slow-word small{color:var(--muted);font-size:9px;align-self:center}.result-actions{display:flex;align-items:center;gap:10px;margin-top:24px;flex-wrap:wrap}.result-actions>.text-button{margin-left:auto}.result-note{font-size:11px;color:var(--muted);line-height:1.7;margin-top:16px;min-height:18px}.daily-board-button{color:var(--orange)}.replay-controls{display:flex;align-items:center;gap:15px;margin:28px auto 25px;max-width:800px;flex-wrap:wrap}.replay-controls input{flex:1;min-width:100px;padding:0;border:0;box-shadow:none;accent-color:var(--orange)}.replay-time{font-size:10px;font-family:var(--mono);color:var(--muted)}.replay-controls>.text-button{margin-left:auto}.dialog{border:1px solid var(--line);background:var(--paper);color:var(--ink);border-radius:15px;padding:38px;width:min(480px,calc(100vw - 32px));max-height:calc(100svh - 32px);box-shadow:0 25px 100px #263d3320;overflow:auto}.dialog.wide{width:min(590px,calc(100vw - 32px))}.dialog::backdrop{background:#172b2445;backdrop-filter:blur(6px)}.dialog-close{position:absolute;top:15px;right:17px;font-size:27px;font-weight:300;color:var(--muted);width:32px;height:32px;line-height:1}.dialog h2{font-size:29px;font-weight:400;letter-spacing:-1px;line-height:1.2;margin:15px 0 28px}.dialog p{line-height:1.7}.dialog label{display:block;font-size:12px;margin-bottom:12px;line-height:1.7}.dialog input[type=text],.dialog input[type=url],.dialog textarea{width:100%;font-size:14px}.dialog textarea{resize:vertical;min-height:130px;line-height:1.65}.dialog-note{font-size:11px;color:var(--muted);line-height:1.75;margin:18px 0 24px}.dialog .setting-row{display:flex;align-items:center;gap:15px;justify-content:space-between;padding:20px 0;border-bottom:1px solid var(--line);margin:0}.setting-row b{font-size:13px;font-weight:500}.setting-row small{display:block;color:var(--muted);font-size:10px;margin-top:5px}.setting-row select{font-size:11px;padding:10px;max-width:125px;flex-shrink:0}.setting-row .switch{appearance:none;width:38px;height:23px;padding:3px;border-radius:99px;background:var(--line);border:0;position:relative;cursor:pointer;flex-shrink:0}.setting-row .switch:before{content:\"\";display:block;width:17px;height:17px;border-radius:50%;background:var(--paper);box-shadow:0 1px 3px #0002;transition:transform .15s}.setting-row .switch:checked{background:var(--ink)}.setting-row .switch:checked:before{transform:translateX(15px)}.source-tabs{display:flex;gap:22px;border-bottom:1px solid var(--line);margin-bottom:26px}.source-tabs button{padding:0 0 12px;font-family:var(--mono);font-size:11px;color:var(--muted);position:relative}.source-tabs button[aria-selected=true]{color:var(--ink)}.source-tabs button[aria-selected=true]:after{content:\"\";position:absolute;bottom:-1px;left:0;right:0;height:2px;background:var(--orange)}.field-with-button{display:flex;gap:8px}.field-with-button input{min-width:0;flex:1}.field-with-button .button{padding:10px 17px}.topic-chips{display:flex;flex-wrap:wrap;gap:7px;margin:13px 0 0}.topic-chips button{font-family:var(--mono);font-size:9px;border:1px solid var(--line);padding:7px 10px;border-radius:99px}.topic-chips button:hover{background:var(--green-soft)}.paste-footer{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:14px}.paste-footer>span{font-family:var(--mono);font-size:10px;color:var(--muted)}.optional{color:var(--muted);font-size:10px}.inline-error{color:#a33b27;font-size:12px;padding:12px;background:var(--orange-soft);border-radius:6px}.help-steps p{display:flex;gap:20px;font-size:13px;padding-bottom:16px;border-bottom:1px solid var(--line)}.help-steps span{font-family:var(--mono);color:var(--orange);font-size:10px;padding-top:4px}.board-list{list-style:none;padding:0;margin:0}.board-list li{display:grid;grid-template-columns:24px minmax(0,1fr) 65px 50px;gap:12px;border-top:1px solid var(--line);padding:15px 0;font-family:var(--mono);font-size:11px;align-items:center}.board-list li>span:first-child{color:var(--muted);font-size:9px}.board-list a{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.board-list a:hover{color:var(--orange)}.board-list small{color:var(--muted);text-align:right}.board-list strong{text-align:right;font-weight:400}.empty-board{font-size:13px;padding:25px 0;color:var(--muted)}.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--ink);color:var(--paper);font-size:12px;line-height:1.5;padding:13px 23px;border-radius:8px;z-index:20;box-shadow:0 7px 30px #263d3320;max-width:calc(100vw - 32px);text-align:center}.mode-switch.locked{opacity:.7}\n@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}}\n@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}}\n@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}}\n@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}}\n@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}\n","type":"text/css; charset=utf-8"},"/favicon.svg":{"body":"\n","type":"image/svg+xml"},"/game.js":{"body":"import { generateWords, normalizePassage } from './words.js';\n\nexport const MODES = ['30', '60', '50', 'endless', 'tempo'];\nexport const MAX_REPLAY_EVENTS = 30000;\nexport const MAX_REPLAY_WORDS = 6000;\n\nconst clamp = (value, low, high) => Math.max(low, Math.min(high, value));\nconst rounded = value => Math.round(value * 10) / 10;\nconst lengthOf = word => Array.from(word || '').length;\nconst prefixLength = flags => {\n const index = flags.indexOf(false);\n return index < 0 ? flags.length : index;\n};\nconst speed = (chars, durationMs) => durationMs > 0\n ? rounded(chars * 12000 / Math.max(1000, durationMs)) : 0;\n\n/**\n * Recompute public metrics from privacy-safe replay outcomes and word timings.\n * A hit/miss records whether a key matched, never what the visitor typed.\n * Fully complete words count one normalized separator in either advance setting.\n * Backspacing never earns output; accuracy still reflects every typing attempt.\n */\nexport function summarizeRun(run) {\n const words = Array.isArray(run.words) ? run.words : [];\n const events = Array.isArray(run.events) ? run.events : [];\n const timings = Array.isArray(run.wordTimings) ? run.wordTimings : [];\n const durationMs = Math.max(0, Number(run.durationMs) || 0);\n const states = new Map();\n let hits = 0;\n let errors = 0;\n for (const event of events) {\n if (!states.has(event.index)) states.set(event.index, { flags: [], errors: 0 });\n const state = states.get(event.index);\n if (event.type === 'hit') {\n state.flags.push(true);\n hits++;\n } else if (event.type === 'miss') {\n state.flags.push(false);\n state.errors++;\n errors++;\n } else if (event.type === 'backspace') {\n state.flags.pop();\n }\n }\n let correctChars = 0;\n let completedWords = 0;\n let streak = 0;\n let bestStreak = 0;\n const wordSpeeds = [];\n for (let index = 0; index < timings.length; index++) {\n const timing = timings[index];\n const word = words.length ? words[timing.index % words.length] : '';\n const state = states.get(timing.index) || { flags: [], errors: 0 };\n // The backend also validates timing structure. Here a claimed completion must\n // agree with replayed character outcomes before it can earn output.\n const complete = timing.completed === true && state.flags.length === lengthOf(word)\n && prefixLength(state.flags) === lengthOf(word) && lengthOf(word) > 0;\n if (complete) {\n correctChars += lengthOf(word) + 1;\n completedWords++;\n streak = state.errors === 0 ? streak + 1 : 0;\n bestStreak = Math.max(bestStreak, streak);\n const stop = Number.isFinite(timing.typedEnd) ? timing.typedEnd : timing.end;\n const ms = stop - timing.start;\n // A one-letter first word in automatic mode can finish at t=0. There is\n // no measured interval for that word, so don't invent an infinite spike.\n if (ms > 0) wordSpeeds.push({ word, index: timing.index, ms: Math.round(ms), wpm: rounded((lengthOf(word) + 1) * 12000 / ms) });\n } else {\n streak = 0;\n if (index === timings.length - 1 && timing.missed !== true) {\n correctChars += Math.min(lengthOf(word), prefixLength(state.flags));\n }\n }\n }\n const slowest = [...wordSpeeds].sort((a, b) => a.wpm - b.wpm || b.ms - a.ms).slice(0, 5);\n // Preserve the shape of long runs without sending hundreds of graph columns.\n const bucketSize = Math.max(1, Math.ceil(wordSpeeds.length / 80));\n const sparkline = [];\n for (let i = 0; i < wordSpeeds.length; i += bucketSize) {\n const bucket = wordSpeeds.slice(i, i + bucketSize);\n sparkline.push(rounded(bucket.reduce((sum, row) => sum + row.wpm, 0) / bucket.length));\n }\n return {\n wpm: speed(correctChars, durationMs),\n accuracy: hits + errors ? rounded(hits * 100 / (hits + errors)) : 100,\n bestStreak, correctChars, keystrokes: hits + errors, errors,\n completedWords, slowest, sparkline\n };\n}\n\nexport class TypingGame {\n constructor({ words, mode = '30', advance = 'space', paceWpm = 40 } = {}) {\n const supplied = Array.isArray(words) ? normalizePassage(words.join(' ')) : normalizePassage(words);\n this.words = supplied.length ? supplied : generateWords();\n this.mode = MODES.includes(String(mode)) ? String(mode) : '30';\n this.advance = advance === 'letter' ? 'letter' : 'space';\n this.paceWpm = clamp(Number(paceWpm) || 40, 10, 200);\n this.status = 'ready';\n this.wordIndex = 0;\n this.buffer = [];\n this.startedAt = null;\n this.endedAt = null;\n this.lastAt = null;\n this.wordStartedAt = null;\n this.typedEnd = null;\n this.wordErrors = 0;\n this.hits = 0;\n this.errors = 0;\n this.completedWords = 0;\n this.completedChars = 0;\n this.streak = 0;\n this.bestStreak = 0;\n this.misses = 0;\n this.waitingForBeat = false;\n this.allowOptionalSeparator = false;\n this.beatDeadline = null;\n this.beatDurationMs = null;\n this.finishedReason = null;\n this.events = [];\n this.wordTimings = [];\n this.replayTruncated = false;\n }\n\n get word() { return this.words[this.wordIndex % this.words.length]; }\n\n _now(nowMs) {\n const proposed = Number.isFinite(nowMs) ? nowMs : globalThis.performance?.now?.() ?? Date.now();\n this.lastAt = this.lastAt === null ? proposed : Math.max(this.lastAt, proposed);\n return this.lastAt;\n }\n\n _relative(time) { return Math.max(0, Math.round(time - this.startedAt)); }\n\n _event(type, now) {\n this.events.push({ t: this._relative(now), type, index: this.wordIndex });\n if (this.events.length > MAX_REPLAY_EVENTS) {\n this.events.splice(0, this.events.length - MAX_REPLAY_EVENTS);\n this.replayTruncated = true;\n }\n }\n\n _flags() {\n const target = Array.from(this.word);\n return this.buffer.map((character, index) => character === target[index]);\n }\n\n _prefix() { return Math.min(lengthOf(this.word), prefixLength(this._flags())); }\n\n _setBeat(now) {\n this.beatDurationMs = clamp((lengthOf(this.word) + 1) * 12000 / this.paceWpm, 350, 15000);\n this.beatDeadline = now + this.beatDurationMs;\n }\n\n _start(now) {\n this.status = 'running';\n this.startedAt = now;\n this.wordStartedAt = now;\n if (this.mode === 'tempo') this._setBeat(now);\n }\n\n _recordWord(now, completed, missed = false) {\n const timing = {\n index: this.wordIndex,\n start: this._relative(this.wordStartedAt),\n end: this._relative(now),\n completed,\n errors: this.wordErrors\n };\n if (this.typedEnd !== null) timing.typedEnd = this._relative(this.typedEnd);\n if (missed) timing.missed = true;\n this.wordTimings.push(timing);\n if (this.wordTimings.length > MAX_REPLAY_WORDS) {\n this.wordTimings.splice(0, this.wordTimings.length - MAX_REPLAY_WORDS);\n this.replayTruncated = true;\n }\n }\n\n _advance(now, completed, isBeat = false) {\n this._event(isBeat ? 'beat' : 'advance', now);\n this._recordWord(now, completed, isBeat && !completed);\n if (completed) {\n this.completedWords++;\n this.completedChars += lengthOf(this.word) + 1;\n this.streak = this.wordErrors === 0 ? this.streak + 1 : 0;\n this.bestStreak = Math.max(this.bestStreak, this.streak);\n if (isBeat) this.paceWpm = Math.min(240, this.paceWpm * 1.035);\n } else {\n this.streak = 0;\n this.misses++;\n }\n this.wordIndex++;\n this.buffer = [];\n this.wordErrors = 0;\n this.typedEnd = null;\n this.waitingForBeat = false;\n this.allowOptionalSeparator = completed && !isBeat && this.advance === 'letter';\n this.wordStartedAt = now;\n if (this.mode === '50' && this.completedWords >= 50) {\n this._finish(now, 'words', false);\n } else if (this.mode === 'tempo' && this.misses >= 3) {\n this._finish(now, 'misses', false);\n } else if (isBeat) {\n this._setBeat(now);\n }\n }\n\n _finish(now, reason, closeCurrent = true) {\n if (this.status === 'finished') return;\n if (this.startedAt !== null && closeCurrent) this._recordWord(now, false);\n this.status = 'finished';\n this.endedAt = now;\n this.finishedReason = reason;\n }\n\n tick(nowMs) {\n const now = this._now(nowMs);\n if (this.status === 'running') {\n if (this.mode === '30' || this.mode === '60') {\n const deadline = this.startedAt + Number(this.mode) * 1000;\n if (now >= deadline) this._finish(deadline, 'time');\n } else if (this.mode === 'tempo') {\n // Catch up from a suspended tab. No human gains extra time by hiding it.\n while (this.status === 'running' && now >= this.beatDeadline) {\n const deadline = this.beatDeadline;\n this._advance(deadline, this.waitingForBeat, true);\n }\n }\n }\n return this._snapshot(now);\n }\n\n press(key, nowMs) {\n const now = this._now(nowMs);\n this.tick(now);\n if (this.status === 'finished' || typeof key !== 'string') return this._snapshot(now);\n if (key === 'Backspace') {\n if (this.status === 'running' && !this.waitingForBeat && this.buffer.length) {\n this.buffer.pop();\n this.typedEnd = null;\n this._event('backspace', now);\n }\n return this._snapshot(now);\n }\n if (Array.from(key).length !== 1 || /[\\r\\n\\t\\u0000-\\u001f\\u007f]/u.test(key)) return this._snapshot(now);\n if (this.status === 'ready') {\n if (/\\s/u.test(key)) return this._snapshot(now);\n this._start(now);\n }\n if (this.waitingForBeat) return this._snapshot(now);\n // Automatic advancement already earned one normalized separator. Let a\n // typist press that space out of habit without corrupting the next word.\n // Only the very next printable key gets this allowance; extra or mid-word\n // spaces still need correction, and an ignored separator earns no output.\n if (this.allowOptionalSeparator) {\n this.allowOptionalSeparator = false;\n if (key === ' ') return this._snapshot(now);\n }\n if (key === ' ' && this.buffer.join('') === this.word) {\n if (this.mode === 'tempo') {\n this.waitingForBeat = true;\n } else {\n this._advance(now, true);\n }\n return this._snapshot(now);\n }\n // The small bound prevents an accidentally held key growing a buffer forever.\n // Ignored overflow presses are not logged or included in accuracy.\n if (this.buffer.length >= 4096) return this._snapshot(now);\n const target = Array.from(this.word);\n const correct = key === target[this.buffer.length];\n this.buffer.push(key);\n if (correct) this.hits++;\n else {\n this.errors++;\n this.wordErrors++;\n this.streak = 0;\n }\n this._event(correct ? 'hit' : 'miss', now);\n if (this.buffer.join('') === this.word) {\n this.typedEnd = now;\n if (this.mode === 'tempo') this.waitingForBeat = true;\n else if (this.advance === 'letter') this._advance(now, true);\n }\n return this._snapshot(now);\n }\n\n finish(nowMs) {\n const now = this._now(nowMs);\n this.tick(now);\n if (this.status !== 'finished') this._finish(now, 'manual');\n return this._snapshot(now);\n }\n\n _snapshot(now) {\n const stop = this.endedAt ?? now;\n const elapsedMs = this.startedAt === null ? 0 : Math.max(0, stop - this.startedAt);\n const flags = this._flags();\n const errorIndex = flags.indexOf(false);\n const correctPrefix = this._prefix();\n const remainingMs = this.mode === '30' || this.mode === '60'\n ? Math.max(0, Number(this.mode) * 1000 - elapsedMs) : null;\n return {\n status: this.status, mode: this.mode, advance: this.advance,\n wordIndex: this.wordIndex, word: this.word,\n nextWord: this.words[(this.wordIndex + 1) % this.words.length],\n buffer: this.buffer.join(''), correctPrefix, errorIndex,\n startedAt: this.startedAt, elapsedMs, remainingMs,\n wpm: speed(this.completedChars + correctPrefix, elapsedMs),\n accuracy: this.hits + this.errors ? rounded(this.hits * 100 / (this.hits + this.errors)) : 100,\n bestStreak: this.bestStreak, streak: this.streak, misses: this.misses,\n paceWpm: rounded(this.paceWpm),\n beatRemainingMs: this.beatDeadline === null ? null : Math.max(0, this.beatDeadline - now),\n beatDurationMs: this.beatDurationMs,\n waitingForBeat: this.waitingForBeat,\n completedWords: this.completedWords, finishedReason: this.finishedReason,\n correctChars: this.completedChars + correctPrefix,\n keystrokes: this.hits + this.errors, errors: this.errors,\n replayTruncated: this.replayTruncated\n };\n }\n\n snapshot(nowMs) { return this.tick(nowMs); }\n\n result(nowMs) {\n const state = this.tick(nowMs);\n const wordTimings = this.wordTimings.map(timing => ({ ...timing }));\n // Useful for previews or manual save callers, without mutating a running game.\n if (this.status === 'running') {\n const timing = {\n index: this.wordIndex, start: this._relative(this.wordStartedAt),\n end: Math.round(state.elapsedMs), completed: false, errors: this.wordErrors\n };\n if (this.typedEnd !== null) timing.typedEnd = this._relative(this.typedEnd);\n wordTimings.push(timing);\n }\n const run = {\n version: 1, mode: this.mode, advance: this.advance,\n words: [...this.words], durationMs: Math.round(state.elapsedMs),\n finishedReason: this.finishedReason,\n events: this.events.map(event => ({ ...event })), wordTimings,\n replayTruncated: this.replayTruncated\n };\n run.stats = summarizeRun(run);\n if (this.replayTruncated) {\n // The full run still has correct local results. The server refuses partial\n // replay uploads instead of pretending the retained tail is the full run.\n Object.assign(run.stats, {\n wpm: state.wpm, accuracy: state.accuracy, bestStreak: state.bestStreak,\n correctChars: state.correctChars, keystrokes: state.keystrokes,\n errors: state.errors, completedWords: state.completedWords\n });\n }\n return run;\n }\n}\n","type":"text/javascript; charset=utf-8"},"/words.js":{"body":"/** Small, frequency-biased English vocabulary. No network or content tracking. */\nexport const MAX_WORDS = 300;\nexport const MAX_WORD_LENGTH = 64;\n\nconst bands = [\n [6, 'the be to of and a in that have i it for not on with he as you do at this but his by from they we say her she or an will my one all would there their what so up out if about who get which go me when make can like time no just him know take people into year your good some could them see other than then now look only come its over think also back after use two how our work first well way even new want because any these give day most us is are was were has had did been does may more'],\n [3, 'find here thing tell many through long down own world still life hand old right big place every great where help same home small number part turn need start little keep last read word run next move sound light water night day line play write point live around between without under again while before each never always thought change open close end air room story read try left feel let put mean call ask too state set three much really must high something both family leave course problem might different group begin seem country school talk show hear during house question answer study name book city eye few white today enough hard real kind public program follow head face stop once person door stand side sure such friend idea often together learn watch grow walk food hour money mother father reason morning front voice music across moment body love letter black clear bring young fact five become best least happen away large power picture line heart mind system form early right child example simple paper easy ready rhythm quiet quick fast space slow steady type finger beat sharp green blue red warm round smooth clean bright'],\n [1, 'river garden window silver ocean forest mountain summer winter spring autumn cloud stone bird moon sun rain wind tree leaf flower field coast island bridge path trail earth planet star sky shape circle square wonder dream hope memory future history science nature human language rhythm balance pattern signal planet curious delight gentle focus breathe listen calm flow spark glow fresh tiny brave clever useful true build draw smile travel explore create discover enjoy repeat practice improve notice understand imagine believe connect choose solve share follow pause rise drift shine carry reach rest repair shape learn design measure gather arrange search welcome begin finish steady quiet simple vivid mellow nimble honest warm cool sound silence orange yellow purple violet copper velvet cotton glass wood metal honey coffee tea bread apple lemon peach grape mint seed root branch shell pebble feather lantern paper pencil brush canvas poem color phrase melody canvas motion orbit puzzle magic chance wonder narrow wide deep shallow local global daily common subtle sudden patient playful perfect careful simple solid supple clear distinct fluent rapid active bright welcome hello thanks together tomorrow yesterday present minute second Monday Tuesday Wednesday Thursday Friday Saturday Sunday January February March April May June July August September October November December']\n];\n\nconst weightedWords = bands.flatMap(([weight, text]) =>\n text.split(/\\s+/).flatMap(word => Array(weight).fill(word.toLowerCase()))\n);\n\nexport function seededRandom(seed) {\n let hash = 2166136261;\n for (const char of String(seed)) hash = Math.imul(hash ^ char.codePointAt(0), 16777619) >>> 0;\n return () => {\n hash = (hash + 0x6D2B79F5) >>> 0;\n let value = hash;\n value = Math.imul(value ^ value >>> 15, value | 1);\n value ^= value + Math.imul(value ^ value >>> 7, value | 61);\n return ((value ^ value >>> 14) >>> 0) / 4294967296;\n };\n}\n\nexport function generateWords(count = MAX_WORDS, seed) {\n const size = Math.max(1, Math.min(MAX_WORDS, Math.trunc(Number(count) || MAX_WORDS)));\n const random = seed === undefined ? Math.random : seededRandom(seed);\n const words = [];\n for (let index = 0; index < size; index++) {\n let next = weightedWords[Math.floor(random() * weightedWords.length)];\n // Immediate repeats are distracting; repeat the draw once, not forever.\n if (next === words[index - 1]) next = weightedWords[Math.floor(random() * weightedWords.length)];\n words.push(next);\n }\n return words;\n}\n\n/** Normalize visible passage text; return at most 300 words, each at most 64 code points. */\nexport function normalizePassage(text) {\n return String(text ?? '').normalize('NFKC')\n .replace(/[\\u2018\\u2019]/g, \"'\")\n .replace(/[\\u201C\\u201D]/g, '\"')\n .replace(/[\\u2010-\\u2014]/g, '-')\n .replace(/[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F\\u200B-\\u200F\\u202A-\\u202E\\u2066-\\u2069\\uFEFF]/g, '')\n .trim().split(/\\s+/u).filter(Boolean).slice(0, MAX_WORDS)\n .map(word => Array.from(word).slice(0, MAX_WORD_LENGTH).join(''));\n}\n","type":"text/javascript; charset=utf-8"}}; diff --git a/typy/src/game.js b/typy/src/game.js index 8b71a8e..6205b73 100644 --- a/typy/src/game.js +++ b/typy/src/game.js @@ -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; diff --git a/typy/src/page.js b/typy/src/page.js index b75e0b4..775dc06 100644 --- a/typy/src/page.js +++ b/typy/src/page.js @@ -1,4 +1,4 @@ -const TEMPLATE = "\n\n\n \n \n \n \n \n \n \n\n\n
\n
\n typy\n a little more in the moment.\n \n
\n\n
\n
\n
\n \n \n \n \n \n \n
\n \n
\n\n \n\n
\n
\n
a fresh start
\n
\n
\n
\n
pace0wpm
\n
accuracy100%
\n
remaining30s
\n
\n \n
\n 001\n
\n
\n
\n
\n
up next
\n
\n \n
\n \n

type the word to begin·space next word

\n \n
\n \n
\n\n \n
\n\n \n
\n\n \n
\n

make yourself comfortable

your rhythm, your rules.

\n \n \n

Changes start a fresh run. Your best scores stay on this device.

\n \n
\n\n \n
\n

words worth spending time with

what’s on your mind?

\n
\n
\n \n
\n
\n

A short, AI-written passage. The topic becomes the URL, so you can send it to a friend.

\n
\n \n \n
\n\n \n
\n

a good rhythm is worth sharing

leave a little ghost.

\n

The link makes this passage, score, and replay public. Your mistyped characters are never saved.

\n \n \n
\n\n \n
\n

same words. a different rhythm.

the daily board.

One passage. Sixty seconds. A fresh start at midnight UTC.

\n
\n\n \n
\n

less looking. more doing.

one word at a time.

\n

01Type the word in the middle. Your first letter starts the clock.

02Use Backspace to fix a mistake, then Space for the next word. Switch to “last letter” in settings for automatic advances.

03Try tempo: finish each word before the beat. Keep up and it gets faster. Three missed beats end the run.

\n

WPM counts five correct output characters as one word. Accuracy measures correct key attempts. Your best scores and settings stay on this device.

\n
\n \n\n\n"; +const TEMPLATE = "\n\n\n \n \n \n \n \n \n \n\n\n
\n
\n typy\n a little more in the moment.\n \n
\n\n
\n
\n
\n \n \n \n \n \n \n
\n \n
\n\n \n\n
\n
\n
a fresh start
\n
\n
\n
\n
pace0wpm
\n
accuracy100%
\n
remaining30s
\n
\n \n
\n 001\n
\n
\n
\n
up next
\n
\n \n
\n \n

type the word to begin·space next word

\n \n
\n \n
\n\n \n
\n\n \n
\n\n \n
\n

make yourself comfortable

your rhythm, your rules.

\n \n \n

Changes start a fresh run. Your best scores stay on this device.

\n \n
\n\n \n
\n

words worth spending time with

what’s on your mind?

\n
\n
\n \n
\n
\n

A short, AI-written passage. The topic becomes the URL, so you can send it to a friend.

\n
\n \n \n
\n\n \n
\n

a good rhythm is worth sharing

leave a little ghost.

\n

The link makes this passage, score, and replay public. Your mistyped characters are never saved.

\n \n \n
\n\n \n
\n

same words. a different rhythm.

the daily board.

One passage. Sixty seconds. A fresh start at midnight UTC.

\n
\n\n \n
\n

less looking. more doing.

one word at a time.

\n

01Type the word in the middle. Your first letter starts the clock.

02Use Backspace to fix a mistake, then Space for the next word. Switch to “last letter” in settings for automatic advances.

03Try tempo: finish each word before the beat. Keep up and it gets faster. Three missed beats end the run.

\n

WPM counts five correct output characters as one word. Accuracy measures correct key attempts. Your best scores and settings stay on this device.

\n
\n \n\n\n"; const escape = (s) => String(s ?? "").replace(/[&<>"\x27]/g, c => ({"&":"&","<":"<",">":">",'"':""","\x27":"'"}[c])); export function renderPage(meta = {}) { const title = escape(meta.title || "typy — one word at a time"); diff --git a/typy/test/game.test.js b/typy/test/game.test.js index df46c81..bc8b6aa 100644 --- a/typy/test/game.test.js +++ b/typy/test/game.test.js @@ -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); diff --git a/typy/test/responsiveness.mjs b/typy/test/responsiveness.mjs new file mode 100644 index 0000000..ffbbb6a --- /dev/null +++ b/typy/test/responsiveness.mjs @@ -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(); }