Compare commits

...
Author SHA1 Message Date
teahubxyz 46deaccc10 ui(typy): show faded previous and next words around the centered target
ci / test (pull_request) Successful in 49s
2026-10-25 00:02:59 +00:00
teahubxyz 05e68742e4 Merge pull request 'Keep typy's main word centered with its preview on the right' (#258) from typy/center-target-254 into main
ci / test (push) Successful in 53s
sync / sync (push) Successful in 23s
Merge centered active-word layout
2026-10-25 00:00:31 +00:00
6 changed files with 24 additions and 15 deletions

No files matched your search

+1 -1
View File
@@ -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.
+18 -10
View File
@@ -88,6 +88,7 @@ function updateOptions() {
$('advance-hint').innerHTML = selectedMode === 'tempo' ? 'finish before the beat' : effectiveAdvance === 'letter' ? 'last letter → next word' : '<kbd>space</kbd> 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);
+2 -1
View File
@@ -59,6 +59,7 @@
<span class="word-number" id="word-number" aria-hidden="true">001</span>
<div class="word-wrap">
<div class="word-pair" id="word-pair">
<div class="previous-context" id="previous-context" aria-hidden="true" hidden><b id="previous-word"></b></div>
<div class="word" id="word" aria-live="off" aria-label="Word to type"></div>
<div class="upcoming" id="upcoming" aria-hidden="true"><b id="next-word"></b></div>
</div>
@@ -109,7 +110,7 @@
<form method="dialog"><button class="dialog-close" aria-label="Close settings">×</button></form>
<p class="eyebrow">make yourself comfortable</p><h2>your rhythm, your rules.</h2>
<label class="setting-row"><span><b>Next word</b><small>Choose what moves you forward.</small></span><select id="advance-setting"><option value="space">press space</option><option value="letter">last letter</option></select></label>
<label class="setting-row"><span><b>A little look ahead</b><small>Show the upcoming word.</small></span><input class="switch" id="preview-setting" type="checkbox" checked></label>
<label class="setting-row"><span><b>A little context</b><small>Show the previous and upcoming words.</small></span><input class="switch" id="preview-setting" type="checkbox" checked></label>
<p class="dialog-note">Changes start a fresh run. Your best scores stay on this device.</p>
<button class="button primary full" id="settings-done">all set</button>
</dialog>
+1 -1
View File
@@ -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 .incLine truncated
*{box-sizing:border-box}html{min-height:100%;background:var(--paper)}body{margin:0;color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased}button,input,select,textarea{font:inherit;color:inherit}button,a,input,select,textarea{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:default;opacity:.4}button,a,input,select,textarea{outline-offset:5px}button:focus-visible,a:focus-visible{outline:2px solid var(--orange)}button{border:0;background:none}a{color:inherit;text-decoration:none}button,a{touch-action:manipulation}input,textarea,select{border:1px solid var(--line);border-radius:8px;background:#fffdf6;padding:14px;outline:none}input:focus,textarea:focus,select:focus{border-color:var(--ink);box-shadow:0 0 0 2px #263d3312}input::placeholder,textarea::placeholder{color:#969b91}[hidden]{display:none!important}::selection{background:#e8b9a4;color:var(--ink)}.shell{max-width:1280px;margin:auto;padding:0 64px;min-height:100svh;display:flex;flex-direction:column}.masthead{height:112px;display:flex;align-items:center;border-bottom:1px solid var(--line);gap:26px;flex-shrink:0}.brand{font-size:48px;font-weight:800;letter-spacing:-4px;line-height:1;display:flex;align-items:baseline;gap:6px}.brand-dot{width:9px;height:9px;border-radius:50%;background:var(--orange)}.brand-note{font-family:var(--mono);font-size:11px;color:var(--muted);padding-top:11px;letter-spacing:-.2px}.top-nav{margin-left:auto;display:flex;align-items:center;gap:24px;font-size:13px}.top-nav a{display:flex;align-items:center;gap:8px}.top-nav a:hover,.text-button:hover{color:var(--orange)}.sun{color:var(--orange);font-size:23px}.icon-button{width:32px;height:36px;display:grid;place-items:center;padding:0}.help-button{font-family:var(--mono);font-size:17px;width:25px;height:25px;border:1px solid var(--faint);border-radius:50%}main{flex:1}.toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:28px 0 20px}.mode-switch{display:flex;align-items:center;gap:4px;border:1px solid var(--line);border-radius:99px;padding:5px}.mode-switch button{font-family:var(--mono);font-size:12px;line-height:30px;padding:0 16px;border-radius:99px;white-space:nowrap;color:var(--muted);transition:color .16s,background .16s}.mode-switch button[aria-pressed="true"]{background:var(--ink);color:var(--paper)}.mode-switch button:not(:disabled):hover{color:var(--ink);background:var(--green-soft)}.mode-switch button[aria-pressed="true"]:hover{background:#365446;color:var(--paper)}.mode-switch button[data-mode="endless"]{font-size:20px}.mode-divider{height:18px;border-left:1px solid var(--line);margin:0 5px}.tempo-option{display:flex;align-items:center;gap:8px}.tempo-icon{display:flex;align-items:center;gap:2px;height:14px}.tempo-icon i{width:2px;height:6px;background:currentColor}.tempo-icon i:nth-child(2){height:14px}.tempo-icon i:nth-child(3){height:10px}.source-button{font-family:var(--mono);font-size:12px;display:flex;align-items:center;gap:15px;max-width:40%;padding:12px 0 12px 12px}.source-button span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.source-button span:last-child{color:var(--orange);font-size:20px}.play-panel{padding-top:21px}.run-strip{display:flex;align-items:center;justify-content:space-between;gap:16px;font-family:var(--mono);font-size:10px;color:var(--muted);letter-spacing:.3px}.run-status{display:flex;gap:9px;align-items:center}.status-dot{width:6px;height:6px;border-radius:50%;background:var(--faint)}.status-dot.active{background:var(--orange);box-shadow:0 0 0 4px #e36c4914}.best-score{font-size:10px}.metrics{display:flex;justify-content:center;gap:62px;margin:44px 0 4px;user-select:none}.metrics>div{display:flex;align-items:baseline;gap:5px;min-width:102px;position:relative}.metric-label{font-family:var(--mono);font-size:9px;position:absolute;top:-19px;left:0;color:var(--muted)}.metric-value{font-family:var(--mono);font-weight:400;font-size:27px;letter-spacing:-1.5px;font-variant-numeric:tabular-nums}.metric-unit{font-size:10px;color:var(--muted);font-family:var(--mono)}.time-metric .metric-value{color:var(--orange)}.stage{height:310px;position:relative;cursor:text;display:flex;align-items:center;justify-content:center;isolation:isolate;overflow:clip}.word-number{position:absolute;left:0;top:48%;font-family:var(--mono);font-size:10px;color:var(--faint);writing-mode:vertical-rl;transform:rotate(180deg);letter-spacing:2px}.word-wrap{position:relative;width:100%;height:180px}.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 .incLine truncated
@media(min-height:860px) and (min-width:701px){.stage{height:360px}.metrics{margin-top:64px}.play-panel{padding-top:32px}.input-row{padding-top:30px}.toolbar{padding-top:34px}}
@media(max-width:900px){.shell{padding:0 36px}.brand-note{font-size:9px}.top-nav{gap:18px}.mode-switch button{padding:0 13px}.stage{height:290px}.metrics{gap:40px}.result-main>span:first-child{font-size:95px}.result-grid{gap:30px}.result-detail{padding-left:30px}}
@media(max-width:700px){.shell{padding:0 23px}.masthead{height:84px;gap:10px}.brand{font-size:41px;letter-spacing:-3.5px}.brand-dot{width:7px;height:7px}.brand-note{display:none}.top-nav{gap:19px;font-size:12px}.help-button{display:none}.toolbar{padding:22px 0 16px;flex-direction:column;align-items:stretch;gap:8px}.mode-switch{align-self:center;max-width:100%;gap:2px;padding:4px}.mode-switch button{padding:0 13px;font-size:11px;line-height:30px}.mode-divider{margin:0 3px}.source-button{font-size:10px;max-width:100%;padding:8px 0;align-self:center;gap:11px}.source-button span:last-child{font-size:15px}.play-panel{padding-top:9px}.run-strip{font-size:8px}.best-score{font-size:8px}.metrics{gap:28px;margin:44px 0 0}.metrics>div{min-width:72px}.metric-value{font-size:24px}.metric-label{font-size:8px;top:-18px}.metric-unit{font-size:8px}.stage{height:236px}.word-wrap{height:140px}.word{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}}
+1 -1
View File
@@ -1 +1 @@
export const ASSETS = {"/app.js":{"body":"import { TypingGame } from '/game.js';\nimport { generateWords, normalizePassage } from '/words.js';\n\nconst $ = id => document.getElementById(id);\nconst escapeHTML = value => String(value ?? '').replace(/[&<>\"']/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[char]));\nconst now = () => performance.now();\nconst pretty = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value))) : '0';\nconst round1 = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value) * 10) / 10) : '0';\nconst modeNames = {'30':'30 seconds','60':'60 seconds','50':'50 words','endless':'endless','tempo':'tempo'};\nconst readLocal = (key, fallback) => { try { return JSON.parse(localStorage.getItem('typy.' + key)) ?? fallback; } catch { return fallback; } };\nconst writeLocal = (key, value) => { try { localStorage.setItem('typy.' + key, JSON.stringify(value)); } catch {} };\nconst settings = {...{advance:'space',preview:true}, ...readLocal('settings', {})};\nconst bests = readLocal('bests', {});\nconst reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;\nlet selectedMode = '30';\nlet source = {kind:'common'};\nlet passage = [];\nlet game = null;\nlet ghost = null;\nlet view = 'play';\nlet currentResult = null;\nlet shareId = null;\nlet shareIntent = 'result';\nlet currentPath = '/';\nlet routeSerial = 0;\nlet lastWordKey = '';\nlet lastWordIndex = -1;\nlet renderedWord = '';\nlet wordSpans = [];\nlet overflowSpan = null;\nlet wordLayoutDirty = true;\nlet errorAnimation = null;\nlet lastClockPaint = 0;\nlet lastErrorCount = 0;\nlet replay = {playing:false,position:0,anchor:0,anchorPosition:0};\nlet toastTimer;\nlet transitioning = false;\nlet isComposing = false;\n\nfunction setText(id, value) {\n const node = $(id), text = String(value);\n if (node.textContent !== text) node.textContent = text;\n}\n\nfunction setClass(node, name, enabled) {\n if (node.classList.contains(name) !== enabled) node.classList.toggle(name, enabled);\n}\n\nfunction toast(message) {\n clearTimeout(toastTimer);\n $('toast').textContent = message;\n $('toast').hidden = false;\n toastTimer = setTimeout(() => { $('toast').hidden = true; }, 4500);\n}\n\nasync function api(path, options = {}) {\n const response = await fetch(path, {...options, headers:{...(options.body ? {'Content-Type':'application/json'} : {}), ...(options.headers || {})}});\n let body;\n try { body = await response.json(); } catch { throw new Error('The server could not answer. Please try again.'); }\n if (!response.ok) {\n const message = typeof body.error === 'string' ? body.error : body.error?.message || body.message;\n const error = new Error(message || 'Something interrupted that request. Please try again.');\n error.status = response.status;\n throw error;\n }\n return body;\n}\n\nfunction sourceName() {\n if (ghost) return 'racing ' + (currentResult?.name || 'a friendly ghost');\n if (source.kind === 'topic') return (source.topic || '').replaceAll('-', ' ');\n if (source.kind === 'daily') return 'the daily · ' + (source.day || 'today');\n if (source.kind === 'paste') return 'your own words';\n return 'common words';\n}\n\nfunction localBestKey() { return selectedMode; }\nfunction updateOptions() {\n const locked = source.kind === 'daily' || Boolean(ghost);\n const effectiveAdvance = ghost ? currentResult.run.advance : settings.advance;\n $('mode-switch').classList.toggle('locked', locked);\n document.querySelectorAll('[data-mode]').forEach(button => {\n button.setAttribute('aria-pressed', String(button.dataset.mode === selectedMode));\n button.disabled = locked || view === 'replay';\n button.title = locked ? (ghost ? 'A race uses the original run’s mode.' : 'The daily is a shared 60-second run.') : '';\n });\n $('source-label').textContent = sourceName();\n $('advance-hint').innerHTML = selectedMode === 'tempo' ? 'finish before the beat' : effectiveAdvance === 'letter' ? 'last letter → next word' : '<kbd>space</kbd> next word';\n 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 Line truncated
export const ASSETS = {"/app.js":{"body":"import { TypingGame } from '/game.js';\nimport { generateWords, normalizePassage } from '/words.js';\n\nconst $ = id => document.getElementById(id);\nconst escapeHTML = value => String(value ?? '').replace(/[&<>\"']/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[char]));\nconst now = () => performance.now();\nconst pretty = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value))) : '0';\nconst round1 = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value) * 10) / 10) : '0';\nconst modeNames = {'30':'30 seconds','60':'60 seconds','50':'50 words','endless':'endless','tempo':'tempo'};\nconst readLocal = (key, fallback) => { try { return JSON.parse(localStorage.getItem('typy.' + key)) ?? fallback; } catch { return fallback; } };\nconst writeLocal = (key, value) => { try { localStorage.setItem('typy.' + key, JSON.stringify(value)); } catch {} };\nconst settings = {...{advance:'space',preview:true}, ...readLocal('settings', {})};\nconst bests = readLocal('bests', {});\nconst reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;\nlet selectedMode = '30';\nlet source = {kind:'common'};\nlet passage = [];\nlet game = null;\nlet ghost = null;\nlet view = 'play';\nlet currentResult = null;\nlet shareId = null;\nlet shareIntent = 'result';\nlet currentPath = '/';\nlet routeSerial = 0;\nlet lastWordKey = '';\nlet lastWordIndex = -1;\nlet renderedWord = '';\nlet wordSpans = [];\nlet overflowSpan = null;\nlet wordLayoutDirty = true;\nlet errorAnimation = null;\nlet lastClockPaint = 0;\nlet lastErrorCount = 0;\nlet replay = {playing:false,position:0,anchor:0,anchorPosition:0};\nlet toastTimer;\nlet transitioning = false;\nlet isComposing = false;\n\nfunction setText(id, value) {\n const node = $(id), text = String(value);\n if (node.textContent !== text) node.textContent = text;\n}\n\nfunction setClass(node, name, enabled) {\n if (node.classList.contains(name) !== enabled) node.classList.toggle(name, enabled);\n}\n\nfunction toast(message) {\n clearTimeout(toastTimer);\n $('toast').textContent = message;\n $('toast').hidden = false;\n toastTimer = setTimeout(() => { $('toast').hidden = true; }, 4500);\n}\n\nasync function api(path, options = {}) {\n const response = await fetch(path, {...options, headers:{...(options.body ? {'Content-Type':'application/json'} : {}), ...(options.headers || {})}});\n let body;\n try { body = await response.json(); } catch { throw new Error('The server could not answer. Please try again.'); }\n if (!response.ok) {\n const message = typeof body.error === 'string' ? body.error : body.error?.message || body.message;\n const error = new Error(message || 'Something interrupted that request. Please try again.');\n error.status = response.status;\n throw error;\n }\n return body;\n}\n\nfunction sourceName() {\n if (ghost) return 'racing ' + (currentResult?.name || 'a friendly ghost');\n if (source.kind === 'topic') return (source.topic || '').replaceAll('-', ' ');\n if (source.kind === 'daily') return 'the daily · ' + (source.day || 'today');\n if (source.kind === 'paste') return 'your own words';\n return 'common words';\n}\n\nfunction localBestKey() { return selectedMode; }\nfunction updateOptions() {\n const locked = source.kind === 'daily' || Boolean(ghost);\n const effectiveAdvance = ghost ? currentResult.run.advance : settings.advance;\n $('mode-switch').classList.toggle('locked', locked);\n document.querySelectorAll('[data-mode]').forEach(button => {\n button.setAttribute('aria-pressed', String(button.dataset.mode === selectedMode));\n button.disabled = locked || view === 'replay';\n button.title = locked ? (ghost ? 'A race uses the original run’s mode.' : 'The daily is a shared 60-second run.') : '';\n });\n $('source-label').textContent = sourceName();\n $('advance-hint').innerHTML = selectedMode === 'tempo' ? 'finish before the beat' : effectiveAdvance === 'letter' ? 'last letter → next word' : '<kbd>space</kbd> next word';\n 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\nfuLine truncated
+1 -1
View File
@@ -1,4 +1,4 @@
const TEMPLATE = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover\">\n <meta name=\"theme-color\" content=\"#f5f3ec\">\n <!--OG-->\n <link rel=\"icon\" href=\"/favicon.svg\" type=\"image/svg+xml\">\n <link rel=\"stylesheet\" href=\"/style.css\">\n <script type=\"module\" src=\"/app.js\"></script>\n</head>\n<body>\n <div class=\"shell\">\n <header class=\"masthead\">\n <a class=\"brand\" href=\"/\" aria-label=\"typy home\">typy<span class=\"brand-dot\" aria-hidden=\"true\"></span></a>\n <span class=\"brand-note\">a little more in the moment.</span>\n <nav class=\"top-nav\" aria-label=\"Main\">\n <a href=\"/daily\" id=\"daily-link\"><span class=\"sun\" aria-hidden=\"true\">✳</span> the daily</a>\n <button class=\"icon-button\" id=\"settings-open\" aria-label=\"Typing settings\" title=\"Typing settings\">\n <svg width=\"21\" height=\"21\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" aria-hidden=\"true\"><path d=\"M4 7h16M4 17h16\"/><circle cx=\"9\" cy=\"7\" r=\"3\" fill=\"var(--paper)\"/><circle cx=\"15\" cy=\"17\" r=\"3\" fill=\"var(--paper)\"/></svg>\n </button>\n <button class=\"icon-button help-button\" id=\"help-open\" aria-label=\"How to play\">?</button>\n </nav>\n </header>\n\n <main id=\"main\">\n <section class=\"toolbar\" aria-label=\"Run options\">\n <div class=\"mode-switch\" id=\"mode-switch\" aria-label=\"Run length\">\n <button data-mode=\"30\" aria-pressed=\"true\">30s</button>\n <button data-mode=\"60\" aria-pressed=\"false\">60s</button>\n <button data-mode=\"50\" aria-pressed=\"false\">50 words</button>\n <button data-mode=\"endless\" aria-pressed=\"false\" aria-label=\"Endless\">∞</button>\n <span class=\"mode-divider\" aria-hidden=\"true\"></span>\n <button data-mode=\"tempo\" class=\"tempo-option\" aria-pressed=\"false\"><span class=\"tempo-icon\" aria-hidden=\"true\"><i></i><i></i><i></i></span>tempo</button>\n </div>\n <button class=\"source-button\" id=\"source-open\"><span id=\"source-label\">common words</span><span aria-hidden=\"true\">↗</span></button>\n </section>\n\n <section id=\"load-panel\" class=\"load-panel\" hidden aria-live=\"polite\">\n <div class=\"spinner\" id=\"loading-spinner\" aria-hidden=\"true\"></div>\n <p class=\"eyebrow\" id=\"load-kicker\">one moment</p>\n <h1 id=\"load-title\">finding the words…</h1>\n <p id=\"load-description\"></p>\n <div class=\"button-row\" id=\"load-actions\" hidden><button class=\"button primary\" id=\"load-retry\">try again</button><button class=\"button\" id=\"load-common\">use common words</button></div>\n </section>\n\n <section id=\"play-panel\" class=\"play-panel\" aria-label=\"Typing stage\">\n <div class=\"run-strip\">\n <div class=\"run-status\"><span class=\"status-dot\" id=\"status-dot\"></span><span id=\"run-status\">a fresh start</span></div>\n <div id=\"best-score\" class=\"best-score\"></div>\n </div>\n <div class=\"metrics\" aria-label=\"Live score\">\n <div><span class=\"metric-label\">pace</span><span class=\"metric-value\" id=\"live-wpm\">0</span><span class=\"metric-unit\">wpm</span></div>\n <div><span class=\"metric-label\">accuracy</span><span class=\"metric-value\" id=\"live-accuracy\">100</span><span class=\"metric-unit\">%</span></div>\n <div class=\"time-metric\"><span class=\"metric-label\" id=\"time-label\">remaining</span><span class=\"metric-value\" id=\"live-time\">30</span><span class=\"metric-unit\" id=\"time-unit\">s</span></div>\n </div>\n <div id=\"ghost-race\" class=\"ghost-race\" hidden aria-live=\"off\"><span>you <b id=\"race-you\">0</b></span><span class=\"race-separator\">/</span><span class=\"ghost-label\">ghost <b id=\"race-them\">0</b></span><span id=\"race-delta\"></span></div>\n <div class=\"stage\" id=\"stage\">\n <span class=\"word-number\" id=\"word-number\" aria-hidden=\"true\">001</span>\n <div class=\"word-wrap\">\n <div class=\"word-pair\" id=\"word-pair\">\n <div class=\"word\" id=\"word\" aria-live=\"off\" aria-label=\"Word to type\"></div>\n <div class=\"upcoming\" id=\"upcoming\" aria-hidden=\"true\"><b id=\"next-word\"></b></div>\n </div>\n </div>\n </div>\n <div id=\"tempo-track\" class=\"tempo-track\" hidden><div class=\"tempo-rail\"><div id=\"tempo-progress\"></div></div><div class=\"tempo-caption\"><span id=\"tempo-caption\">find the beat</span><span id=\"tempo-lives\" aria-label=\"Three beats remaining\"><i></i><i></i><i></i></span></div></div>\n <div class=\"input-row\" id=\"input-row\">\n <input id=\"typing-input\" class=\"tLine truncated
const TEMPLATE = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover\">\n <meta name=\"theme-color\" content=\"#f5f3ec\">\n <!--OG-->\n <link rel=\"icon\" href=\"/favicon.svg\" type=\"image/svg+xml\">\n <link rel=\"stylesheet\" href=\"/style.css\">\n <script type=\"module\" src=\"/app.js\"></script>\n</head>\n<body>\n <div class=\"shell\">\n <header class=\"masthead\">\n <a class=\"brand\" href=\"/\" aria-label=\"typy home\">typy<span class=\"brand-dot\" aria-hidden=\"true\"></span></a>\n <span class=\"brand-note\">a little more in the moment.</span>\n <nav class=\"top-nav\" aria-label=\"Main\">\n <a href=\"/daily\" id=\"daily-link\"><span class=\"sun\" aria-hidden=\"true\">✳</span> the daily</a>\n <button class=\"icon-button\" id=\"settings-open\" aria-label=\"Typing settings\" title=\"Typing settings\">\n <svg width=\"21\" height=\"21\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" aria-hidden=\"true\"><path d=\"M4 7h16M4 17h16\"/><circle cx=\"9\" cy=\"7\" r=\"3\" fill=\"var(--paper)\"/><circle cx=\"15\" cy=\"17\" r=\"3\" fill=\"var(--paper)\"/></svg>\n </button>\n <button class=\"icon-button help-button\" id=\"help-open\" aria-label=\"How to play\">?</button>\n </nav>\n </header>\n\n <main id=\"main\">\n <section class=\"toolbar\" aria-label=\"Run options\">\n <div class=\"mode-switch\" id=\"mode-switch\" aria-label=\"Run length\">\n <button data-mode=\"30\" aria-pressed=\"true\">30s</button>\n <button data-mode=\"60\" aria-pressed=\"false\">60s</button>\n <button data-mode=\"50\" aria-pressed=\"false\">50 words</button>\n <button data-mode=\"endless\" aria-pressed=\"false\" aria-label=\"Endless\">∞</button>\n <span class=\"mode-divider\" aria-hidden=\"true\"></span>\n <button data-mode=\"tempo\" class=\"tempo-option\" aria-pressed=\"false\"><span class=\"tempo-icon\" aria-hidden=\"true\"><i></i><i></i><i></i></span>tempo</button>\n </div>\n <button class=\"source-button\" id=\"source-open\"><span id=\"source-label\">common words</span><span aria-hidden=\"true\">↗</span></button>\n </section>\n\n <section id=\"load-panel\" class=\"load-panel\" hidden aria-live=\"polite\">\n <div class=\"spinner\" id=\"loading-spinner\" aria-hidden=\"true\"></div>\n <p class=\"eyebrow\" id=\"load-kicker\">one moment</p>\n <h1 id=\"load-title\">finding the words…</h1>\n <p id=\"load-description\"></p>\n <div class=\"button-row\" id=\"load-actions\" hidden><button class=\"button primary\" id=\"load-retry\">try again</button><button class=\"button\" id=\"load-common\">use common words</button></div>\n </section>\n\n <section id=\"play-panel\" class=\"play-panel\" aria-label=\"Typing stage\">\n <div class=\"run-strip\">\n <div class=\"run-status\"><span class=\"status-dot\" id=\"status-dot\"></span><span id=\"run-status\">a fresh start</span></div>\n <div id=\"best-score\" class=\"best-score\"></div>\n </div>\n <div class=\"metrics\" aria-label=\"Live score\">\n <div><span class=\"metric-label\">pace</span><span class=\"metric-value\" id=\"live-wpm\">0</span><span class=\"metric-unit\">wpm</span></div>\n <div><span class=\"metric-label\">accuracy</span><span class=\"metric-value\" id=\"live-accuracy\">100</span><span class=\"metric-unit\">%</span></div>\n <div class=\"time-metric\"><span class=\"metric-label\" id=\"time-label\">remaining</span><span class=\"metric-value\" id=\"live-time\">30</span><span class=\"metric-unit\" id=\"time-unit\">s</span></div>\n </div>\n <div id=\"ghost-race\" class=\"ghost-race\" hidden aria-live=\"off\"><span>you <b id=\"race-you\">0</b></span><span class=\"race-separator\">/</span><span class=\"ghost-label\">ghost <b id=\"race-them\">0</b></span><span id=\"race-delta\"></span></div>\n <div class=\"stage\" id=\"stage\">\n <span class=\"word-number\" id=\"word-number\" aria-hidden=\"true\">001</span>\n <div class=\"word-wrap\">\n <div class=\"word-pair\" id=\"word-pair\">\n <div class=\"previous-context\" id=\"previous-context\" aria-hidden=\"true\" hidden><b id=\"previous-word\"></b></div>\n <div class=\"word\" id=\"word\" aria-live=\"off\" aria-label=\"Word to type\"></div>\n <div class=\"upcoming\" id=\"upcoming\" aria-hidden=\"true\"><b id=\"next-word\"></b></div>\n </div>\n </div>\n </div>\n <div id=\"tempo-track\" class=\"tempo-track\" hidden><div class=\"tempo-rail\"><div id=\"tempo-progress\"></div></div><div class=\"tempo-caption\"><span id=\"tempo-caption\">find the beat</span><span id=\"tempo-lives\" aria-label=\"Three beats remaining\"><i></iLine truncated
const escape = (s) => String(s ?? "").replace(/[&<>"\x27]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","\x27":"&#39;"}[c]));
export function renderPage(meta = {}) {
const title = escape(meta.title || "typy — one word at a time");