$sloprun.dev

Typing Speed Test

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 5 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

A sixty-second typing test on three short pieces of ordinary prose — a slow morning, a second-hand bookshop, a walk up a river path. The clock does not start until your first keystroke; after that the passage colors itself in underneath you, right in solid ink and wrong in red, while speed and accuracy update on every character. The results screen keeps the honest numbers side by side — net wpm counts only the characters you got right, raw wpm counts everything your fingers did — next to mistakes, corrections, your longest clean streak, a speed curve dotted on every second that held a typo, and a replay of what you really typed. Let the opening demo run for a moment, then start typing to take it over; once you have a number, drop to the 15-second option and try to beat it, because short runs punish typos far harder than long ones.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/typing-test" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Typing Speed Test — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/typing-test

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 919 lines 37.0 KB index.html
demos/typing-test/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>Typing Speed Test — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
   Identity: instrument-panel. Machine facts in mono; human words in sans.
   Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }

/* ---------- local (non-token) helpers ---------- */
:root { --bad-soft: rgba(201,60,55,.14); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --bad-soft: rgba(244,112,103,.18); }
}
:root[data-theme="dark"] { --bad-soft: rgba(244,112,103,.18); }

/* ---------- base ---------- */
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; padding: 22px 18px 40px; line-height: 1.5; overflow-x: hidden; }
.wrap { max-width: 880px; margin: 0 auto; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
button { font: inherit; color: inherit; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

/* ---------- header ---------- */
.eyebrow {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .14em;
  text-transform: uppercase; color: var(--accent); margin: 0 0 6px;
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.eyebrow .led {
  width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft); flex: none;
}
h1 { font-size: clamp(21px, 4.8vw, 30px); line-height: 1.14; margin: 0 0 8px; letter-spacing: -.022em; }
.lede { margin: 0 0 18px; color: var(--muted); font-size: 15px; max-width: 60ch; }

/* ---------- panels ---------- */
.panel {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); margin-bottom: 14px; overflow: hidden;
}
.phead {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  padding: 9px 12px; border-bottom: 1px solid var(--line);
}
.ptitle {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--muted); margin: 0; font-weight: 600;
  min-width: 0; overflow-wrap: anywhere;
}
.spacer { flex: 1 1 auto; min-width: 0; }
.pbody { padding: 14px 12px; }

/* ---------- controls ---------- */
.btn {
  font-family: var(--font-mono); font-size: 12px; line-height: 1;
  padding: 8px 11px; border: 1px solid var(--line); border-radius: 7px;
  background: var(--surface); cursor: pointer; white-space: nowrap;
  transition: border-color .12s ease, background .12s ease, color .12s ease;
}
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn.primary { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); font-weight: 600; }
.btn.primary:hover { background: var(--accent); color: var(--surface); }
.chips { display: flex; gap: 4px; border: 1px solid var(--line); border-radius: 8px; padding: 2px; }
.chip {
  font-family: var(--font-mono); font-size: 11.5px; line-height: 1; padding: 6px 9px;
  border: 0; background: transparent; color: var(--muted); border-radius: 6px; cursor: pointer;
  transition: background .12s ease, color .12s ease;
}
.chip:hover { color: var(--ink); }
.chip[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 700; }

/* ---------- hud ---------- */
.hud { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 14px; }
.tile {
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
  padding: 10px 12px 0; overflow: hidden; display: flex; flex-direction: column; min-width: 0;
}
.tile .k {
  font-family: var(--font-mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase;
  color: var(--muted); margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tile .v {
  font-family: var(--font-mono); font-weight: 600; font-size: clamp(28px, 8.6vw, 46px);
  line-height: 1.02; letter-spacing: -.035em; font-variant-numeric: tabular-nums;
  display: flex; align-items: baseline; gap: 3px; min-width: 0; white-space: nowrap;
}
.tile .v .u { font-size: 12.5px; font-weight: 500; color: var(--muted); letter-spacing: 0; }
.tile .sub {
  font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin: 3px 0 8px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tile .bar { height: 3px; background: var(--line); margin: 0 -12px; }
.tile .bar i { display: block; height: 100%; background: var(--accent); width: 100%; transition: width .18s linear; }
.tile.low .v { color: var(--danger); }
.tile.low .bar i { background: var(--danger); }

/* ---------- typing stage ---------- */
.stage {
  position: relative; border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--bg); padding: 15px 16px; cursor: text;
  transition: border-color .12s ease, box-shadow .12s ease;
}
.stage:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.win {
  font-size: clamp(16.5px, 4.2vw, 21px); line-height: 1.78; height: 5.34em;
  overflow: hidden; position: relative;
}
.win::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: .75em;
  background: linear-gradient(to bottom, transparent, var(--bg)); pointer-events: none;
}
.text { transition: transform .2s ease; }
.c { position: relative; color: var(--muted); transition: color .07s linear; }
.c.ok { color: var(--ink); font-weight: 500; }
.c.bad { color: var(--danger); box-shadow: inset 0 -2px 0 var(--danger); }
.c.sp.bad { background: var(--bad-soft); }
.c.cur::before {
  content: ""; position: absolute; left: -1.5px; top: .04em; bottom: .2em; width: 2px;
  background: var(--accent); border-radius: 2px; animation: blink 1.06s steps(1) infinite;
}
@keyframes blink { 0%, 55% { opacity: 1 } 56%, 100% { opacity: 0 } }
.stage:not(:focus-within) .c.cur::before { animation: none; opacity: .5; }
#ta {
  position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 0;
  border: 0; outline: 0; resize: none; opacity: 0; background: transparent;
  font-size: 16px; caret-color: transparent; cursor: text; z-index: 2;
}
.hint {
  font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
  margin: 10px 2px 0;
}
.hint b { color: var(--accent); font-weight: 600; }
.hint .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%;
  background: var(--accent); margin-right: 7px; vertical-align: middle; }

/* ---------- results ---------- */
.rtop { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
.big {
  font-family: var(--font-mono); font-weight: 600; letter-spacing: -.045em; line-height: .92;
  font-size: clamp(50px, 15vw, 84px); font-variant-numeric: tabular-nums; white-space: nowrap;
}
.big .u { font-size: 15px; letter-spacing: 0; color: var(--muted); font-weight: 500; }
.rtop .side { display: flex; flex-direction: column; gap: 7px; padding-bottom: 7px; }
.badge {
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase;
  color: var(--run); background: var(--run-soft); border: 1px solid var(--run);
  padding: 4px 9px; border-radius: 999px; font-weight: 700; align-self: flex-start;
}
.badge.plain { color: var(--muted); background: transparent; border-color: var(--line); font-weight: 600; }
.rgrid {
  display: grid; grid-template-columns: repeat(4, 1fr);
  gap: 1px; background: var(--line); border: 1px solid var(--line);
  border-radius: var(--radius); overflow: hidden; margin-top: 16px;
}
@media (min-width: 780px) { .rgrid { grid-template-columns: repeat(8, 1fr); } }
.cell { background: var(--surface); padding: 9px 11px; }
.cell .k {
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .12em;
  text-transform: uppercase; color: var(--muted);
}
.cell .v {
  font-family: var(--font-mono); font-size: 19px; font-weight: 600;
  font-variant-numeric: tabular-nums; margin-top: 2px; letter-spacing: -.02em;
}
.cell .v.good { color: var(--run); }
.cell .v.bad { color: var(--danger); }
.chartwrap { margin-top: 16px; border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 10px 8px; }
.chartwrap h3 {
  font-family: var(--font-mono); font-size: 10px; letter-spacing: .13em;
  text-transform: uppercase; color: var(--muted); margin: 0 0 6px; font-weight: 600;
}
.chartwrap svg { display: block; width: 100%; height: auto; }
.chartwrap svg text { font-family: var(--font-mono); font-size: 10px; fill: var(--muted); }
.chartwrap svg .grid { stroke: var(--line); stroke-width: 1; }
.chartwrap svg .ln { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.chartwrap svg .ar { fill: url(#wpmGrad); }
.chartwrap svg .er { fill: var(--danger); }
.legend {
  display: flex; gap: 14px; flex-wrap: wrap; font-family: var(--font-mono);
  font-size: 10.5px; color: var(--muted); margin-top: 4px;
}
.legend i { display: inline-block; width: 10px; height: 2px; vertical-align: middle; margin-right: 5px; background: var(--accent); }
.legend i.d { height: 7px; width: 7px; border-radius: 50%; background: var(--danger); }
details.review { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 12px; }
details.review summary {
  font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); cursor: pointer;
  list-style: none; display: flex; align-items: center; gap: 6px;
}
details.review summary::-webkit-details-marker { display: none; }
details.review summary::before { content: "+"; color: var(--accent); font-weight: 700; }
details.review[open] summary::before { content: "\2212"; }
details.review summary:hover { color: var(--ink); }
.reviewbox {
  margin-top: 10px; padding: 12px 13px; background: var(--bg); border: 1px solid var(--line);
  border-radius: var(--radius); font-size: 15px; line-height: 1.75; max-height: 200px; overflow-y: auto;
}
.ractions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 16px; }

footer {
  margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap;
}

@media (max-width: 470px) {
  body { padding: 18px 12px 32px; }
  .pbody { padding: 12px 10px; }
  .stage { padding: 12px 12px; }
  .phead { padding: 8px 10px; }
  .hud { gap: 7px; }
  .tile { padding: 8px 9px 0; }
  .tile .bar { margin: 0 -9px; }
  .tile .v { font-size: clamp(25px, 7.6vw, 34px); }
  .tile .v .u { font-size: 10.5px; }
  .tile .k { font-size: 9.5px; letter-spacing: .1em; }
  .tile .sub { font-size: 9px; letter-spacing: -.012em; }
  .rgrid { grid-template-columns: repeat(2, 1fr); }
  .cell .v { font-size: 18px; }
}
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition: none !important; animation: none !important; scroll-behavior: auto !important;
  }
  .c.cur::before { opacity: 1; }
}
</style>
</head>
<body>
<div class="wrap">

  <p class="eyebrow"><span class="led"></span>typing test · <span id="durlab">60</span> seconds · net wpm</p>
  <h1>How fast do you actually type?</h1>
  <p class="lede">Type the passage below. The clock starts on your first keystroke — speed and accuracy update live, and when the time runs out every keystroke is accounted for.</p>

  <div class="hud">
    <div class="tile" id="timeTile">
      <div class="k">time left</div>
      <div class="v"><span id="vTime">60</span><span class="u">s</span></div>
      <div class="sub" id="sTime">press a key</div>
      <div class="bar"><i id="timeBar"></i></div>
    </div>
    <div class="tile">
      <div class="k">speed</div>
      <div class="v"><span id="vWpm">0</span><span class="u">wpm</span></div>
      <div class="sub" id="sWpm">net words/min</div>
      <div class="bar"><i id="wpmBar" style="width:0%"></i></div>
    </div>
    <div class="tile">
      <div class="k">accuracy</div>
      <div class="v"><span id="vAcc">100</span><span class="u">%</span></div>
      <div class="sub" id="sAcc">0 keystrokes</div>
      <div class="bar"><i id="accBar" style="width:0%"></i></div>
    </div>
  </div>

  <section class="panel" id="stagePanel">
    <div class="phead">
      <h2 class="ptitle" id="pmeta">passage 1 / 3</h2>
      <span class="spacer"></span>
      <div class="chips" role="group" aria-label="Test length in seconds">
        <button class="chip" type="button" data-dur="15" aria-pressed="false">15s</button>
        <button class="chip" type="button" data-dur="30" aria-pressed="false">30s</button>
        <button class="chip" type="button" data-dur="60" aria-pressed="true">60s</button>
      </div>
      <button class="btn" type="button" id="btnNew">new passage</button>
      <button class="btn" type="button" id="btnRestart">restart</button>
    </div>
    <div class="pbody">
      <div class="stage" id="stage">
        <div class="win"><div class="text" id="text" aria-hidden="true"></div></div>
        <textarea id="ta" rows="1" spellcheck="false" autocomplete="off" autocorrect="off"
          autocapitalize="off" aria-label="Typing area. Type the passage exactly as shown. Backspace fixes mistakes."
          aria-describedby="srPassage"></textarea>
      </div>
      <p class="sr" id="srPassage"></p>
      <p class="hint" id="hint"><span class="dot"></span><span id="hintText">loading</span></p>
    </div>
  </section>

  <section class="panel" id="resultsPanel" hidden>
    <div class="phead">
      <h2 class="ptitle">result</h2>
      <span class="spacer"></span>
      <span class="ptitle" id="rMeta"></span>
    </div>
    <div class="pbody">
      <div class="rtop">
        <div class="big"><span id="rWpm">0</span> <span class="u">wpm</span></div>
        <div class="side">
          <span class="badge" id="rBadge">run complete</span>
          <span class="badge plain" id="rBest">session best</span>
        </div>
      </div>
      <div class="rgrid" id="rGrid"></div>
      <div class="chartwrap" id="chartWrap">
        <h3>speed over time</h3>
        <div id="chart"></div>
        <div class="legend">
          <span><i></i>net wpm</span>
          <span><i class="d"></i>seconds containing a mistake</span>
        </div>
      </div>
      <details class="review">
        <summary>see exactly what you typed</summary>
        <div class="reviewbox" id="reviewBox" tabindex="0" role="group"
          aria-label="Character by character replay of what you typed"></div>
      </details>
      <div class="ractions">
        <button class="btn primary" type="button" id="btnAgain">go again · same passage</button>
        <button class="btn" type="button" id="btnAnother">different passage</button>
      </div>
    </div>
  </section>

  <footer>
    <span>demo · sloprun.dev</span>
    <span>net wpm = correct chars / 5 / minutes</span>
  </footer>
</div>

<p class="sr" role="status" aria-live="polite" id="live"></p>

<script>
(function () {
  "use strict";

  /* ---------- platform theme bridge ---------- */
  addEventListener("message", function (e) {
    var d = e && e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      document.documentElement.setAttribute("data-theme", d.theme);
    }
  });

  /* ---------- passages (plain prose, everyday words) ---------- */
  var PASSAGES = [
    {
      name: "before seven",
      text: "The kettle starts its slow climb toward a boil while the rest of the house is still quiet. There is a particular kind of light that only shows up before seven, thin and grey, and it lands on the counter in one long stripe. I grind the beans by hand because the electric one sounds like an argument at this hour. Water goes in slowly, in small circles, and the grounds rise and settle like something breathing. None of this is efficient. I could press a button and have coffee in forty seconds, and some mornings I do, but the slow version buys me ten minutes that belong to nobody else."
    },
    {
      name: "second hand books",
      text: "The shop on the corner sells books that other people have already finished. Nothing is sorted the way you would expect. Poetry sits next to boat repair, and a shelf marked history holds mostly cookbooks from the seventies. The owner insists there is a system and refuses to explain it, which I have decided is part of the charm. What I like best are the things left inside the pages: a bus ticket, a grocery list, a note that says back by six, do not wait. You pay four dollars and you take home a stranger's afternoon along with the story."
    },
    {
      name: "the river path",
      text: "The path along the river is flat for the first mile and then it decides otherwise. You climb through a stand of birch, the ground gets loose under your boots, and for about ten minutes you are certain this was a mistake. Then the trees open. From up there the water looks slow, though you know from standing in it that the current pushes harder than it lets on. A heron works the shallows with more patience than I have ever managed. We eat sandwiches on a warm rock and argue about whether the far bank is closer than it looks. It is not."
    }
  ];

  /* ---------- element refs ---------- */
  function $(id) { return document.getElementById(id); }
  var elText = $("text"), ta = $("ta"), stage = $("stage");
  var vTime = $("vTime"), vWpm = $("vWpm"), vAcc = $("vAcc");
  var sTime = $("sTime"), sWpm = $("sWpm"), sAcc = $("sAcc");
  var timeBar = $("timeBar"), wpmBar = $("wpmBar"), accBar = $("accBar"), timeTile = $("timeTile");
  var hintText = $("hintText"), pmeta = $("pmeta"), durlab = $("durlab");
  var stagePanel = $("stagePanel"), resultsPanel = $("resultsPanel"), live = $("live");
  var reduced = false;
  try { reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (err) { reduced = false; }

  /* ---------- state ---------- */
  var st = null, raf = 0, demoTimer = 0, best = 0, lineH = 0, baseTop = 0;

  function freshState(pi, dur) {
    return {
      pi: pi, dur: dur, text: PASSAGES[pi].text, spans: [], cls: [],
      typed: "", idx: 0, started: false, startedAt: 0, endsAt: 0,
      finished: false, demo: false, keys: 0, correct: 0, good: 0, mistakes: 0,
      backspaces: 0, run: 0, bestRun: 0, samples: [], errSecs: {}, lastSec: 0, scroll: -1
    };
  }

  function esc(ch) {
    if (ch === "&") return "&amp;";
    if (ch === "<") return "&lt;";
    if (ch === ">") return "&gt;";
    return ch;
  }
  function escAttr(ch) {
    if (ch === '"') return "&quot;";
    if (ch === "'") return "&#39;";
    return esc(ch);
  }

  /* ---------- passage rendering ---------- */
  function buildPassage() {
    var t = st.text, html = "", i, ch;
    for (i = 0; i < t.length; i++) {
      ch = t.charAt(i);
      html += '<span class="c' + (ch === " " ? " sp" : "") + '">' + esc(ch) + "</span>";
    }
    elText.innerHTML = html;
    st.spans = [].slice.call(elText.children);
    st.cls = [];
    for (i = 0; i < st.spans.length; i++) st.cls.push("");
    elText.style.transform = "translateY(0px)";
    st.scroll = 0;
    lineH = 0;
    $("srPassage").textContent = "Passage to type: " + t;
    pmeta.textContent = "passage " + (st.pi + 1) + " / " + PASSAGES.length + " · " + PASSAGES[st.pi].name;
  }

  function classFor(i) {
    var cls = st.text.charAt(i) === " " ? "c sp" : "c";
    if (i < st.typed.length) cls += st.typed.charAt(i) === st.text.charAt(i) ? " ok" : " bad";
    if (i === st.idx && !st.finished) cls += " cur";
    return cls;
  }

  function paint() {
    var i, want;
    for (i = 0; i < st.spans.length; i++) {
      want = classFor(i);
      if (st.cls[i] !== want) { st.spans[i].className = want; st.cls[i] = want; }
    }
    scrollToCursor();
  }

  function measureLine() {
    if (!st.spans.length) return;
    baseTop = st.spans[0].offsetTop;
    var i;
    for (i = 1; i < st.spans.length; i++) {
      if (st.spans[i].offsetTop > baseTop) { lineH = st.spans[i].offsetTop - baseTop; return; }
    }
    lineH = st.spans[0].offsetHeight || 30;
  }

  function scrollToCursor() {
    var span = st.spans[Math.min(st.idx, st.spans.length - 1)];
    if (!span) return;
    if (!lineH) measureLine();
    if (!lineH) return;
    var rel = span.offsetTop - baseTop;
    var target = Math.max(0, rel - lineH);
    if (target !== st.scroll) {
      st.scroll = target;
      elText.style.transform = "translateY(" + (-target) + "px)";
    }
  }

  /* ---------- stats ---------- */
  function elapsedSec(now) {
    if (!st.started) return 0;
    return Math.min(st.dur, (now - st.startedAt) / 1000);
  }
  function netWpm(el) { return el > 0 ? (st.good / 5) / (Math.max(el, 1) / 60) : 0; }
  function rawWpm(el) { return el > 0 ? (st.keys / 5) / (Math.max(el, 1) / 60) : 0; }
  function accuracy() { return st.keys ? (st.correct / st.keys) * 100 : 100; }

  function updateStats(now) {
    var el = elapsedSec(now);
    var rem = st.started ? Math.max(0, st.endsAt - now) : st.dur * 1000;
    var secs = Math.ceil(rem / 1000);

    vTime.textContent = String(secs);
    timeBar.style.width = (rem / (st.dur * 1000) * 100).toFixed(2) + "%";
    var warnAt = st.dur <= 20 ? 5 : 10;
    if (st.started && !st.finished && secs <= warnAt) timeTile.classList.add("low");
    else timeTile.classList.remove("low");
    sTime.textContent = st.demo ? "demo" :
      (st.finished ? "run finished" : (st.started ? "clock running" : "press a key"));

    var w = Math.round(netWpm(el));
    vWpm.textContent = String(w);
    wpmBar.style.width = Math.max(0, Math.min(100, w / 120 * 100)) + "%";
    sWpm.textContent = st.keys ? (Math.round(rawWpm(el)) + " raw wpm") : "net words/min";

    var a = accuracy();
    vAcc.textContent = String(Math.floor(a));
    if (!st.keys) {
      accBar.style.width = "0%";
      accBar.style.background = "var(--accent)";
    } else {
      accBar.style.width = a.toFixed(2) + "%";
      accBar.style.background = a >= 99.999 ? "var(--run)" : (a < 90 ? "var(--danger)" : "var(--accent)");
    }
    sAcc.textContent = st.keys + (st.keys === 1 ? " keystroke" : " keystrokes");

    if (st.started && !st.finished) {
      while (st.lastSec < Math.floor(el) && st.lastSec < st.dur) {
        st.lastSec++;
        st.samples.push({ t: st.lastSec, wpm: netWpm(st.lastSec) });
      }
    }
  }

  function setHint() {
    if (st.demo) {
      hintText.innerHTML = "demo running · <b>start typing</b> to take over";
    } else if (st.finished) {
      hintText.textContent = "run finished · press enter to go again";
    } else if (st.started) {
      hintText.textContent = "keystrokes " + st.keys + " · mistakes " + st.mistakes +
        " · corrections " + st.backspaces + " · longest clean run " + st.bestRun;
    } else {
      hintText.innerHTML = "<b>press any key to start</b> · " + st.dur +
        " seconds · backspace fixes mistakes";
    }
  }

  /* ---------- clock ---------- */
  function tick() {
    raf = 0;
    if (!st.started || st.finished) return;
    var now = Date.now();
    updateStats(now);
    if (now >= st.endsAt) { finish(false); return; }
    raf = requestAnimationFrame(tick);
  }
  function startTick() { if (!raf) raf = requestAnimationFrame(tick); }
  function stopTick() { if (raf) { cancelAnimationFrame(raf); raf = 0; } }

  /* ---------- input ---------- */
  function caretEnd() {
    try { ta.setSelectionRange(ta.value.length, ta.value.length); } catch (err) { /* ignore */ }
  }

  function onInput() {
    if (st.finished) { ta.value = st.typed; return; }
    var t = st.text;
    var v = ta.value.replace(/[\r\n\t]+/g, "");
    if (v.length > t.length) v = v.slice(0, t.length);
    if (ta.value !== v) { ta.value = v; caretEnd(); }

    /* diff against the common prefix: a selection-replace (ctrl+a then a key)
       can keep the length identical, and a length-only comparison would drop it
       on the floor and desync st.typed from what the box actually holds */
    var prev = st.typed, i, lim = Math.min(prev.length, v.length), common = 0;
    while (common < lim && prev.charAt(common) === v.charAt(common)) common++;
    var removed = prev.length - common, added = v.length - common;
    if (!removed && !added) return;

    if (removed) {
      st.backspaces += removed;
      st.run = 0;
      for (i = common; i < prev.length; i++) {
        if (prev.charAt(i) === t.charAt(i)) st.good--;
      }
    }
    if (added) {
      if (!st.started) {
        st.started = true;
        st.startedAt = Date.now();
        st.endsAt = st.startedAt + st.dur * 1000;
        startTick();
      }
      var sec = Math.floor(elapsedSec(Date.now()));
      for (i = common; i < v.length; i++) {
        st.keys++;
        if (v.charAt(i) === t.charAt(i)) {
          st.good++;
          st.correct++;
          st.run++;
          if (st.run > st.bestRun) st.bestRun = st.run;
        } else {
          st.mistakes++;
          st.run = 0;
          st.errSecs[sec] = true;
        }
      }
    }

    st.typed = v;
    st.idx = v.length;
    paint();
    updateStats(Date.now());
    setHint();
    if (v.length >= t.length) finish(true);
  }

  var BLOCKED = { ArrowLeft: 1, ArrowRight: 1, ArrowUp: 1, ArrowDown: 1,
    Home: 1, End: 1, PageUp: 1, PageDown: 1 };
  function blocked(k) { return Object.prototype.hasOwnProperty.call(BLOCKED, k); }

  ta.addEventListener("keydown", function (e) {
    if (typeof e.key !== "string") return;
    if (blocked(e.key)) { e.preventDefault(); caretEnd(); return; }
    if (e.key === "Enter") {
      e.preventDefault();
      if (st.finished) { reset(st.pi, st.dur, false); focusTa(); }
      return;
    }
    if (st.demo && (e.key === "Backspace" ||
        (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey))) {
      takeover();
      if (e.key === "Backspace") e.preventDefault();
    }
  });
  ta.addEventListener("input", function () {
    /* soft keyboards and IMEs fire input without a printable keydown, so the
       demo may still be running here — hand over, keeping what was just typed */
    if (st.demo) {
      var extra = ta.value.length > st.typed.length ? ta.value.slice(st.typed.length) : "";
      takeover();
      ta.value = extra;
    }
    caretEnd();
    onInput();
  });
  ta.addEventListener("paste", function (e) { e.preventDefault(); });
  ta.addEventListener("drop", function (e) { e.preventDefault(); });
  ta.addEventListener("click", caretEnd);
  stage.addEventListener("pointerdown", function () {
    if (st.demo) takeover();
    focusTa();
    setTimeout(function () { ta.focus(); caretEnd(); }, 0);
  });

  document.addEventListener("keydown", function (e) {
    if (typeof e.key !== "string") return;
    if (document.activeElement === ta) return;
    if (e.ctrlKey || e.metaKey || e.altKey) return;
    var tag = e.target && e.target.tagName;
    if (tag === "INPUT" || tag === "TEXTAREA" || tag === "BUTTON" ||
        tag === "SELECT" || tag === "SUMMARY" || tag === "A") return;
    if (st.finished) {
      if (e.key === "Enter") { e.preventDefault(); reset(st.pi, st.dur, false); focusTa(); }
      return;
    }
    if (e.key.length === 1) {
      e.preventDefault();
      if (st.demo) takeover();
      ta.focus();
      ta.value = ta.value + e.key;
      caretEnd();
      onInput();
    }
  });

  /* ---------- demo: the page opens already running ---------- */
  var NEIGHBOR = {
    a: "s", b: "v", c: "x", d: "f", e: "r", f: "g", g: "h", h: "j", i: "o", j: "k",
    k: "l", l: "k", m: "n", n: "m", o: "p", p: "o", q: "w", r: "t", s: "d", t: "y",
    u: "i", v: "b", w: "e", x: "c", y: "u", z: "x"
  };
  function wrongChar(ch) {
    var low = ch.toLowerCase();
    return NEIGHBOR[low] || "e";
  }

  function startDemo() {
    st.demo = true;
    setHint();
    var preview = st.text.slice(0, 46);

    if (reduced) {
      /* no animation: show the same moment as a finished still, typos and all */
      var flawed = "", spots = { 13: 1, 30: 1 }, j, c2;
      for (j = 0; j < preview.length; j++) {
        c2 = preview.charAt(j);
        flawed += (spots[j] && c2 !== " ") ? wrongChar(c2) : c2;
      }
      ta.value = flawed;
      onInput();
      st.startedAt = Date.now() - 8400;
      st.endsAt = st.startedAt + st.dur * 1000;
      stopTick();
      updateStats(Date.now());
      st.demo = true;
      setHint();
      return;
    }

    var seq = [], typos = { 13: 1, 30: 1 }, i, ch;
    for (i = 0; i < preview.length; i++) {
      ch = preview.charAt(i);
      if (typos[i] && ch !== " ") { seq.push(wrongChar(ch)); seq.push("\b"); }
      seq.push(ch);
    }
    var k = 0;
    function step() {
      demoTimer = 0;
      if (!st.demo) return;
      if (k >= seq.length) { stopTick(); setHint(); return; }
      var c = seq[k++];
      if (c === "\b") ta.value = ta.value.slice(0, -1);
      else ta.value = ta.value + c;
      onInput();
      if (!st.demo) return;
      setHint();
      var d = c === "\b" ? 140 : 135 + Math.random() * 95;
      if (seq[k] === "\b") d = 260;
      demoTimer = setTimeout(step, d);
    }
    demoTimer = setTimeout(step, 550);
  }

  function stopDemo() {
    if (demoTimer) { clearTimeout(demoTimer); demoTimer = 0; }
    if (st) st.demo = false;
  }

  function takeover() {
    if (!st.demo) return;
    stopDemo();
    reset(st.pi, st.dur, false);
  }

  /* ---------- finish ---------- */
  function fmt(n, d) { return n.toFixed(d === undefined ? 0 : d); }

  function finish(completed) {
    if (st.finished) return;
    stopDemo();
    stopTick();
    var now = Date.now();
    var el = elapsedSec(now);
    if (!(el > 0)) el = 0.001;
    if (Math.floor(el) > st.lastSec) {
      st.lastSec = Math.min(st.dur, Math.floor(el));
      st.samples.push({ t: st.lastSec, wpm: netWpm(el) });
    }
    st.finished = true;
    st.idx = st.typed.length;
    paint();
    updateStats(now);
    vTime.textContent = completed ? String(Math.max(0, Math.round(st.dur - el))) : "0";
    setHint();
    showResults(completed, el);
  }

  function showResults(completed, el) {
    var net = netWpm(el), raw = rawWpm(el), acc = accuracy();
    var rounded = Math.round(net);
    var isBest = rounded > best && rounded > 0;
    if (rounded > best) best = rounded;

    $("rWpm").textContent = fmt(net);
    $("rBadge").textContent = completed ? "passage finished early" : "run complete";
    var rbest = $("rBest");
    rbest.textContent = isBest ? "new session best" : ("session best " + best + " wpm");
    rbest.className = isBest ? "badge" : "badge plain";
    $("rMeta").textContent = PASSAGES[st.pi].name + " · " + fmt(el, 1) + "s";

    var cells = [
      ["accuracy", Math.floor(acc) + "%", acc >= 99.999 ? "good" : (acc < 90 ? "bad" : "")],
      ["raw wpm", fmt(raw), ""],
      ["correct", String(st.correct), ""],
      ["mistakes", String(st.mistakes), st.mistakes ? "bad" : ""],
      ["corrections", String(st.backspaces), ""],
      ["keystrokes", String(st.keys), ""],
      ["best streak", String(st.bestRun), ""],
      ["chars / sec", fmt(st.good / Math.max(el, 0.5), 1), ""]
    ];
    var html = "", i;
    for (i = 0; i < cells.length; i++) {
      html += '<div class="cell"><div class="k">' + cells[i][0] +
        '</div><div class="v ' + cells[i][2] + '">' + cells[i][1] + "</div></div>";
    }
    $("rGrid").innerHTML = html;

    /* reveal first: the chart measures its own box to size the axis labels */
    stagePanel.hidden = true;
    resultsPanel.hidden = false;

    drawChart(el);
    buildReview();
    live.textContent = "Run complete. " + fmt(net) + " net words per minute, " + Math.floor(acc) +
      " percent accuracy, " + st.mistakes + " mistakes across " + st.keys + " keystrokes.";
    var again = $("btnAgain");
    if (again) { try { again.focus(); } catch (err) { /* ignore */ } }
  }

  function drawChart(elapsed) {
    var box = $("chart"), wrap = $("chartWrap");
    var s = [], i;
    for (i = 0; i < st.samples.length; i++) {
      if (isFinite(st.samples[i].wpm)) s.push(st.samples[i]);
    }
    if (s.length < 2) { wrap.hidden = true; box.innerHTML = ""; return; }
    wrap.hidden = false;

    var W = Math.round(Math.max(300, Math.min(900, box.clientWidth || 600)));
    var H = 168, L = 44, R = 14, T = 12, B = 26;
    var maxW = 0;
    for (i = 0; i < s.length; i++) if (s[i].wpm > maxW) maxW = s[i].wpm;
    var yMax = Math.max(40, Math.ceil((maxW * 1.18) / 20) * 20);
    var tMax = Math.max(2, Math.min(st.dur, Math.max(Math.ceil(elapsed), s[s.length - 1].t)));
    function X(t) { return (L + (t / tMax) * (W - L - R)).toFixed(1); }
    function Y(v) { return (T + (1 - v / yMax) * (H - T - B)).toFixed(1); }

    var g = "", ticks = [0, yMax / 2, yMax], v;
    for (i = 0; i < ticks.length; i++) {
      v = ticks[i];
      g += '<line class="grid" x1="' + L + '" y1="' + Y(v) + '" x2="' + (W - R) + '" y2="' + Y(v) + '"/>';
      g += '<text x="' + (L - 7) + '" y="' + (parseFloat(Y(v)) + 3.5).toFixed(1) +
        '" text-anchor="end">' + Math.round(v) + "</text>";
    }
    var line = "";
    for (i = 0; i < s.length; i++) line += (i ? " L" : "M") + X(s[i].t) + "," + Y(s[i].wpm);
    var area = line + " L" + X(s[s.length - 1].t) + "," + Y(0) + " L" + X(s[0].t) + "," + Y(0) + " Z";

    var dots = "";
    for (i = 0; i < s.length; i++) {
      if (st.errSecs[s[i].t - 1]) {
        dots += '<circle class="er" cx="' + X(s[i].t) + '" cy="' + Y(s[i].wpm) + '" r="3.4"/>';
      }
    }
    var xlabs = "", stepT = tMax <= 6 ? 2 : (tMax <= 15 ? 5 : (tMax <= 30 ? 10 : 15));
    for (i = 0; i <= tMax; i += stepT) {
      xlabs += '<text x="' + X(i) + '" y="' + (H - 8) + '" text-anchor="middle">' + i + "s</text>";
    }

    box.innerHTML =
      '<svg viewBox="0 0 ' + W + " " + H + '" role="img" aria-label="Net words per minute over time">' +
      '<defs><linearGradient id="wpmGrad" x1="0" y1="0" x2="0" y2="1">' +
      '<stop offset="0%" style="stop-color:var(--accent);stop-opacity:.26"/>' +
      '<stop offset="100%" style="stop-color:var(--accent);stop-opacity:.02"/>' +
      "</linearGradient></defs>" + g +
      '<path class="ar" d="' + area + '"/>' +
      '<path class="ln" d="' + line + '"/>' + dots + xlabs + "</svg>";
  }

  function buildReview() {
    var t = st.text, v = st.typed, html = "", i, got, want;
    for (i = 0; i < v.length; i++) {
      got = v.charAt(i);
      want = t.charAt(i);
      html += '<span class="c' + (got === " " ? " sp" : "") +
        (got === want ? " ok" : " bad") + '"' +
        (got === want ? "" : ' title="should be: ' + (want === " " ? "space" : escAttr(want)) + '"') +
        ">" + esc(got) + "</span>";
    }
    if (!v.length) html = '<span class="c">nothing typed</span>';
    $("reviewBox").innerHTML = html;
  }

  /* ---------- reset / wiring ---------- */
  function reset(pi, dur, demo) {
    stopDemo();
    stopTick();
    st = freshState(pi, dur);
    ta.value = "";
    resultsPanel.hidden = true;
    stagePanel.hidden = false;
    timeTile.classList.remove("low");
    durlab.textContent = String(dur);
    buildPassage();
    paint();
    updateStats(Date.now());
    setHint();
    live.textContent = "";
    if (demo) startDemo();
  }

  function pickOther(cur) {
    if (PASSAGES.length < 2) return cur;
    var n = cur;
    while (n === cur) n = Math.floor(Math.random() * PASSAGES.length);
    return n;
  }

  function focusTa() { try { ta.focus({ preventScroll: true }); } catch (err) { ta.focus(); } }

  $("btnNew").addEventListener("click", function () { reset(pickOther(st.pi), st.dur, false); focusTa(); });
  $("btnRestart").addEventListener("click", function () { reset(st.pi, st.dur, false); focusTa(); });
  $("btnAgain").addEventListener("click", function () { reset(st.pi, st.dur, false); focusTa(); });
  $("btnAnother").addEventListener("click", function () { reset(pickOther(st.pi), st.dur, false); focusTa(); });

  var chips = [].slice.call(document.querySelectorAll(".chip"));
  chips.forEach(function (c) {
    c.addEventListener("click", function () {
      var d = parseInt(c.getAttribute("data-dur"), 10) || 60;
      chips.forEach(function (o) { o.setAttribute("aria-pressed", o === c ? "true" : "false"); });
      reset(st.pi, d, false);
      focusTa();
    });
  });

  window.addEventListener("resize", function () {
    lineH = 0;
    if (st && st.spans.length) { st.scroll = -1; scrollToCursor(); }
  });

  /* ---------- boot ---------- */
  reset(Math.floor(Math.random() * PASSAGES.length), 60, true);
  setTimeout(focusTa, 60);
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong