Files
rangerhq-reader/ranger-reader.html
T
ranger a5819f0c72 fix: high-WPM chunk modes were clamped by 1500ms minimum-display floor (v1.1.2)
After v1.1.1 raised the WPM cap to 3000 for Sentence and Paragraph modes,
David noticed pushing the slider from 1500 to 3000 didn't actually speed
up reading. The chunks still sat on screen for the same amount of time.

Root cause: the v1.1.0 minimum-display floor was 1500ms per chunk. At
3000 WPM, any sentence under ~60 words mathematically wants to display
for under 1500ms — but the floor clamped them all to 1500ms, so the
calculated speed-up never reached the user.

Lowered floors:
- Sentence mode: 1500ms → 400ms
- Paragraph mode: 1500ms → 800ms

Max cap stays at 12s for very long paragraphs at very low WPM.

At 3000 WPM with the new floors, a 20-word sentence now displays for
500ms (was 1500ms, 3x faster) and the slider behaves the way users
expect — higher WPM = faster reading, all the way to 3000.

The reader does NOT iterate word-by-word inside a chunk; the whole
sentence/paragraph is rendered in one DOM update. Word count is used
only to estimate the auto-advance interval, not as a per-word loop.
2026-05-27 02:43:36 +01:00

645 lines
21 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>◈ Ranger Reader — RSVP for thesis papers</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #0e1a23;
--fg: #d6e1ea;
--accent: #2271b1;
--accent-hover: #135e96;
--orp: #ff5252;
--muted: #6b7c8a;
--panel: #1a2a36;
--border: #2a3a48;
--done: #00a32a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
.header {
padding: 14px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
flex-wrap: wrap;
}
.header h1 {
font-size: 16px;
font-weight: 600;
color: var(--accent);
letter-spacing: 0.5px;
}
.header .meta { color: var(--muted); flex: 1; }
.header .version { color: var(--muted); font-size: 11px; }
.mode-toggle {
display: inline-flex;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
}
.mode-toggle button {
background: transparent;
color: var(--muted);
border: none;
padding: 6px 12px;
font-family: inherit;
font-size: 12px;
cursor: pointer;
border-right: 1px solid var(--border);
transition: background 0.15s, color 0.15s;
}
.mode-toggle button:last-child { border-right: none; }
.mode-toggle button:hover { color: var(--fg); }
.mode-toggle button.active {
background: var(--accent);
color: #fff;
}
.stage {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 60px 40px;
min-height: 320px;
position: relative;
}
.stage::before, .stage::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 80px;
height: 1px;
background: var(--border);
opacity: 0;
transition: opacity 0.2s;
}
.stage.mode-word::before { top: 35%; opacity: 1; }
.stage.mode-word::after { bottom: 35%; opacity: 1; }
.word-display {
font-family: ui-monospace, "SF Mono", Consolas, Menlo, monospace;
font-size: 56px;
font-weight: 500;
letter-spacing: 0.5px;
white-space: pre;
text-align: center;
line-height: 1.2;
min-height: 70px;
user-select: none;
}
.stage.mode-sentence .word-display {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-size: 24px;
font-weight: 400;
letter-spacing: normal;
white-space: normal;
line-height: 1.55;
max-width: 760px;
min-height: 110px;
}
.stage.mode-paragraph .word-display {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-size: 17px;
font-weight: 400;
letter-spacing: normal;
white-space: pre-wrap;
line-height: 1.65;
max-width: 880px;
min-height: 200px;
text-align: left;
}
.orp { color: var(--orp); }
.controls {
padding: 14px 24px;
border-top: 1px solid var(--border);
background: var(--panel);
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
button.ctrl {
background: var(--accent);
color: #fff;
border: none;
padding: 8px 14px;
border-radius: 4px;
font-family: inherit;
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
}
button.ctrl:hover { background: var(--accent-hover); }
button.ctrl.secondary {
background: transparent;
border: 1px solid var(--border);
color: var(--fg);
}
button.ctrl.secondary:hover {
background: rgba(34, 113, 177, 0.12);
border-color: var(--accent);
}
.slider-group {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
}
.slider-group strong { color: var(--fg); min-width: 36px; text-align: right; }
input[type=range] {
width: 140px;
accent-color: var(--accent);
}
.input-area {
padding: 14px 24px;
border-top: 1px solid var(--border);
background: var(--panel);
}
textarea {
width: 100%;
height: 100px;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 10px;
font-family: inherit;
font-size: 13px;
resize: vertical;
}
textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(34, 113, 177, 0.25);
}
.progress {
flex: 1;
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
min-width: 100px;
}
.progress-bar {
height: 100%;
background: var(--accent);
width: 0;
transition: width 0.2s;
}
.hint {
font-size: 11px;
color: var(--muted);
margin-top: 8px;
line-height: 1.6;
}
.kbd {
background: var(--bg);
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 3px;
padding: 1px 6px;
font-family: ui-monospace, monospace;
font-size: 10px;
color: var(--fg);
}
.done-state .word-display { color: var(--done); }
.dropzone-overlay {
position: fixed;
inset: 0;
background: rgba(34, 113, 177, 0.20);
border: 3px dashed var(--accent);
display: none;
align-items: center;
justify-content: center;
font-size: 24px;
color: var(--fg);
z-index: 1000;
pointer-events: none;
}
.dropzone-overlay.active { display: flex; }
</style>
</head>
<body>
<div class="header">
<h1>◈ Ranger Reader</h1>
<div class="mode-toggle" id="mode-toggle">
<button data-mode="word" class="active" title="Word-by-word RSVP (classic)">Word</button>
<button data-mode="sentence" title="One sentence at a time">Sentence</button>
<button data-mode="paragraph" title="One paragraph at a time">Paragraph</button>
</div>
<span class="meta" id="meta">Paste text below, drag a .txt file in, then press Play</span>
<span class="version">v1.1.2</span>
</div>
<div class="stage mode-word" id="stage">
<div class="word-display" id="word">ready</div>
</div>
<div class="controls">
<button class="ctrl" id="btn-play">▶ Play</button>
<button class="ctrl secondary" id="btn-back">◀ Back</button>
<button class="ctrl secondary" id="btn-forward">Forward ▶</button>
<button class="ctrl secondary" id="btn-reset">⟲ Reset</button>
<div class="slider-group">
<span>WPM</span>
<input type="range" id="wpm" min="200" max="1500" value="500" step="50">
<strong id="wpm-val">500</strong>
</div>
<div class="progress"><div class="progress-bar" id="progress"></div></div>
</div>
<div class="input-area">
<textarea id="text" placeholder="Paste the contents of one of your .txt files here, or drag a .txt file anywhere on the page..."></textarea>
<p class="hint">
<strong style="color:var(--fg)">Modes</strong>
<span class="kbd">1</span> Word (RSVP, fast),
<span class="kbd">2</span> Sentence (chunked, easier comprehension),
<span class="kbd">3</span> Paragraph (skim mode).
&nbsp;·&nbsp;
<span class="kbd">Space</span> play/pause ·
<span class="kbd"></span> <span class="kbd"></span> skip ·
<span class="kbd">+</span> <span class="kbd"></span> WPM ·
<span class="kbd">R</span> reset.
Text + WPM + mode + position auto-saved. Drag any <code>.txt</code> file onto the page.
</p>
</div>
<div class="dropzone-overlay" id="dropzone">Drop .txt file here</div>
<script>
(() => {
const $ = id => document.getElementById(id);
const display = $('word');
const meta = $('meta');
const wpmSlider = $('wpm');
const wpmVal = $('wpm-val');
const progress = $('progress');
const textarea = $('text');
const stage = $('stage');
const dropzone = $('dropzone');
const btnPlay = $('btn-play');
const btnBack = $('btn-back');
const btnFwd = $('btn-forward');
const btnReset = $('btn-reset');
const modeToggle = $('mode-toggle');
let mode = 'word'; // 'word' | 'sentence' | 'paragraph'
let chunks = []; // array of strings — one word, one sentence, or one paragraph each
let pos = 0;
let timer = null;
let playing = false;
const STORAGE_KEY = 'ranger-reader-v1';
// ─── Persistence ───────────────────────────────────────────────
function load() {
try {
const s = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
if (s.text) textarea.value = s.text;
if (s.wpm) { wpmSlider.value = s.wpm; wpmVal.textContent = s.wpm; }
if (s.mode && ['word','sentence','paragraph'].includes(s.mode)) mode = s.mode;
if (typeof s.pos === 'number') pos = s.pos;
applyMode();
updateChunks();
if (pos > 0 && pos < chunks.length) renderChunk(chunks[pos]);
} catch (e) { /* ignore */ }
}
function save() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
text: textarea.value,
wpm: wpmSlider.value,
mode: mode,
pos: pos
}));
} catch (e) { /* ignore quota errors */ }
}
// ─── Text → chunks (mode-dependent) ────────────────────────────
function splitWords(text) {
return text.split(/\s+/).filter(w => w.length > 0);
}
function splitSentences(text) {
// Guard common abbreviations + decimal numbers before splitting on .!?
let t = text
.replace(/\b(Mr|Mrs|Ms|Dr|Prof|Sr|Jr|St|vs|cf|etc|Vol|No|Fig|Eq|Ref|e\.g|i\.e|et al)\./g, '$1<P>')
.replace(/(\d)\.(\d)/g, '$1<P>$2');
// Split where punctuation is followed by whitespace + capital letter or whitespace + end-of-string
const parts = t.split(/(?<=[.!?])\s+(?=[A-Z“"\(])/);
return parts
.map(s => s.replace(/<P>/g, '.').trim())
.filter(s => s.length > 0);
}
function splitParagraphs(text) {
// Paragraphs separated by blank line OR repeated newlines
return text.split(/\n\s*\n/)
.map(p => p.replace(/\s+/g, ' ').trim())
.filter(p => p.length > 0);
}
function updateChunks() {
const text = textarea.value;
if (mode === 'word') chunks = splitWords(text);
else if (mode === 'sentence') chunks = splitSentences(text);
else chunks = splitParagraphs(text);
if (pos >= chunks.length) pos = Math.max(0, chunks.length - 1);
updateMeta();
updateProgress();
}
// ─── Meta + progress ───────────────────────────────────────────
function chunkUnitLabel() {
return mode === 'word' ? 'words' : mode === 'sentence' ? 'sentences' : 'paragraphs';
}
function totalWordsFromCurrent() {
// Approximate words remaining for accurate time estimate, in any mode
let n = 0;
for (let i = pos; i < chunks.length; i++) n += splitWords(chunks[i]).length;
return n;
}
function updateMeta() {
if (!chunks.length) {
meta.textContent = 'Paste text below, drag a .txt file in, then press Play';
return;
}
const total = chunks.length;
const wpm = parseInt(wpmSlider.value);
const wordsRemaining = totalWordsFromCurrent();
const minsRemaining = Math.ceil(wordsRemaining / wpm);
const unit = chunkUnitLabel();
meta.textContent = `${pos + 1} / ${total} ${unit} · ~${minsRemaining} min remaining at ${wpm} WPM`;
}
function updateProgress() {
progress.style.width = chunks.length ? `${(pos / chunks.length) * 100}%` : '0%';
}
// ─── Rendering ─────────────────────────────────────────────────
// ORP: Optimal Recognition Point. Pinning the focal char keeps eyes still.
function orpIndex(word) {
const n = word.length;
if (n <= 1) return 0;
if (n <= 4) return 1;
if (n <= 9) return 2;
if (n <= 13) return 3;
return 4;
}
function esc(s) {
return (s || '').replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
}
function renderChunk(chunk) {
if (!chunk) { display.innerHTML = ''; return; }
if (mode === 'word') {
// Single word with ORP coloring
const i = Math.min(orpIndex(chunk), chunk.length - 1);
const before = chunk.slice(0, i);
const orp = chunk[i] || '';
const after = chunk.slice(i + 1);
display.innerHTML = `${esc(before)}<span class="orp">${esc(orp)}</span>${esc(after)}`;
} else {
// Sentence or paragraph — render as plain text (no ORP)
display.textContent = chunk;
}
}
// ─── Timing (mode-aware) ───────────────────────────────────────
// Pause longer at punctuation — gives the brain a beat to settle (word mode only)
function pauseMult(word) {
if (!word) return 1;
const last = word[word.length - 1];
if ('.!?'.includes(last)) return 2.5;
if (',;:'.includes(last)) return 1.7;
if (')]"”'.includes(last)) return 1.3;
return 1;
}
function chunkInterval(chunk) {
const wpm = parseInt(wpmSlider.value);
if (mode === 'word') {
const base = 60000 / wpm;
return base * pauseMult(chunk);
}
// Sentence / paragraph: in these modes the whole chunk is displayed
// in one DOM update — no per-word iteration. We only need to decide
// how long to leave the chunk visible before advancing.
//
// Time = (words in chunk) / WPM × 60 s × comprehension multiplier.
// At low WPM the formula dominates. At high WPM the floor kicks in.
// v1.1.2: lowered floors significantly — the previous 1.5 s sentence
// floor was clamping any value above ~1500 WPM, making 3000 WPM
// feel identical to 1500 WPM. Lower floors let high-WPM skim mode
// actually skim. The maximum cap stays at 12 s for very long
// paragraphs at very low WPM.
const wordCount = splitWords(chunk).length;
const baseMs = (wordCount / wpm) * 60000;
const multiplier = mode === 'sentence' ? 1.25 : 1.40;
const floor = mode === 'sentence' ? 400 : 800;
return Math.max(floor, Math.min(12000, baseMs * multiplier));
}
function tick() {
if (pos >= chunks.length) {
stop();
display.textContent = '— done —';
stage.classList.add('done-state');
return;
}
const chunk = chunks[pos];
renderChunk(chunk);
updateMeta();
updateProgress();
save();
pos++;
timer = setTimeout(tick, chunkInterval(chunk));
}
function play() {
stage.classList.remove('done-state');
updateChunks();
if (!chunks.length) return;
if (pos >= chunks.length) pos = 0;
playing = true;
btnPlay.textContent = '⏸ Pause';
tick();
}
function stop() {
playing = false;
if (timer) { clearTimeout(timer); timer = null; }
btnPlay.textContent = '▶ Play';
}
function toggle() { playing ? stop() : play(); }
function skip(n) {
stop();
updateChunks();
pos = Math.max(0, Math.min(chunks.length - 1, pos + n));
if (chunks[pos]) renderChunk(chunks[pos]);
updateMeta();
updateProgress();
save();
}
// Skip distance: 1 in chunk modes, 10 in word mode
function skipDelta() { return mode === 'word' ? 10 : 1; }
function reset() {
stop();
stage.classList.remove('done-state');
pos = 0;
updateChunks();
if (chunks[0]) renderChunk(chunks[0]);
else display.textContent = 'ready';
updateMeta();
updateProgress();
save();
}
// ─── Mode switching ────────────────────────────────────────────
// Mode-aware WPM ceiling. Word mode caps at 1500 — beyond that, the
// brain can't register single words faster than ~25ms per word. But
// Sentence and Paragraph modes display whole chunks at once; "WPM"
// there controls auto-advance timing, so values up to 3000 are
// genuinely useful for skim-reading (a 20-word sentence at 3000 WPM
// still gets ~400ms of display, which is comprehensible).
const WPM_MAX = { word: 1500, sentence: 3000, paragraph: 3000 };
function applyMode() {
stage.className = `stage mode-${mode}`;
[...modeToggle.querySelectorAll('button')].forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
// Adjust the WPM ceiling for the current mode + clamp current value if it exceeds
const newMax = WPM_MAX[mode] || 1500;
wpmSlider.max = newMax;
if (parseInt(wpmSlider.value) > newMax) {
wpmSlider.value = newMax;
wpmVal.textContent = newMax;
}
}
function setMode(newMode) {
if (newMode === mode) return;
stop();
mode = newMode;
pos = 0;
applyMode();
updateChunks();
if (chunks[0]) renderChunk(chunks[0]);
else display.textContent = 'ready';
updateMeta();
updateProgress();
save();
}
// ─── Wire up controls ─────────────────────────────────────────
modeToggle.addEventListener('click', e => {
const btn = e.target.closest('button[data-mode]');
if (btn) setMode(btn.dataset.mode);
});
btnPlay.addEventListener('click', toggle);
btnBack.addEventListener('click', () => skip(-skipDelta()));
btnFwd.addEventListener('click', () => skip(skipDelta()));
btnReset.addEventListener('click', reset);
wpmSlider.addEventListener('input', () => {
wpmVal.textContent = wpmSlider.value;
updateMeta();
save();
});
textarea.addEventListener('input', () => {
pos = 0;
stage.classList.remove('done-state');
updateChunks();
save();
});
// Keyboard shortcuts (ignore when focused in textarea)
document.addEventListener('keydown', (e) => {
if (e.target === textarea) return;
const k = e.key;
if (k === ' ') { e.preventDefault(); toggle(); }
else if (k === 'ArrowLeft') { e.preventDefault(); skip(-skipDelta()); }
else if (k === 'ArrowRight') { e.preventDefault(); skip(skipDelta()); }
else if (k === '1') { setMode('word'); }
else if (k === '2') { setMode('sentence'); }
else if (k === '3') { setMode('paragraph'); }
else if (k === '+' || k === '=') {
wpmSlider.value = Math.min(parseInt(wpmSlider.max), parseInt(wpmSlider.value) + 50);
wpmVal.textContent = wpmSlider.value;
updateMeta();
save();
}
else if (k === '-' || k === '_') {
wpmSlider.value = Math.max(200, parseInt(wpmSlider.value) - 50);
wpmVal.textContent = wpmSlider.value;
updateMeta();
save();
}
else if (k === 'r' || k === 'R') { reset(); }
});
// ─── Drag-and-drop .txt files ─────────────────────────────────
let dragCounter = 0;
document.addEventListener('dragenter', e => {
e.preventDefault();
dragCounter++;
dropzone.classList.add('active');
});
document.addEventListener('dragleave', e => {
dragCounter--;
if (dragCounter <= 0) dropzone.classList.remove('active');
});
document.addEventListener('dragover', e => e.preventDefault());
document.addEventListener('drop', e => {
e.preventDefault();
dragCounter = 0;
dropzone.classList.remove('active');
const file = e.dataTransfer.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
textarea.value = ev.target.result;
pos = 0;
stage.classList.remove('done-state');
updateChunks();
save();
};
reader.readAsText(file);
});
// ─── Boot ─────────────────────────────────────────────────────
load();
})();
</script>
</body>
</html>