import { useState, useEffect, useMemo, useRef } from "react"; /* ============================================================ STYLES ============================================================ */ const STYLE = ` @import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,400;1,9..144,500&family=JetBrains+Mono:wght@400;500;600&display=swap'); :root { --bg: #f3ecdd; --bg-2: #e8dec6; --paper: #faf4e6; --paper-2: #f5eed9; --ink: #1c1611; --ink-soft: rgba(28, 22, 17, 0.30); --ink-mute: #7a6c54; --ink-faint: #a89980; --accent: #8b2418; --accent-soft: #b85447; --good: #4d6b39; --good-soft: #7d9a5e; --amber: #b07a2b; --amber-soft: #d9a86b; --rule: #c9bca0; --rule-soft: #ddd2b8; --hi: #efe3c4; --hi-amber: rgba(176, 122, 43, 0.18); --hi-good: rgba(125, 154, 94, 0.18); --hi-bad: rgba(184, 84, 71, 0.16); } .tl-root { font-family: 'Fraunces', Georgia, serif; background: var(--bg); color: var(--ink); min-height: 100vh; font-feature-settings: "ss01" on, "onum" on; background-image: radial-gradient(ellipse 80% 50% at 15% 0%, rgba(139, 36, 24, 0.04), transparent 60%), radial-gradient(ellipse 80% 50% at 85% 100%, rgba(176, 122, 43, 0.05), transparent 60%); } .tl-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-variant-ligatures: none; } .tl-tnum { font-variant-numeric: tabular-nums; } .tl-display { font-family: 'Fraunces', Georgia, serif; font-variation-settings: "opsz" 144; letter-spacing: -0.025em; font-weight: 500; } .tl-italic { font-style: italic; font-weight: 400; } .tl-label { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--ink-mute); font-weight: 500; } .tl-paper { background: var(--paper); border: 1px solid var(--rule); } .tl-paper-2 { background: var(--paper-2); } .tl-rule { height: 1px; background: var(--rule); border: none; } .tl-rule-soft { background: var(--rule-soft); } .tl-input { font-family: 'JetBrains Mono', monospace; font-size: 14px; background: var(--paper); border: 1px solid var(--rule); padding: 8px 12px; color: var(--ink); outline: none; width: 100%; box-sizing: border-box; } .tl-input:focus { border-color: var(--ink-mute); } .tl-btn { font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; padding: 8px 14px; border: 1px solid var(--rule); background: var(--paper); color: var(--ink); cursor: pointer; transition: all 160ms ease; } .tl-btn:hover:not(:disabled) { background: var(--ink); color: var(--paper); border-color: var(--ink); } .tl-btn:disabled { opacity: 0.35; cursor: not-allowed; } .tl-pill { display: inline-block; font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 0.06em; padding: 1px 6px; border: 1px solid var(--rule); background: var(--paper-2); color: var(--ink); } /* === Cell grids === */ .tl-cell { display: inline-flex; flex-direction: column; align-items: center; margin: 0 1px; position: relative; } .tl-cell-num { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--ink-faint); font-variant-numeric: tabular-nums; margin-bottom: 3px; letter-spacing: 0.05em; } .tl-cell-char { font-family: 'JetBrains Mono', monospace; font-size: 18px; font-weight: 500; width: 30px; height: 38px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--rule); background: var(--paper); position: relative; transition: all 160ms ease; } .tl-cell-mark { height: 12px; margin-top: 3px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-faint); line-height: 1; } /* Reference cell states */ .tl-cell-cursor .tl-cell-char { background: var(--hi); border-color: var(--ink); border-width: 2px; font-weight: 700; } .tl-cell-cursor .tl-cell-mark { color: var(--ink); font-weight: 700; } .tl-cell-past .tl-cell-char { color: var(--ink-soft); background: var(--paper-2); } .tl-cell-skipped .tl-cell-char { background: var(--hi-bad); color: var(--accent); border-color: var(--accent-soft); border-style: dashed; } .tl-cell-skipped .tl-cell-mark { color: var(--accent); } .tl-cell-scan .tl-cell-char { background: var(--hi-amber); border-color: var(--amber-soft); border-width: 2px; } .tl-cell-scan .tl-cell-mark { color: var(--amber); } /* Input cell fates */ .tl-cell-correct .tl-cell-char { background: var(--hi-good); border-color: var(--good-soft); color: var(--good); } .tl-cell-wrong .tl-cell-char { background: var(--hi-bad); border-color: var(--accent-soft); color: var(--accent); } .tl-cell-absolved .tl-cell-char { background: var(--hi-amber); border-color: var(--amber-soft); color: var(--amber); } .tl-cell-pending .tl-cell-char { color: var(--ink-faint); background: rgba(28,22,17,0.02); } .tl-cell-extra .tl-cell-char { background: var(--hi-bad); border-color: var(--accent-soft); color: var(--accent); border-style: dotted; } .tl-cell-tail .tl-cell-char { outline: 2px solid var(--ink); outline-offset: 2px; } .tl-cell-current-step .tl-cell-mark { color: var(--ink); font-weight: 700; } /* === Trace cards === */ .tl-trace-card { background: var(--paper); border: 1px solid var(--rule); padding: 14px 18px; margin-bottom: 10px; font-family: 'JetBrains Mono', monospace; font-size: 12.5px; line-height: 1.6; } .tl-trace-card.current { border-color: var(--ink); border-width: 2px; padding: 13px 17px; } .tl-trace-step-head { font-family: 'Fraunces', Georgia, serif; font-size: 17px; font-weight: 600; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 8px; } .tl-trace-step-meta { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-mute); font-weight: 400; letter-spacing: 0.02em; } .tl-trace-branch { padding: 2px 0; } .tl-trace-branch-pass { color: var(--good); } .tl-trace-branch-fail { color: var(--ink-mute); } .tl-trace-attempt { margin-left: 22px; font-size: 11.5px; } .tl-action-bar { margin-top: 10px; padding: 8px 12px; font-family: 'JetBrains Mono', monospace; font-size: 12px; letter-spacing: 0.04em; font-weight: 500; } .tl-action-advance { background: var(--hi-good); border-left: 3px solid var(--good); } .tl-action-skip-1 { background: var(--hi-amber); border-left: 3px solid var(--amber); } .tl-action-resync { background: var(--hi-amber); border-left: 3px solid var(--amber); } .tl-action-stay { background: var(--hi-bad); border-left: 3px solid var(--accent); } .tl-action-extra { background: var(--hi-bad); border-left: 3px solid var(--accent); } .tl-legend { display: flex; gap: 18px; flex-wrap: wrap; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-mute); align-items: center; } .tl-legend-swatch { display: inline-block; width: 14px; height: 12px; margin-right: 5px; vertical-align: middle; border: 1px solid var(--rule); } ::selection { background: var(--hi); color: var(--ink); } /* === Essay / prose === */ .tl-prose { font-family: 'Fraunces', Georgia, serif; font-size: 17px; line-height: 1.65; color: var(--ink); max-width: 700px; } .tl-prose p { margin: 0 0 18px; } .tl-prose h3 { font-family: 'Fraunces', Georgia, serif; font-variation-settings: "opsz" 96; font-weight: 500; font-size: 26px; letter-spacing: -0.02em; margin: 42px 0 14px; display: flex; align-items: baseline; gap: 14px; line-height: 1.15; } .tl-prose h3 .tl-prose-num { font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 0.22em; color: var(--ink-mute); font-weight: 500; text-transform: uppercase; flex-shrink: 0; } .tl-prose code { font-family: 'JetBrains Mono', monospace; font-size: 14px; background: var(--paper-2); padding: 1px 6px; border: 1px solid var(--rule-soft); font-weight: 400; } .tl-prose pre { font-family: 'JetBrains Mono', monospace; font-size: 12.5px; background: var(--paper-2); border: 1px solid var(--rule); padding: 16px 20px; line-height: 1.7; overflow-x: auto; margin: 20px 0; white-space: pre; color: var(--ink); } .tl-prose strong { font-weight: 600; color: var(--ink); } .tl-prose em { font-style: italic; } .tl-lede { font-size: 19.5px; line-height: 1.55; } .tl-dropcap::first-letter { font-family: 'Fraunces', Georgia, serif; font-variation-settings: "opsz" 144; font-size: 68px; font-weight: 600; float: left; line-height: 0.85; margin: 8px 12px -2px 0; color: var(--accent); letter-spacing: -0.02em; } .tl-skip-link { font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-mute); text-decoration: none; padding: 7px 13px; border: 1px solid var(--rule); background: var(--paper); display: inline-block; transition: all 160ms ease; } .tl-skip-link:hover { color: var(--paper); border-color: var(--ink); background: var(--ink); } .tl-essay-divider { text-align: center; margin: 40px 0 32px; color: var(--ink-faint); font-family: 'Fraunces', Georgia, serif; font-style: italic; font-size: 14px; letter-spacing: 0.1em; } .tl-essay-divider::before, .tl-essay-divider::after { content: "—"; margin: 0 12px; color: var(--rule); } `; /* ============================================================ ALGORITHM (pure, returns {newState, trace} per keystroke) ============================================================ */ function initialState() { return { cursor: 0, buffer: "", errorPositions: [], skippedPositions: [], sessionErrors: 0, keyStatErrors: {}, errorAtCursor: false, inputFates: [] }; } function processKeystroke(state, keystroke, params, referenceText) { const { N, maxSkip, bufferSize } = params; const newBuffer = (state.buffer + keystroke).slice(-bufferSize); const expected = referenceText[state.cursor]; const trace = { keystroke, cursorBefore: state.cursor, bufferBefore: state.buffer, bufferAfter: newBuffer, branches: [] }; /* --- Past end of reference --- */ if (expected === undefined) { return { newState: { ...state, buffer: newBuffer, sessionErrors: state.sessionErrors + 1, inputFates: [...state.inputFates, "extra"] }, trace: { ...trace, action: "extra-keystroke", cursorAfter: state.cursor } }; } /* --- Branch 1: exact match --- */ const exactMatch = keystroke === expected; trace.branches.push({ name: "exact", detail: `text[${state.cursor}] = ${JSON.stringify(expected)}, pressed = ${JSON.stringify(keystroke)}`, passed: exactMatch }); if (exactMatch) { return { newState: { ...state, cursor: state.cursor + 1, buffer: newBuffer, errorAtCursor: false, inputFates: [...state.inputFates, "correct"] }, trace: { ...trace, action: "advance", cursorAfter: state.cursor + 1 } }; } /* --- Branch 2: lookahead-1 (single-char skip with tightening) --- */ const next1 = referenceText[state.cursor + 1]; let la1Reject = null; if (next1 === undefined) la1Reject = "no character at text[cursor+1]"; else if (expected === " " || expected === "\n" || expected === "\t") la1Reject = "would skip whitespace"; else if (next1 === " " || next1 === "\n" || next1 === "\t") la1Reject = "lookahead char is whitespace"; else if (expected === next1) la1Reject = "doubled letter (expected == next)"; const la1Match = la1Reject === null && keystroke === next1; trace.branches.push({ name: "lookahead-1", detail: next1 !== undefined ? `text[${state.cursor + 1}] = ${JSON.stringify(next1)}, pressed = ${JSON.stringify(keystroke)}` : "no next character", passed: la1Match, rejectedReason: la1Reject && next1 !== undefined ? la1Reject : null }); if (la1Match) { return { newState: { ...state, cursor: state.cursor + 2, buffer: newBuffer, errorAtCursor: false, errorPositions: [...state.errorPositions, state.cursor], skippedPositions: [...state.skippedPositions, state.cursor], keyStatErrors: { ...state.keyStatErrors, [expected]: (state.keyStatErrors[expected] || 0) + 1 }, inputFates: [...state.inputFates, "correct"] }, trace: { ...trace, action: "skip-1", cursorAfter: state.cursor + 2, skippedChars: [expected] } }; } /* --- Branch 3: forward-scan with N-character confirmation --- */ const tail = newBuffer.slice(-N); const scanAttempts = []; let resyncK = -1; if (tail.length === N) { for (let k = 1; k <= maxSkip; k++) { const startPos = state.cursor + k; if (startPos + N > referenceText.length) { scanAttempts.push({ k, startPos, window: null, skipped: null, tokenMatch: false, hasWS: false, accepted: false, beyondEnd: true }); break; } const window = referenceText.slice(startPos, startPos + N); const skipped = referenceText.slice(state.cursor, startPos); const hasWS = /\s/.test(skipped) || /\s/.test(window); const tokenMatch = window === tail; const accepted = tokenMatch && !hasWS; scanAttempts.push({ k, startPos, window, skipped, tokenMatch, hasWS, accepted }); if (accepted) { resyncK = k; break; } } } trace.branches.push({ name: `forward-scan (N=${N})`, tail, N, attempts: scanAttempts, found: resyncK >= 0, note: tail.length < N ? `buffer tail too short — have ${tail.length} chars, need ${N}` : null }); if (resyncK >= 0) { const skippedRangeStart = state.cursor; const skippedRangeEnd = state.cursor + resyncK; const newErrorPositions = [...state.errorPositions]; const newSkippedPositions = [...state.skippedPositions]; const newKeyStatErrors = { ...state.keyStatErrors }; for (let i = skippedRangeStart; i < skippedRangeEnd; i++) { newErrorPositions.push(i); newSkippedPositions.push(i); const ch = referenceText[i]; newKeyStatErrors[ch] = (newKeyStatErrors[ch] || 0) + 1; } // Retro-absolve previous (N-1) keystrokes if they were marked 'wrong' const newInputFates = [...state.inputFates]; let absolvedCount = 0; const absolveStart = newInputFates.length - (N - 1); for (let i = absolveStart; i < newInputFates.length; i++) { if (i >= 0 && newInputFates[i] === "wrong") { newInputFates[i] = "absolved"; absolvedCount += 1; } } // The current keystroke is the Nth char of the matching tail — also absolved newInputFates.push("absolved"); return { newState: { ...state, cursor: state.cursor + resyncK + N, buffer: newBuffer, errorAtCursor: false, errorPositions: newErrorPositions, skippedPositions: newSkippedPositions, keyStatErrors: newKeyStatErrors, sessionErrors: state.sessionErrors - absolvedCount, inputFates: newInputFates }, trace: { ...trace, action: "resync", cursorAfter: state.cursor + resyncK + N, skippedChars: referenceText.slice(skippedRangeStart, skippedRangeEnd).split(""), absolvedCount, resyncK } }; } /* --- Branch 4: real error, stay put --- */ return { newState: { ...state, buffer: newBuffer, errorAtCursor: true, sessionErrors: state.sessionErrors + 1, inputFates: [...state.inputFates, "wrong"] }, trace: { ...trace, action: "stay-error", cursorAfter: state.cursor } }; } /* ============================================================ PRESETS ============================================================ */ const EXAMPLES = [ { id: "thinking", name: "thinking · rgoinking", reference: "thinking words", input: "rgoinking words" }, { id: "deletion", name: "missing letter · world/wrld", reference: "world peace", input: "wrld peace" }, { id: "garbage", name: "burst of garbage · hello/hxxxllo", reference: "hello world", input: "hxxxllo world" }, { id: "noresync", name: "no resync possible · xyz", reference: "abc def", input: "xyz def" }, { id: "trans", name: "transposition · the/hte", reference: "the cat sat", input: "hte cat sat" }, { id: "wsblock", name: "across word boundary", reference: "go now", input: "gnow" }, { id: "double", name: "doubled letter · all/al", reference: "all good", input: "al good" } ]; /* ============================================================ SUB-COMPONENTS ============================================================ */ function ReferenceRow({ text, cursor, errorPositions, scanRange }) { const errorSet = new Set(errorPositions); return (
{text.split("").map((ch, i) => { const isCursor = i === cursor; const isPast = i < cursor; const isSkipped = errorSet.has(i); const isScan = scanRange && i >= scanRange[0] && i < scanRange[1]; let cls = "tl-cell"; if (isCursor) cls += " tl-cell-cursor"; else if (isScan) cls += " tl-cell-scan"; else if (isSkipped) cls += " tl-cell-skipped"; else if (isPast) cls += " tl-cell-past"; const display = ch === " " ? "·" : ch === "\n" ? "↵" : ch; return (
{i}
{display}
{isCursor ? "▲" : isSkipped ? "✗" : isScan ? "scan" : ""}
); })}
); } function InputRow({ sequence, currentStep, fates, bufferSize, N }) { return (
{sequence.split("").map((ch, i) => { const isProcessed = i < currentStep; const fate = isProcessed ? fates[i] || "wrong" : "pending"; const isLatest = i === currentStep - 1; const isInBuffer = isProcessed && i >= currentStep - bufferSize && i < currentStep; const isInTail = isProcessed && i >= currentStep - N && i < currentStep; let cls = "tl-cell tl-cell-" + fate; if (isInTail) cls += " tl-cell-tail"; if (isLatest) cls += " tl-cell-current-step"; const display = ch === " " ? "·" : ch === "\n" ? "↵" : ch; return (
{i + 1}
{display}
{isLatest ? "▲" : isInBuffer && !isInTail ? "·" : ""}
); })}
); } function TraceCard({ trace, step, current }) { const actionClass = { advance: "tl-action-advance", "skip-1": "tl-action-skip-1", resync: "tl-action-resync", "stay-error": "tl-action-stay", "extra-keystroke": "tl-action-extra" }[trace.action] || ""; let actionLabel = ""; let actionDetail = ""; switch (trace.action) { case "advance": actionLabel = "ADVANCE"; actionDetail = `cursor ${trace.cursorBefore} → ${trace.cursorAfter}`; break; case "skip-1": actionLabel = "SKIP-1 · single-char lookahead match"; actionDetail = `cursor ${trace.cursorBefore} → ${trace.cursorAfter} · skipped "${trace.skippedChars.join("")}" (charged as key error)`; break; case "resync": actionLabel = `RESYNC · forward-scan match at k=${trace.resyncK}`; actionDetail = `cursor ${trace.cursorBefore} → ${trace.cursorAfter} · skipped "${trace.skippedChars.join("")}" · absolved last ${trace.absolvedCount} keystroke${trace.absolvedCount === 1 ? "" : "s"}`; break; case "stay-error": actionLabel = "STAY · no branch matched"; actionDetail = `cursor remains at ${trace.cursorBefore} · keystroke counted as session error`; break; case "extra-keystroke": actionLabel = "EXTRA · past end of reference"; actionDetail = "no character expected, keystroke counted as error"; break; default: actionLabel = trace.action; } return (
Step {step} · pressed {trace.keystroke === " " ? "␣" : trace.keystroke === "\n" ? "↵" : trace.keystroke} before: cursor={trace.cursorBefore}, buf="{trace.bufferBefore}"
{trace.branches.map((b, bi) => (
{b.passed ? "✓" : "✗"} {b.name} {b.detail &&   ·  {b.detail}} {b.rejectedReason &&   ·  {b.rejectedReason}} {b.note &&   ·  {b.note}} {b.attempts && b.attempts.length > 0 && (
{b.tail !== undefined && (
tail = "{b.tail}"  (seeking length-{b.N} match in next {b.attempts.length} reference window{b.attempts.length === 1 ? "" : "s"})
)} {b.attempts.map((a, ai) => (
{a.accepted ? "✓" : "✗"} k={a.k}   {a.beyondEnd ? ( (beyond end of reference) ) : ( <> text[{a.startPos}:{a.startPos + b.N}] = "{a.window}" {a.tokenMatch && a.hasWS &&  · token matches but skipping whitespace} {!a.tokenMatch &&  · no match} {a.accepted &&  · match!} )}
))}
)}
))}
{actionLabel} {actionDetail && · {actionDetail}}
); } function ParamSlider({ label, value, setValue, min, max, note }) { return (
{label}
setValue(parseInt(e.target.value, 10))} style={{ width: 130, accentColor: "var(--ink)" }} /> {value}
{note && ( {note} )}
); } /* ============================================================ MAIN ============================================================ */ export default function TypistLab() { const [referenceText, setReferenceText] = useState("thinking words"); const [inputSequence, setInputSequence] = useState("rgoinking words"); const [currentStep, setCurrentStep] = useState(0); const [N, setN] = useState(2); const [maxSkip, setMaxSkip] = useState(5); const [bufferSize, setBufferSize] = useState(6); const liveInputRef = useRef(null); const params = useMemo(() => ({ N, maxSkip, bufferSize }), [N, maxSkip, bufferSize]); // Simulate the entire input sequence; cache all intermediate states const { states, traces } = useMemo(() => { let s = initialState(); const sts = [s]; const trs = []; for (let i = 0; i < inputSequence.length; i++) { const r = processKeystroke(s, inputSequence[i], params, referenceText); sts.push(r.newState); trs.push(r.trace); s = r.newState; } return { states: sts, traces: trs }; }, [inputSequence, referenceText, params]); // Clamp currentStep when the sequence length changes useEffect(() => { setCurrentStep((s) => Math.min(s, inputSequence.length)); }, [inputSequence.length]); const currentState = states[Math.min(currentStep, states.length - 1)]; const currentTrace = currentStep > 0 ? traces[currentStep - 1] : null; // Highlight the scan window in the reference when current action is a resync let scanRange = null; if (currentTrace && currentTrace.action === "resync" && currentTrace.resyncK !== undefined) { const start = currentTrace.cursorBefore + currentTrace.resyncK; scanRange = [start, start + N]; } function loadExample(ex) { setReferenceText(ex.reference); setInputSequence(ex.input); setCurrentStep(0); } function reset() { setCurrentStep(0); } function stepBack() { setCurrentStep((s) => Math.max(0, s - 1)); } function stepFwd() { setCurrentStep((s) => Math.min(inputSequence.length, s + 1)); } function runAll() { setCurrentStep(inputSequence.length); } function handleLiveKey(e) { if (e.ctrlKey || e.metaKey || e.altKey) return; const k = e.key; if (k === "Backspace") { e.preventDefault(); if (inputSequence.length > 0) { const newSeq = inputSequence.slice(0, -1); setInputSequence(newSeq); setCurrentStep((s) => Math.min(s, newSeq.length)); } return; } if (k.length !== 1 && k !== "Enter") return; e.preventDefault(); const ch = k === "Enter" ? "\n" : k; setInputSequence(inputSequence + ch); setCurrentStep((s) => s + 1); } // Trace cards: show all processed steps, newest first const visibleTraces = traces.slice(0, currentStep); const reversedTraces = [...visibleTraces].reverse(); const errorCharsList = Object.entries(currentState.keyStatErrors); return ( <>
{/* HEADER */}
Vol. I  ·  Addendum  ·  A laboratory companion

Typist · Lab

On the design and behaviour of a forgiving typing-tutor cursor — followed by an apparatus for stepping through it, one keystroke at a time.

Skip to the apparatus →

{/* ESSAY */}

A typing tutor compares what the user types, character by character, against a reference passage. The simple version is straightforward: if the pressed key equals text[cursor], advance the cursor; otherwise, mark an error. This is the strict mode found in most typing software — keybr's only mode, Monkeytype's letter-mode. It is pedagogically defensible: every wrong key is felt. But it has a brittle failure case, and that failure case is what the apparatus below is built to study.

Suppose the reference is thinking words and the user types rgoinking words. They meant to type the right thing — their fingers were misplaced for the first three letters, then they recovered and typed the rest correctly. In strict mode the cursor freezes at position 0 (the t). The user keeps pressing keys, none of which match t. Each is logged as an error. They notice something is wrong only when they look up and see the cursor hasn't moved — by which time they've typed half a word into the void.

What we want, instead, is some way to recognise that the user is back on track — that their recent keystrokes line up with the reference, just at a different cursor position than we currently believe.

§ 1 Why simpler approaches fall short

The first thing to try is lookahead-1: after a mismatch at cursor, peek at text[cursor+1]. If the user's keystroke matches that, infer they skipped one character, charge the skipped character as a key error, and advance the cursor by two. This handles a clean single-letter deletion (world typed as wrld) well, and stays safe with two cheap checks: refuse to skip across whitespace — so a typo doesn't fly past a word boundary — and refuse to skip when text[cursor] equals text[cursor+1], the doubled-letter trap, where typing one l at ll looks like a skip but is just a missed second letter.

Lookahead-1 stops there. It cannot recover from rgoinking: three keystrokes happened before the user came back into phase, and by the time they type i we would need to peek at least two positions ahead. Even then, a single i matching text[2] is a coin flip's worth of evidence. Reasonably long English text has i's scattered everywhere; matching one in isolation tells us almost nothing about whether the user has actually recovered.

A different approach: full edit-distance. Run a Wagner-Fischer dynamic program that aligns the user's entire input against the reference and find the true minimum-cost alignment. This is mathematically clean. But it's slow to do per-keystroke, and the alignments it produces flicker — the cursor jumps backward and forward as the optimal alignment evolves with each new character. For an authoring tool, fine. For a real-time typing tutor, distracting.

The middle path is what the apparatus below implements: forward scan with N-character confirmation.

§ 2 The core insight

The user's recent keystrokes form a short signal. When they are typing correctly, that signal is identical to the corresponding window of the reference. When they are typing incorrectly, the signal diverges. When they recover, the signal re-converges — but to a different position in the reference than where the cursor currently sits.

We don't need to do anything sophisticated with the signal. We need to ask, after each keystroke: do my last N keystrokes appear contiguously in the next few characters of the reference? If yes, that is overwhelming evidence the user has resumed correct typing at that offset, and the cursor should snap there.

The choice of N is the entire game. N = 1 means "is the latest keystroke any character that appears within the next several positions?" — almost always true, gives garbage. N = 2 means "do my last two keystrokes match a 2-char window?" — coincidence rate roughly one-in-several-hundred for random letters and far better for English, where bigram frequencies are highly non-uniform. N = 3 is more conservative still, but it rarely fires; by the time the user types three correct letters past the reference position, they often realise something is off and stop. In practice, N = 2 is the sweet spot for natural-text typing tutors, and it is the default below. Try sliding it to 1 in the apparatus and watch the false positives accumulate.

§ 3 The four-branch decomposition

Each keystroke runs through four branches, in order, stopping at the first one that fires:

{`branch 1  —  exact match
  pressed === text[cursor] ?
  → advance cursor by 1.

branch 2  —  lookahead-1   (single-char skip)
  pressed === text[cursor+1],
  and not whitespace, and not a doubled letter ?
  → advance cursor by 2;
    charge text[cursor] as a key error.

branch 3  —  forward scan with N-confirmation
  buffer[-N:] === text[cursor+k : cursor+k+N]
  for some k in 1..MAX_SKIP,
  with no whitespace in the skipped slice ?
  → advance cursor by k+N;
    charge text[cursor : cursor+k] as key errors;
    retroactively absolve the previous N-1 keystrokes.

branch 4  —  real error
  none of the above matched.
  → stay; mark errorAtCursor;
    count this keystroke as a session error.`}

Branch 1 is the fast path: most keystrokes are correct. Branch 2 is essentially a special case of Branch 3 with N = 1, but it carries stricter safety conditions — particularly the doubled-letter check — and we keep it as a separate test because that single-skip case is common enough to deserve dedicated logic. Branch 3 is the heart of the algorithm. Branch 4 is the fallback for genuine errors, where the user really has typed the wrong key and needs to correct it before continuing.

§ 4 The accounting question

Once we accept that some wrong-at-the-time keystrokes were retroactively part of correct typing, we have to be careful about what "errors" means. There are two distinct quantities, and they should not be conflated.

The session error count drives the live accuracy display. It should reflect how often the user actually typed the wrong key. When they type r,g,o,i,n against thinking, three of those keystrokes (r, g, o) were genuinely wrong, and two (i, n) turned out to be the right keystrokes for positions 2 and 3 — they were just typed before the cursor reached them. An honest count is 3, not 5.

The per-key error count is what feeds the adaptive practice generator. It is keyed by the expected character: which keys did the user fumble? Here the answer is different. The user should have typed t and h to advance through positions 0 and 1, and they didn't. So t and h each get +1 error. This is what makes the adaptive generator surface practice text rich in t and h for a while — until the user demonstrates they can hit those keys reliably.

The algorithm tracks both. On a successful resync of distance k, the k skipped reference characters each get +1 in keyStatErrors, and the previous N−1 keystrokes flip from wrong to absolved, with sessionErrors decremented by the same count. The current keystroke that triggered the resync — being itself part of the matching tail — also doesn't add to the session count. This dual accounting is the algorithm's pedagogical contribution: strict mode collapses both quantities into one number, while forward-scan separates what the user did wrong from what their fingers missed. Adaptive practice uses the latter; live feedback uses the former. Neither is forced to lie about the other.

§ 5 The tightening conditions

Three constraints prevent the scan from doing dumb things, and watching them reject candidate matches in the trace is a good way to internalise them.

No whitespace in the skip. A spurious match across a space — the buffer go matching the start of a future word good, when the user actually skipped over a space and into a word they shouldn't be in — would feel violating. Refusing any resync whose skipped slice or matched window contains whitespace keeps the cursor inside word boundaries. Within-word slips are common; across-word slips usually mean the user is genuinely lost and should look up.

No doubled letters in lookahead-1. When the reference is all and the user types one l at position 1, lookahead-1 would compare l against text[2] = l and conclude the first l was skipped. They didn't skip it; they just typed too few. The check text[cursor] !== text[cursor+1] blocks this. Forward-scan with N ≥ 2 is naturally immune, since a 2-char tail cannot form a false match against a single doubled character.

Bounded max-skip. Without a cap, a user typing complete garbage for 30 seconds could eventually trigger a spurious resync against some random window of the reference. Capping the maximum skip at 5 or 6 keeps the algorithm anchored. If recovery does not happen within a handful of keystrokes, the cursor refuses to leap and the user is forced to look up and recover deliberately. That is the right outcome — the algorithm's job is to forgive small slips, not to rescue the lost.

§ 6 What it doesn't handle

The algorithm captures deletions — the user dropped a character — cleanly. It does not handle two related cases.

Insertions: the user typed an extra letter that isn't in the reference. Reference the, input thhe. Forward-scan finds no useful resync target because the cursor is already roughly where the alignment expects it to be — the problem is on the input side, not the reference side. To handle insertions, you would need a different motion: instead of skipping forward in the reference, skip backward in the input. Recognise that the latest keystroke is the extra one and the user is still on track at the current cursor position. This requires a small alignment buffer rather than single-keystroke lookahead.

Transpositions: the typed as hte. The algorithm handles single transpositions accidentally, by way of lookahead-1: when the user types h at position 0, lookahead-1 fires (text[1] = h), the cursor skips to 2, and the user's next t lands at position 2 expecting e — which is a real error. Net result: t is charged as the skipped key error, the typed t is wasted, and the user has to correct it. Not perfect (the user "typed all the right letters in the wrong order" gets recorded as one error and one wasted keystroke), but acceptable for a practice tool.

For genuinely tricky cases — long transposed runs, mixed insertions and deletions, repeated phonetic substitutions — the right tool is a small banded edit-distance computation (Damerau-Levenshtein over a 4-6 character window) or the bitap algorithm with errors. Those handle all four edit operations uniformly. They are more complex to implement and produce alignments rather than discrete branch decisions, but for high-end typing software they are worth the complexity. Forward-scan trades that capability for simplicity, predictability, and a trace you can read.

§ 7 A note on Aho–Corasick and friends

A natural question, given the keyword "matching": doesn't this look like a string-matching problem? Why not the standard fast algorithms?

Aho–Corasick is for finding any of many patterns in a stream. Its win is the failure-function machinery that keeps partial matches active across all patterns simultaneously. We have one pattern — the upcoming reference text — so AC degenerates into KMP, whose failure function we don't need either: our scan window is at most ~6 characters and our buffer tail is at most N characters, both tiny constants. The total work per keystroke is bounded by perhaps 30 character comparisons. Fancy preprocessing buys us nothing.

There is a deeper reason the analogy is wrong. Aho–Corasick and KMP answer "where in the text does the pattern appear?" We are asking the dual question — "where in the reference should the cursor be, given streaming user input?" That dual is online approximate string matching, and the textbook tool there is bitap (Wu & Manber, 1992), which encodes the match state as bitmasks updated in O(1) per keystroke. Bitap is genuinely beautiful, and it is the right answer if you want to handle insertions, transpositions, and substitutions all at once. But for skip-only behaviour, it is overkill. Forward-scan with N-confirmation gets the same recovery for ~30 lines of straightforward code instead of a bitmask DP.

The lesson is that algorithmic muscle should be calibrated to the problem. We have one short pattern, a tight scan window, and only one edit type to handle. The right algorithm is the smallest one that fits.

fin

Below: the apparatus. Step through any input one keystroke at a time, watch each branch test and outcome, see absolution in action.


{/* PRESETS */}
Presets
{EXAMPLES.map((ex) => ( ))}
{/* CONFIG */}
Reference text
{ setReferenceText(e.target.value); setCurrentStep(0); }} />
Input sequence (the user's keystrokes)
{ setInputSequence(e.target.value); setCurrentStep(0); }} />
{/* LIVE TYPING */}
Or type live · click here, then type — each keystroke appends to the sequence and auto-advances; backspace removes
{/* PARAMETERS */}

{/* PLAYBACK */}
Step {currentStep} / {inputSequence.length} {currentTrace && ( last action: {currentTrace.action} )}
{/* GRID VISUALIZATION */}
Reference

Input keystrokes   (black outline = current N-tail used in scan)

correct wrong (counted) absolved by resync skipped reference char (key error) resync scan window
{/* STATE PANEL */}
Algorithm state
cursor  =  {currentState.cursor} / {referenceText.length}
buffer  =  "{currentState.buffer}"
session errors  =  0 ? "var(--accent)" : "var(--ink)" }}>{currentState.sessionErrors}
error at cursor  =  {String(currentState.errorAtCursor)}
error positions  =  [{currentState.errorPositions.join(", ") || "—"}]
key error counts  = {" "} {errorCharsList.length === 0 ? ( {"{ }"} ) : ( errorCharsList.map(([k, v], i) => ( {i > 0 && ", "} {k === " " ? "␣" : k}  {v} )) )}
{/* TRACE LOG */}

Trace · newest first

{visibleTraces.length} step{visibleTraces.length === 1 ? "" : "s"}
{reversedTraces.length === 0 ? (
Use ▶ Step to advance one keystroke at a time, or ▶▶ Run all to process the entire input.
) : ( reversedTraces.map((tr, idx) => { const stepNum = visibleTraces.length - idx; return ( ); }) )}
); }