Files
rangerhq-reader/ranger-reader.html
T
ranger 669aabf5f2 feat: mode-aware WPM ceiling — Sentence/Paragraph now go up to 3000 WPM (v1.1.1)
The WPM slider's maximum value now adapts to the active reading mode:
- Word mode: 1500 WPM ceiling (human single-word recognition limit)
- Sentence / Paragraph modes: 3000 WPM ceiling

Rationale: in chunk modes, "WPM" controls auto-advance timing across
larger display units. A 20-word sentence at 3000 WPM still gets ~400 ms
of display time — well within visual-recognition comfort and suitable
for skim-pass reading of already-familiar material.

Switching back to Word mode auto-clamps the current value down to 1500
to prevent accidentally-illegible word-mode playback.

The `+` keyboard shortcut now respects the mode-specific ceiling instead
of being hardcoded to 1500.
2026-05-27 02:40:06 +01:00

635 lines
20 KiB
HTML
Raw 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.1</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: time = words / WPM, with a comprehension multiplier
const wordCount = splitWords(chunk).length;
const baseMs = (wordCount / wpm) * 60000;
const multiplier = mode === 'sentence' ? 1.25 : 1.40; // brain needs more time on chunks
// Sensible bounds: 1.5s min, 12s max
return Math.max(1500, 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>