one moment
\nfinding the words…
\n \n \ntype the word to begin·space next word
\n \n30 seconds, well spent
personal best ↗there’s your rhythm.
\n- \n
- accuracy
- 100%
- best clean streak
- 0 words
- words completed
- 0
diff --git a/typy/README.md b/typy/README.md index 5c472ef..6d2fbaa 100644 --- a/typy/README.md +++ b/typy/README.md @@ -9,7 +9,7 @@ One word at a time. Type it, find the next one, settle into a rhythm. - 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. 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. +- 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 optional faded previous/next words sit beside the centered 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. diff --git a/typy/public/app.js b/typy/public/app.js index 2197513..a5236eb 100644 --- a/typy/public/app.js +++ b/typy/public/app.js @@ -88,6 +88,7 @@ function updateOptions() { $('advance-hint').innerHTML = selectedMode === 'tempo' ? 'finish before the beat' : effectiveAdvance === 'letter' ? 'last letter → next word' : 'space next word'; if ($('upcoming').hidden === settings.preview) wordLayoutDirty = true; $('upcoming').hidden = !settings.preview; + $('previous-context').hidden = !settings.preview || !$('previous-word').textContent; $('advance-setting').value = effectiveAdvance; $('advance-setting').disabled = Boolean(ghost); $('preview-setting').checked = settings.preview; @@ -135,7 +136,7 @@ function beginRun({freshWords = false, focus = false} = {}) { if (focus || innerWidth > 700) focusTyping(); } -function drawWord(word, buffer, index, nextWord, flags = null) { +function drawWord(word, buffer, index, nextWord, flags = null, previousWord = '') { const key = word + '|' + buffer + '|' + index + '|' + (flags?.join('') || ''); if (key === lastWordKey && !wordLayoutDirty) return; const node = $('word'); @@ -163,6 +164,8 @@ function drawWord(word, buffer, index, nextWord, flags = null) { $('typing-input').setAttribute('aria-label', 'Type the word: ' + word); setText('word-number', String(index + 1).padStart(3, '0')); setText('next-word', nextWord || ''); + setText('previous-word', previousWord); + $('previous-context').hidden = !settings.preview || !previousWord; wordLayoutDirty = true; } wordSpans.forEach((span, i) => { @@ -183,19 +186,22 @@ function drawWord(word, buffer, index, nextWord, flags = null) { const style = getComputedStyle(node); const natural = parseFloat(style.fontSize); const tracking = parseFloat(style.letterSpacing) / natural; - // The active word stays at the page center. Its preview only uses the - // remaining space to the right, without shifting or shrinking the target. + // The active word stays at the page center. Context words only use the + // remaining space on either side, without shifting or shrinking the target. const anchor = .5; const available = Math.max(1, stageWidth - 28); const size = width > available ? Math.max(8, natural * available / width * .96) : natural; if (size !== natural) node.style.fontSize = size + 'px'; $('word-pair').style.left = anchor * 100 + '%'; const gap = Math.max(12, size * .45); - const upcoming = $('upcoming'); - upcoming.style.fontSize = size + 'px'; - upcoming.style.letterSpacing = (Number.isFinite(tracking) ? tracking * size : 0) + 'px'; - upcoming.style.left = 'calc(100% + ' + gap + 'px)'; - upcoming.style.width = Math.max(0, stageWidth * (1 - anchor) - width * size / natural / 2 - gap - 14) + 'px'; + const contextWidth = Math.max(0, stageWidth / 2 - width * size / natural / 2 - gap - 14) + 'px'; + for (const id of ['previous-context', 'upcoming']) { + const context = $(id); + context.style.fontSize = size + 'px'; + context.style.letterSpacing = (Number.isFinite(tracking) ? tracking * size : 0) + 'px'; + context.style.width = contextWidth; + context.style[id === 'previous-context' ? 'right' : 'left'] = 'calc(100% + ' + gap + 'px)'; + } wordLayoutDirty = false; } lastWordKey = key; @@ -204,7 +210,8 @@ function drawWord(word, buffer, index, nextWord, flags = null) { } function renderGame(state) { - drawWord(state.word, state.buffer, state.wordIndex, state.nextWord); + const previous = state.wordIndex > 0 ? game.words[(state.wordIndex - 1) % game.words.length] : ''; + drawWord(state.word, state.buffer, state.wordIndex, state.nextWord, null, previous); setText('live-wpm', pretty(state.wpm)); setText('live-accuracy', pretty(state.accuracy)); setClass($('status-dot'), 'active', state.status === 'running'); @@ -430,7 +437,8 @@ function renderReplay(position) { } const characters = Array.from(word); const buffer = flags.map((flag, i) => flag ? characters[i] || '' : '·').join(''); - drawWord(word, buffer, index, run.words[(index + 1) % run.words.length], flags); + const previous = index > 0 ? run.words[(index - 1) % run.words.length] : ''; + drawWord(word, buffer, index, run.words[(index + 1) % run.words.length], flags, previous); $('live-wpm').textContent = pretty(run.stats.wpm); $('live-accuracy').textContent = pretty(run.stats.accuracy); $('live-time').textContent = Math.floor(position / 1000); diff --git a/typy/public/index.html b/typy/public/index.html index 66628d2..ea344a4 100644 --- a/typy/public/index.html +++ b/typy/public/index.html @@ -59,6 +59,7 @@
make yourself comfortable
Changes start a fresh run. Your best scores stay on this device.
diff --git a/typy/public/style.css b/typy/public/style.css index c905298..65ac674 100644 --- a/typy/public/style.css +++ b/typy/public/style.css @@ -1,5 +1,5 @@ :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}.word-pair{position:absolute;left:50%;top:50%;width:max-content;transform:translate(-50%,-50%)}.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:none;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:calc(100% + .45em);top:0;height:100%;display:flex;align-items:center;overflow:hidden;font-family:var(--mono);font-size:inherit;font-weight:500;line-height:1.3;letter-spacing:-.075em;color:#9da791;white-space:nowrap;pointer-events:none}.upcoming b{font:inherit;letter-spacing:inherit;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}.word-pair{position:absolute;left:50%;top:50%;width:max-content;transform:translate(-50%,-50%)}.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:none;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,.previous-context{position:absolute;left:calc(100% + .45em);top:0;height:100%;display:flex;align-items:center;overflow:hidden;font-family:var(--mono);font-size:inherit;font-weight:500;line-height:1.3;letter-spacing:-.075em;color:#9da791;white-space:nowrap;pointer-events:none}.previous-context{left:auto;right:calc(100% + .45em);justify-content:flex-end}.upcoming b,.previous-context b{font:inherit;letter-spacing:inherit;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{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%}.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}} diff --git a/typy/src/assets.js b/typy/src/assets.js index e66e40e..2c7f445 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 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 if ($('upcoming').hidden === settings.preview) wordLayoutDirty = true;\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 stageWidth = $('stage').clientWidth;\n const width = node.scrollWidth;\n const style = getComputedStyle(node);\n const natural = parseFloat(style.fontSize);\n const tracking = parseFloat(style.letterSpacing) / natural;\n // The active word stays at the page center. Its preview only uses the\n // remaining space to the right, without shifting or shrinking the target.\n const anchor = .5;\n const available = Math.max(1, stageWidth - 28);\n const size = width > available ? Math.max(8, natural * available / width * .96) : natural;\n if (size !== natural) node.style.fontSize = size + 'px';\n $('word-pair').style.left = anchor * 100 + '%';\n const gap = Math.max(12, size * .45);\n const upcoming = $('upcoming');\n upcoming.style.fontSize = size + 'px';\n upcoming.style.letterSpacing = (Number.isFinite(tracking) ? tracking * size : 0) + 'px';\n upcoming.style.left = 'calc(100% + ' + gap + 'px)';\n upcoming.style.width = Math.max(0, stageWidth * (1 - anchor) - width * size / natural / 2 - gap - 14) + 'px';\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 = '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 ? '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});\n else {\n updateOptions();\n if (changed && view === 'replay') renderReplay(replay.position);\n }\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}.word-pair{position:absolute;left:50%;top:50%;width:max-content;transform:translate(-50%,-50%)}.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:none;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:calc(100% + .45em);top:0;height:100%;display:flex;align-items:center;overflow:hidden;font-family:var(--mono);font-size:inherit;font-weight:500;line-height:1.3;letter-spacing:-.075em;color:#9da791;white-space:nowrap;pointer-events:none}.upcoming b{font:inherit;letter-spacing:inherit;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%}.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"}}; +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 if ($('upcoming').hidden === settings.preview) wordLayoutDirty = true;\n $('upcoming').hidden = !settings.preview;\n $('previous-context').hidden = !settings.preview || !$('previous-word').textContent;\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, previousWord = '') {\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 setText('previous-word', previousWord);\n $('previous-context').hidden = !settings.preview || !previousWord;\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 stageWidth = $('stage').clientWidth;\n const width = node.scrollWidth;\n const style = getComputedStyle(node);\n const natural = parseFloat(style.fontSize);\n const tracking = parseFloat(style.letterSpacing) / natural;\n // The active word stays at the page center. Context words only use the\n // remaining space on either side, without shifting or shrinking the target.\n const anchor = .5;\n const available = Math.max(1, stageWidth - 28);\n const size = width > available ? Math.max(8, natural * available / width * .96) : natural;\n if (size !== natural) node.style.fontSize = size + 'px';\n $('word-pair').style.left = anchor * 100 + '%';\n const gap = Math.max(12, size * .45);\n const contextWidth = Math.max(0, stageWidth / 2 - width * size / natural / 2 - gap - 14) + 'px';\n for (const id of ['previous-context', 'upcoming']) {\n const context = $(id);\n context.style.fontSize = size + 'px';\n context.style.letterSpacing = (Number.isFinite(tracking) ? tracking * size : 0) + 'px';\n context.style.width = contextWidth;\n context.style[id === 'previous-context' ? 'right' : 'left'] = 'calc(100% + ' + gap + 'px)';\n }\n wordLayoutDirty = false;\n }\n lastWordKey = key;\n lastWordIndex = index;\n renderedWord = word;\n}\n\nfunction renderGame(state) {\n const previous = state.wordIndex > 0 ? game.words[(state.wordIndex - 1) % game.words.length] : '';\n drawWord(state.word, state.buffer, state.wordIndex, state.nextWord, null, previous);\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 = '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 ? '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});\n else {\n updateOptions();\n if (changed && view === 'replay') renderReplay(replay.position);\n }\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}.word-pair{position:absolute;left:50%;top:50%;width:max-content;transform:translate(-50%,-50%)}.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:none;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,.previous-context{position:absolute;left:calc(100% + .45em);top:0;height:100%;display:flex;align-items:center;overflow:hidden;font-family:var(--mono);font-size:inherit;font-weight:500;line-height:1.3;letter-spacing:-.075em;color:#9da791;white-space:nowrap;pointer-events:none}.previous-context{left:auto;right:calc(100% + .45em);justify-content:flex-end}.upcoming b,.previous-context b{font:inherit;letter-spacing:inherit;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%}.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/page.js b/typy/src/page.js index c07403a..f7826ee 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\none moment
\ntype the word to begin·space next word
\n \n30 seconds, well spent
personal best ↗one moment
\ntype the word to begin·space next word
\n \n30 seconds, well spent
personal best ↗