Merge pull request 'Put typy's next word beside the target at matching size' (#257) from typy/inline-preview-254 into main
Merge the inline same-size next-word preview
This commit was merged in pull request #257.
This commit is contained in:
commit
1810deab1d
5 files changed
+34
-11
No files matched your search
+26
-5
@@ -86,6 +86,7 @@ function updateOptions() {
|
||||
});
|
||||
$('source-label').textContent = sourceName();
|
||||
$('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;
|
||||
$('advance-setting').value = effectiveAdvance;
|
||||
$('advance-setting').disabled = Boolean(ghost);
|
||||
@@ -177,12 +178,28 @@ function drawWord(word, buffer, index, nextWord, flags = null) {
|
||||
// not change glyph widths and must not force a layout read on every key.
|
||||
if (wordLayoutDirty) {
|
||||
node.style.fontSize = '';
|
||||
const available = Math.max(1, $('stage').clientWidth - 28);
|
||||
const stageWidth = $('stage').clientWidth;
|
||||
const width = node.scrollWidth;
|
||||
if (width > available) {
|
||||
const natural = parseFloat(getComputedStyle(node).fontSize);
|
||||
node.style.fontSize = Math.max(8, natural * available / width * .96) + 'px';
|
||||
const style = getComputedStyle(node);
|
||||
const natural = parseFloat(style.fontSize);
|
||||
const tracking = parseFloat(style.letterSpacing) / natural;
|
||||
// Keep the active target anchored independently of the preview's length.
|
||||
// Extremely long targets get the full stage; previews use remaining room.
|
||||
let anchor = settings.preview ? .35 : .5;
|
||||
let available = Math.max(1, stageWidth * anchor * 2 - 28);
|
||||
if (width > available && natural * available / width * .96 < 8) {
|
||||
anchor = .5;
|
||||
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';
|
||||
wordLayoutDirty = false;
|
||||
}
|
||||
lastWordKey = key;
|
||||
@@ -568,7 +585,11 @@ $('settings-done').addEventListener('click', () => {
|
||||
if (!ghost) settings.advance = $('advance-setting').value;
|
||||
settings.preview = $('preview-setting').checked; writeLocal('settings',settings);
|
||||
$('settings-dialog').close();
|
||||
if (changed && view === 'play') beginRun({focus:true}); else updateOptions();
|
||||
if (changed && view === 'play') beginRun({focus:true});
|
||||
else {
|
||||
updateOptions();
|
||||
if (changed && view === 'replay') renderReplay(replay.position);
|
||||
}
|
||||
});
|
||||
$('source-open').addEventListener('click', () => $('source-dialog').showModal());
|
||||
['topic','paste','common'].forEach(tab => $('tab-' + tab).addEventListener('click', () => {
|
||||
|
||||
@@ -58,9 +58,11 @@
|
||||
<div class="stage" id="stage">
|
||||
<span class="word-number" id="word-number" aria-hidden="true">001</span>
|
||||
<div class="word-wrap">
|
||||
<div class="word" id="word" aria-live="off" aria-label="Word to type"></div>
|
||||
<div class="word-pair" id="word-pair">
|
||||
<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>
|
||||
</div>
|
||||
<div class="upcoming" id="upcoming" aria-hidden="true"><span>up next</span><b id="next-word"></b></div>
|
||||
</div>
|
||||
<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>
|
||||
<div class="input-row" id="input-row">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
:root{--paper:#f5f3ec;--ink:#263d33;--muted:#7b8177;--faint:#b8beb2;--line:#dcded3;--orange:#e36c49;--orange-soft:#f5e0d5;--green-soft:#e7ebdd;--sans:Arial,Helvetica,sans-serif;--mono:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace}
|
||||
*{box-sizing:border-box}html{min-height:100%;background:var(--paper)}body{margin:0;color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased}button,input,select,textarea{font:inherit;color:inherit}button,a,input,select,textarea{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:default;opacity:.4}button,a,input,select,textarea{outline-offset:5px}button:focus-visible,a:focus-visible{outline:2px solid var(--orange)}button{border:0;background:none}a{color:inherit;text-decoration:none}button,a{touch-action:manipulation}input,textarea,select{border:1px solid var(--line);border-radius:8px;background:#fffdf6;padding:14px;outline:none}input:focus,textarea:focus,select:focus{border-color:var(--ink);box-shadow:0 0 0 2px #263d3312}input::placeholder,textarea::placeholder{color:#969b91}[hidden]{display:none!important}::selection{background:#e8b9a4;color:var(--ink)}.shell{max-width:1280px;margin:auto;padding:0 64px;min-height:100svh;display:flex;flex-direction:column}.masthead{height:112px;display:flex;align-items:center;border-bottom:1px solid var(--line);gap:26px;flex-shrink:0}.brand{font-size:48px;font-weight:800;letter-spacing:-4px;line-height:1;display:flex;align-items:baseline;gap:6px}.brand-dot{width:9px;height:9px;border-radius:50%;background:var(--orange)}.brand-note{font-family:var(--mono);font-size:11px;color:var(--muted);padding-top:11px;letter-spacing:-.2px}.top-nav{margin-left:auto;display:flex;align-items:center;gap:24px;font-size:13px}.top-nav a{display:flex;align-items:center;gap:8px}.top-nav a:hover,.text-button:hover{color:var(--orange)}.sun{color:var(--orange);font-size:23px}.icon-button{width:32px;height:36px;display:grid;place-items:center;padding:0}.help-button{font-family:var(--mono);font-size:17px;width:25px;height:25px;border:1px solid var(--faint);border-radius:50%}main{flex:1}.toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:28px 0 20px}.mode-switch{display:flex;align-items:center;gap:4px;border:1px solid var(--line);border-radius:99px;padding:5px}.mode-switch button{font-family:var(--mono);font-size:12px;line-height:30px;padding:0 16px;border-radius:99px;white-space:nowrap;color:var(--muted);transition:color .16s,background .16s}.mode-switch button[aria-pressed="true"]{background:var(--ink);color:var(--paper)}.mode-switch button:not(:disabled):hover{color:var(--ink);background:var(--green-soft)}.mode-switch button[aria-pressed="true"]:hover{background:#365446;color:var(--paper)}.mode-switch button[data-mode="endless"]{font-size:20px}.mode-divider{height:18px;border-left:1px solid var(--line);margin:0 5px}.tempo-option{display:flex;align-items:center;gap:8px}.tempo-icon{display:flex;align-items:center;gap:2px;height:14px}.tempo-icon i{width:2px;height:6px;background:currentColor}.tempo-icon i:nth-child(2){height:14px}.tempo-icon i:nth-child(3){height:10px}.source-button{font-family:var(--mono);font-size:12px;display:flex;align-items:center;gap:15px;max-width:40%;padding:12px 0 12px 12px}.source-button span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.source-button span:last-child{color:var(--orange);font-size:20px}.play-panel{padding-top:21px}.run-strip{display:flex;align-items:center;justify-content:space-between;gap:16px;font-family:var(--mono);font-size:10px;color:var(--muted);letter-spacing:.3px}.run-status{display:flex;gap:9px;align-items:center}.status-dot{width:6px;height:6px;border-radius:50%;background:var(--faint)}.status-dot.active{background:var(--orange);box-shadow:0 0 0 4px #e36c4914}.best-score{font-size:10px}.metrics{display:flex;justify-content:center;gap:62px;margin:44px 0 4px;user-select:none}.metrics>div{display:flex;align-items:baseline;gap:5px;min-width:102px;position:relative}.metric-label{font-family:var(--mono);font-size:9px;position:absolute;top:-19px;left:0;color:var(--muted)}.metric-value{font-family:var(--mono);font-weight:400;font-size:27px;letter-spacing:-1.5px;font-variant-numeric:tabular-nums}.metric-unit{font-size:10px;color:var(--muted);font-family:var(--mono)}.time-metric .metric-value{color:var(--orange)}.stage{height:310px;position:relative;cursor:text;display:flex;align-items:center;justify-content:center;isolation:isolate;overflow:clip}.word-number{position:absolute;left:0;top:48%;font-family:var(--mono);font-size:10px;color:var(--faint);writing-mode:vertical-rl;transform:rotate(180deg);letter-spacing:2px}.word-wrap{position:relative;width:100%;height:180px;display:grid;place-items:center}.word{font-family:var(--mono);font-size:clamp(50px,8.6vw,114px);font-weight:500;line-height:1.3;letter-spacing:-.075em;white-space:nowrap;color:var(--faint);max-width:98%;text-align:center;font-variant-ligatures:none}.word.long{font-size:clamp(25px,5.2vw,68px);letter-spacing:-.06em}.word.very-long{font-size:clamp(19px,3.5vw,46px)}.word{color:#7c8875}.word .correct{color:var(--ink)}.word .incorrect{color:#b24d36;background:var(--orange-soft);border-radiusLine truncated
|
||||
*{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:35%;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%}.upcoming{top:calc(50% + 55px);gap:10px}.upcoming b{font-size:23px}.upcoming span{font-size:7px}.input-row{padding-top:22px;min-height:135px}.typing-input{position:static;opacity:1;width:100%;height:49px;border:1px solid var(--line);border-radius:8px;padding:12px 14px;pointer-events:auto;overflow:auto;font-size:16px;text-align:center;background:#eeeee5}.typing-input:focus{background:#fdfcf7}.typing-input::placeholder{font-size:11px;color:var(--muted);font-family:var(--mono)}.run-hint{font-size:9px;gap:8px;margin-top:14px}.run-hint kbd{font-size:8px}.footer{height:auto;min-height:68px;font-size:8px;align-items:center;padding:17px 0;margin-top:18px;gap:10px}.footer>div{gap:10px}.footer a{font-size:8px}#restart-tip{display:none}.footer>.text-button{font-size:8px}.tempo-track{padding-top:0}.tempo-caption{font-size:9px}.ghost-race{margin-top:22px;margin-bottom:-16px;font-size:10px;gap:9px}.result-panel{margin:16px auto 25px}.result-panel h1{font-size:30px;margin:10px 0 20px}.result-grid{gap:25px;align-items:center;margin-bottom:25px}.result-main>span:first-child{font-size:75px;letter-spacing:-6px}.result-main>span:last-child{font-size:10px}.result-detail{padding-left:24px;gap:16px}.result-detail>div{flex-direction:column;gap:5px;align-items:flex-start}.result-detail dt{font-size:8px}.result-detail dd{font-size:13px}.chart-label{font-size:8px}.rhythm-chart svg{height:65px}.slow-words{font-size:9px;gap:15px;align-items:flex-start;padding-bottom:18px}.slow-words>div{gap:10px 15px}.slow-word small{font-size:8px}.result-actions{gap:8px;margin-top:20px}.result-actions .button{flex:1;font-size:10px;gap:8px;padding:10px 12px;min-height:44px}.result-actions>.text-button{margin-left:0;flex-basis:100%;text-align:left;margin-top:2px}.eyebrow{font-size:9px}.new-best{font-size:8px;padding:5px 7px}.result-note{font-size:10px}.replay-controls{gap:10px;margin-top:20px}.replay-time{font-size:9px}.replay-controls>.text-button{flex-basis:100%;text-align:center}.dialog{padding:30px 24px}.dialog h2{font-size:26px;margin-bottom:22px}.source-tabs{gap:20px}.source-tabs button{font-size:10px}.load-panel{min-height:430px;padding:25px 10px}.load-panel h1{font-size:27px}.load-panel>p:last-of-type{font-size:12px}.load-panel .button{font-size:11px;padding:10px 14px}.toast{bottom:16px;font-size:11px}.button-row{flex-wrap:wrap;justify-content:center}}
|
||||
@media(max-width: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}}
|
||||
@media(max-width:370px){.shell{padding:0 17px}.mode-switch button{padding:0 10px}.tempo-option{gap:5px}.top-nav{gap:12px}.metrics{gap:22px}.stage{height:205px}.source-tabs{gap:16px}.footer a{display:none}}
|
||||
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||||
+1
-1
@@ -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' : '<kbd>space</kbd> next word';\n $('upcoming').hidden = !settings.preview;\n $('advance-setting').value = effectiveAdvance;\n $('advance-setting').disabled = Boolean(ghost);\n $('preview-setting').checked = settings.preview;\n $('footer-board').hidden = source.kind !== 'daily';\n $('daily-link').setAttribute('aria-current', source.kind === 'daily' ? 'page' : 'false');\n $('best-score').textContent = bests[localBestKey()] ? 'device best ' + pretty(bests[localBestKey()]) + ' wpm' : '';\n}\n\nfunction focusTyping() {\n if (view !== 'play' || document.querySelector('dialog[open]')) return;\n $('typing-input').focus({preventScroll:true});\n}\n\nfunction newCommonPassage() {\n const words = generateWords(300);\n words[0] = 'breathe';\n return words;\n}\n\nfunction beginRun({freshWords = false, focus = false} = {}) {\n transitioning = false;\n if (freshWords && source.kind === 'common' && !ghost) passage = newCommonLine truncated
|
||||
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' : '<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
|
||||
+1
-1
@@ -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\" id=\"word\" aria-live=\"off\" aria-label=\"Word to type\"></div>\n </div>\n <div class=\"upcoming\" id=\"upcoming\" aria-hidden=\"true\"><span>up next</span><b id=\"next-word\"></b></div>\n </div>\n <div id=\"tempo-track\" class=\"tempo-track\" hidden><div class=\"tempo-rail\"><div id=\"tempo-progress\"></div></div><div class=\"tempo-caption\"><span id=\"tempo-caption\">find the beat</span><span id=\"tempo-lives\" aria-label=\"Three beats remaining\"><i></i><i></i><i></i></span></div></div>\n <div class=\"input-row\" id=\"input-row\">\n <input id=\"typing-input\" class=\"typing-input\" type=\"text\" inputmode=\"text\" enterkeyhint=\"Line truncated
|
||||
const 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 escape = (s) => String(s ?? "").replace(/[&<>"\x27]/g, c => ({"&":"&","<":"<",">":">",'"':""","\x27":"'"}[c]));
|
||||
export function renderPage(meta = {}) {
|
||||
const title = escape(meta.title || "typy — one word at a time");
|
||||
|
||||
Reference in new issue
Block a user