Files
rangerhq-reader/ranger-reader.html
ranger ab8d007b72 feat: ORP anchor on the middle word in Sentence mode (v1.1.3)
David proposed: "for sentence we can add in the red letter to keep
the eye in the middle of the sentence block."

Sentence mode now picks the middle word of each sentence (by word
index) and applies the same ORP-character formula used in Word mode.
The single red letter gives the eye a fixed anchor; the rest of
the sentence is absorbed peripherally instead of saccading.

This matters most at high WPM (post v1.1.1/v1.1.2 — Sentence mode
now goes up to 3000 WPM with a 400ms minimum-display floor). Without
an anchor, the eye scans from the left as if reading normally and
runs out of time. With the anchor, the eye fixates immediately and
the whole sentence registers in one peripheral-vision read.

Paragraph mode still renders plain text — a single ORP on a
multi-line block doesn't give a useful fixation point. Per-sentence
ORP within paragraphs could come in a future release if needed.
2026-05-27 02:50:01 +01:00

670 lines
22 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.3</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 on the focal character
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 if (mode === 'sentence') {
// v1.1.3: anchor the eye to ONE red letter near the middle of the
// sentence. Find the middle word (by index), then apply the same
// ORP-character formula to that word. Other tokens are preserved
// verbatim including original spacing.
const tokens = chunk.split(/(\s+)/); // keep whitespace tokens for layout
const wordTokenIndices = tokens
.map((t, i) => (/^\s+$/.test(t) || t.length === 0) ? -1 : i)
.filter(i => i !== -1);
const anchorTokenIdx = wordTokenIndices.length
? wordTokenIndices[Math.floor(wordTokenIndices.length / 2)]
: -1;
let html = '';
tokens.forEach((tok, i) => {
if (i === anchorTokenIdx && tok.length > 0) {
const j = Math.min(orpIndex(tok), tok.length - 1);
html += `${esc(tok.slice(0, j))}<span class="orp">${esc(tok[j])}</span>${esc(tok.slice(j + 1))}`;
} else {
html += esc(tok);
}
});
display.innerHTML = html;
} else {
// Paragraph mode — plain text. (ORP on a multi-line block doesn't
// give the eye a single useful anchor; the eye still saccades
// across lines. If demand emerges, per-sentence ORP within the
// paragraph could be added in a future version.)
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>