Reaction Timer
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Wait for the panel to turn green, then hit it — click, tap, or space — five times, and your average lands in big mono type. The clock starts on the animation frame the green actually paints rather than when the timer fired, and the run refuses to bank anything under 90 ms (a guess) or over 1.5 s (you looked away), so jumping the gun costs you the round instead of quietly flattering your score. Your five rounds drop onto the real spread of human click-and-react times, with a needle for your average and a dashed line where most people land. It opens on a finished sample run: press space to replace it with your own, then go early on purpose to watch the round replay instantly.
<!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>Reaction Timer — sloprun.dev</title>
<link rel="icon" href="data:,">
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
: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); }
/* ── end tokens ───────────────────────────────────────────────────────────── */
/* demo-local additions (tokens are opaque; these need alpha / on-color pairs) */
:root {
--shadow: 0 1px 2px rgba(26,29,33,.05);
--on-run: #FFFFFF; /* text on solid --run (contrast 5.0 on #1A7F37) */
--danger-soft: #FBEDEC;
--tick: rgba(26,29,33,.10);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--shadow: 0 1px 2px rgba(0,0,0,.35);
--on-run: #071B0C; /* text on solid --run (contrast 9.8 on #3FB950) */
--danger-soft: #2B1715;
--tick: rgba(232,230,225,.12);
}
}
:root[data-theme="dark"] {
--shadow: 0 1px 2px rgba(0,0,0,.35);
--on-run: #071B0C;
--danger-soft: #2B1715;
--tick: rgba(232,230,225,.12);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }
.wrap {
max-width: 1040px; margin: 0 auto;
padding: 22px 16px 26px;
display: flex; flex-direction: column; gap: 16px;
}
h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 70ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px; box-shadow: var(--shadow);
}
.card h2 {
margin: 0; font-family: var(--font-mono); font-size: 11px; font-weight: 600;
letter-spacing: .1em; text-transform: uppercase; color: var(--muted);
}
.stage { display: grid; grid-template-columns: minmax(0,1fr) 300px; gap: 16px; align-items: stretch; }
@media (max-width: 860px) { .stage { grid-template-columns: minmax(0,1fr); } }
/* ── the panel ───────────────────────────────────────────────────────────── */
.panelcard { display: flex; flex-direction: column; gap: 10px; padding: 14px; }
.panel {
-webkit-appearance: none; appearance: none;
width: 100%; min-height: clamp(198px, 33vh, 292px);
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); color: var(--ink);
font: inherit; text-align: center; padding: 18px 14px;
cursor: pointer; user-select: none; -webkit-user-select: none;
touch-action: manipulation; -webkit-tap-highlight-color: transparent;
transition: background-color .06s linear, color .06s linear, border-color .06s linear;
}
.panel:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.pw {
font-family: var(--font-mono); font-weight: 600;
font-size: clamp(24px, 6.2vw, 44px); letter-spacing: .09em; text-transform: uppercase;
line-height: 1.05;
}
.pw.num { letter-spacing: -.02em; font-size: clamp(40px, 11vw, 74px); font-variant-numeric: tabular-nums; }
.pw.num small { font-size: .34em; letter-spacing: .06em; opacity: .62; margin-left: .18em; }
.ps { font-size: 14px; line-height: 1.5; max-width: 34ch; opacity: .82; }
.panel[data-state="idle"] { border-style: dashed; border-color: var(--accent); background: var(--accent-soft); }
.panel[data-state="wait"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.panel[data-state="wait"] .ps { color: var(--ink); opacity: .7; }
/* the green must paint on one frame — a 60ms cross-fade would be measured as reaction time */
.panel[data-state="go"] { background: var(--run); border-color: var(--run); color: var(--on-run); transition: none; }
.panel[data-state="score"]{ background: var(--surface); border-color: var(--line); }
.panel[data-state="foul"] { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
.panel[data-state="foul"] .ps { color: var(--ink); opacity: .74; }
.panel[data-state="done"] { background: var(--run-soft); border-color: var(--run); }
.panel[data-state="done"] .pw { color: var(--run); }
.dots { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.dot {
flex: 1 1 0; min-width: 44px;
font-family: var(--font-mono); font-size: 11.5px; font-variant-numeric: tabular-nums;
text-align: center; padding: 6px 4px; border-radius: 7px;
border: 1px solid var(--line); color: var(--muted); background: var(--bg);
}
.dot b { display: block; font-size: 9px; letter-spacing: .12em; font-weight: 600; opacity: .7; }
.dot[data-s="done"] { color: var(--ink); border-color: var(--line); background: var(--surface); }
.dot[data-s="live"] { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.hint {
margin: 0; font-family: var(--font-mono); font-size: 11px; color: var(--muted);
line-height: 1.5; min-height: 1.5em;
}
.hint kbd {
font: inherit; border: 1px solid var(--line); border-bottom-width: 2px;
border-radius: 4px; padding: 0 4px; background: var(--bg); color: var(--ink);
}
/* ── results ─────────────────────────────────────────────────────────────── */
.results { display: flex; flex-direction: column; gap: 11px; }
.rhead { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.chip {
font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase;
border: 1px solid var(--line); border-radius: 999px; padding: 3px 8px; color: var(--muted);
background: var(--bg); white-space: nowrap;
}
.chip[data-ok="1"] { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.big {
font-family: var(--font-mono); font-weight: 600; font-variant-numeric: tabular-nums;
font-size: clamp(46px, 12vw, 66px); line-height: 1; letter-spacing: -.035em;
display: flex; align-items: baseline; gap: .16em;
}
.big .unit { font-size: .28em; letter-spacing: .08em; color: var(--muted); font-weight: 500; }
.big[data-empty="1"] { font-size: clamp(26px, 6vw, 34px); color: var(--muted); letter-spacing: .04em; }
.big[data-empty="1"] .unit { display: none; }
.bigrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: -4px 0 0; }
.biglab { margin: 0; font-size: 12.5px; color: var(--muted); }
.delta {
font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .04em;
border: 1px solid var(--line); border-radius: 999px; padding: 3px 8px;
color: var(--muted); background: var(--bg); white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.delta[data-dir="faster"] { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.delta[hidden] { display: none; }
.grid3 { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; }
.stat {
border: 1px solid var(--line); border-radius: 8px; padding: 7px 8px; background: var(--bg);
display: flex; flex-direction: column; gap: 2px; min-width: 0;
}
.stat .k { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
.stat .v { font-family: var(--font-mono); font-size: 14px; font-variant-numeric: tabular-nums; }
.rounds { list-style: none; margin: 2px 0 0; padding: 0; display: flex; flex-direction: column; gap: 5px; }
.rounds li {
display: grid; grid-template-columns: 16px minmax(0,1fr) 58px; align-items: center; gap: 8px;
font-family: var(--font-mono); font-size: 11.5px; font-variant-numeric: tabular-nums; color: var(--muted);
}
.rounds .bar { height: 8px; border-radius: 4px; background: var(--tick); overflow: hidden; }
.rounds .bar i { display: block; height: 100%; background: var(--accent); border-radius: 4px; }
.rounds .val { text-align: right; color: var(--ink); }
.rounds li[data-empty="1"] .val { color: var(--muted); }
.verdict { margin: 0; font-size: 13px; line-height: 1.55; color: var(--ink); }
.verdict b { color: var(--accent); }
.btns { display: flex; gap: 8px; flex-wrap: wrap; margin-top: auto; padding-top: 2px; }
button.b {
-webkit-appearance: none; appearance: none;
font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .02em;
padding: 8px 11px; border-radius: 8px; border: 1px solid var(--line);
background: var(--surface); color: var(--ink); cursor: pointer;
}
button.b:hover { border-color: var(--accent); color: var(--accent); }
button.b:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.b[data-primary] { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
/* ── distribution ────────────────────────────────────────────────────────── */
.dist { display: flex; flex-direction: column; gap: 10px; }
.dhead { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.dsub { font-size: 12.5px; color: var(--muted); margin: 0; }
.plot { position: relative; height: 122px; margin-top: 40px; }
.plot svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.plot .fill { fill: var(--accent); opacity: .085; }
.plot .stroke { fill: none; stroke: var(--accent); stroke-width: 1.5; opacity: .45; vector-effect: non-scaling-stroke; }
.sep { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--tick); }
.median { position: absolute; top: 6px; bottom: 0; width: 0; border-left: 1px dashed var(--muted); opacity: .75; }
.medlab {
position: absolute; top: -38px; transform: translateX(-50%);
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .06em; color: var(--muted); white-space: nowrap;
}
.needle { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--accent); border-radius: 2px; }
.needlab {
position: absolute; top: -20px; transform: translateX(-50%);
font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; color: var(--accent);
background: var(--surface); padding: 0 5px; border-radius: 5px; white-space: nowrap;
border: 1px solid var(--accent);
}
.mark {
position: absolute; bottom: 0; transform: translateX(-50%);
width: 9px; height: 9px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--accent);
}
.base { position: absolute; left: 0; right: 0; bottom: 4px; height: 1px; background: var(--line); }
.axis { position: relative; height: 30px; }
.axis .t {
position: absolute; top: 0; transform: translateX(-50%);
font-family: var(--font-mono); font-size: 10px; color: var(--muted); white-space: nowrap;
}
.axis .z {
position: absolute; top: 15px; transform: translateX(-50%);
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .06em; color: var(--muted);
text-transform: uppercase; white-space: nowrap; opacity: .9;
}
@media (max-width: 460px) { .axis .z[data-edge="1"] { display: none; } }
.legend {
display: flex; gap: 14px; flex-wrap: wrap; font-family: var(--font-mono);
font-size: 10.5px; color: var(--muted);
}
.legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; border: 2px solid var(--accent); vertical-align: -1px; margin-right: 5px; }
.legend i[data-k="typ"] { border: 0; border-top: 1px dashed var(--muted); border-radius: 0; height: 0; width: 14px; vertical-align: 3px; }
.share {
width: 100%; font-family: var(--font-mono); font-size: 11px; color: var(--muted);
background: var(--bg); border: 1px solid var(--line); border-radius: 8px;
padding: 8px 9px; overflow-x: auto; white-space: nowrap;
}
.share:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
footer {
margin-top: 4px; 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;
}
.sr {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Reaction Timer</h1>
<p class="lede">Wait for the panel to turn <b>green</b>, then hit it as fast as you can — five rounds,
then your average. Move too early and it's a jump: the round replays. The clock starts on the frame
the green actually paints, so the number is the real thing.</p>
</header>
<main class="stage">
<section class="card panelcard">
<button class="panel" id="panel" type="button" data-state="idle" aria-describedby="live">
<span class="pw" id="pw">Click to start</span>
<span class="ps" id="ps">Then wait for green. Five rounds, about ten seconds.</span>
</button>
<div class="dots" id="dots"></div>
<p class="hint" id="live" aria-live="polite">Ready · press <kbd>space</kbd>, <kbd>enter</kbd>, click or tap</p>
</section>
<aside class="card results">
<div class="rhead">
<h2>Results</h2>
<span class="chip" id="runchip">sample run</span>
</div>
<div class="big"><span id="avg">259</span><span class="unit">ms</span></div>
<div class="bigrow">
<p class="biglab" id="biglab">average of 5 rounds</p>
<span class="delta" id="delta" hidden></span>
</div>
<div class="grid3">
<div class="stat"><span class="k">best</span><span class="v" id="best">231</span></div>
<div class="stat"><span class="k">spread</span><span class="v" id="spread">58</span></div>
<div class="stat"><span class="k">jumps</span><span class="v" id="jumps">1</span></div>
</div>
<ol class="rounds" id="rounds"></ol>
<p class="verdict" id="verdict"></p>
<div class="btns">
<button class="b" id="again" type="button" data-btn data-primary>Run five rounds</button>
<button class="b" id="samp" type="button" data-btn>Sample run</button>
</div>
</aside>
</main>
<section class="card dist">
<div class="dhead">
<h2>Where your rounds land</h2>
<p class="dsub" id="pct">Typical click-and-react times pile up around 272 ms.</p>
</div>
<div class="plot" id="plot">
<svg viewBox="0 0 1000 100" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<path class="fill" id="curveFill" d=""></path>
<path class="stroke" id="curveLine" d=""></path>
</svg>
<div class="base"></div>
</div>
<div class="axis" id="axis"></div>
<div class="legend">
<span><i></i>your rounds</span>
<span><i data-k="typ"></i>where most people land</span>
<span id="lgnote">curve = how common each time is</span>
</div>
<div class="share" id="share" tabindex="0" aria-label="Shareable summary of this run"></div>
</section>
<footer>
<span>demo · sloprun.dev</span>
<span id="engine">reaction · performance.now · raf-timed</span>
</footer>
</div>
<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);
}
});
/* ---------- constants ---------- */
var ROUNDS = 5;
var LO = 120, HI = 520; /* chart domain, ms */
var MED = 272, SIG = 0.20; /* lognormal model of typical simple reaction times */
var MU = Math.log(MED);
var MIN_REAL = 90; /* below this it is a guess, not a reaction */
var MAX_REAL = 1500; /* above this you were not watching — not a reaction either */
var WAIT_MIN = 900, WAIT_SPAN = 2600;
var $ = function (id) { return document.getElementById(id); };
var panel = $("panel"), pw = $("pw"), ps = $("ps"), live = $("live"), dots = $("dots");
var avgEl = $("avg"), bigEl = null, biglab = $("biglab"), bestEl = $("best"), spreadEl = $("spread"),
jumpsEl = $("jumps"), roundsEl = $("rounds"), verdictEl = $("verdict"), runchip = $("runchip"),
deltaEl = $("delta");
var plot = $("plot"), axis = $("axis"), pctEl = $("pct"), shareEl = $("share");
bigEl = avgEl.parentNode;
/* ---------- state ---------- */
var SAMPLE = { times: [258, 231, 289, 244, 271], jumps: 1 };
var times = SAMPLE.times.slice(), jumps = SAMPLE.jumps, isSample = true;
var mode = "idle"; /* idle | wait | go | score | foul | done */
var armTimer = 0, goTs = 0, armedAt = -1e6, pointerHandled = false;
var prevAvg = null; /* the previous REAL run you are trying to beat */
var SETTLE = 140; /* a stray double-click is not a false start */
/* ---------- math ---------- */
function erf(x) {
var s = x < 0 ? -1 : 1; x = Math.abs(x);
var t = 1 / (1 + 0.3275911 * x);
var y = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
return s * y;
}
function cdf(ms) { return 0.5 * (1 + erf((Math.log(ms) - MU) / (SIG * Math.SQRT2))); }
function dens(ms) {
var z = (Math.log(ms) - MU) / SIG;
return Math.exp(-0.5 * z * z) / ms;
}
function pos(ms) {
var p = (ms - LO) / (HI - LO);
return Math.max(0, Math.min(1, p)) * 100;
}
function clampPct(p, pad) { return Math.max(pad, Math.min(100 - pad, p)); }
/* ---------- curve (static) ---------- */
(function drawCurve() {
var peak = dens(Math.exp(MU - SIG * SIG));
var pts = [], i, ms, y;
for (i = 0; i <= 200; i++) {
ms = LO + (HI - LO) * (i / 200);
y = 96 - (dens(ms) / peak) * 88;
pts.push((i * 5).toFixed(1) + " " + y.toFixed(2));
}
var line = "M" + pts.join(" L");
$("curveLine").setAttribute("d", line);
$("curveFill").setAttribute("d", line + " L1000 96 L0 96 Z");
})();
/* ---------- axis + bands (static) ---------- */
(function drawAxis() {
var marks = [150, 250, 350, 450];
var zones = [
{ a: LO, b: 220, t: "very fast", edge: 1 },
{ a: 220, b: 285, t: "quick", edge: 0 },
{ a: 285, b: 360, t: "average", edge: 0 },
{ a: 360, b: HI, t: "relaxed", edge: 1 }
];
var html = "", i;
for (i = 0; i < marks.length; i++) {
html += '<span class="t" style="left:' + pos(marks[i]).toFixed(2) + '%">' + marks[i] + '</span>';
}
for (i = 0; i < zones.length; i++) {
var mid = clampPct((pos(zones[i].a) + pos(zones[i].b)) / 2, 7);
html += '<span class="z" data-edge="' + zones[i].edge + '" style="left:' + mid.toFixed(2) + '%">' + zones[i].t + '</span>';
}
axis.innerHTML = html;
var sep = "";
for (i = 1; i < zones.length; i++) {
sep += '<span class="sep" style="left:' + pos(zones[i].a).toFixed(2) + '%"></span>';
}
plot.insertAdjacentHTML("beforeend", sep);
})();
/* ---------- rendering ---------- */
function fmt(n) { return String(Math.round(n)); }
function deltaText(avg) {
var d = Math.round(avg) - Math.round(prevAvg);
if (d === 0) { return "dead even with your last run"; }
return (d < 0 ? "\u2212" : "+") + fmt(Math.abs(d)) + " ms vs your last run";
}
function stats() {
var n = times.length, i, sum = 0, best = Infinity, worst = 0;
for (i = 0; i < n; i++) { sum += times[i]; if (times[i] < best) best = times[i]; if (times[i] > worst) worst = times[i]; }
return { n: n, avg: n ? sum / n : 0, best: n ? best : 0, spread: n ? worst - best : 0 };
}
function verdictFor(avg) {
if (avg < 190) return "That is at the very edge of what nerves can do — nice.";
if (avg < 230) return "Seriously quick. Most people never see numbers like this.";
if (avg < 270) return "Faster than the middle of the pack.";
if (avg < 320) return "Right in the normal band — this is where most people land.";
if (avg < 400) return "A relaxed pace. Sit up, unclench, and the number usually drops.";
return "Sleepy fingers. Try again with your hand already resting on the button.";
}
function render() {
var s = stats();
var full = s.n >= ROUNDS;
avgEl.textContent = s.n ? fmt(s.avg) : "waiting";
if (s.n) { bigEl.removeAttribute("data-empty"); } else { bigEl.setAttribute("data-empty", "1"); }
biglab.textContent = s.n
? ("average of " + s.n + " round" + (s.n === 1 ? "" : "s"))
: "first round lands here";
bestEl.textContent = s.n ? fmt(s.best) : "—";
spreadEl.textContent = s.n ? fmt(s.spread) : "—";
jumpsEl.textContent = String(jumps);
runchip.textContent = isSample ? "sample run" : (full ? "run complete" : "your run · " + s.n + "/" + ROUNDS);
if (!isSample && full) { runchip.setAttribute("data-ok", "1"); } else { runchip.removeAttribute("data-ok"); }
/* rounds list */
var html = "", i, ms, w;
for (i = 0; i < ROUNDS; i++) {
ms = times[i];
if (typeof ms === "number") {
w = Math.max(4, Math.min(100, ((ms - 100) / 400) * 100));
html += '<li><span>' + (i + 1) + '</span><span class="bar"><i style="width:' + w.toFixed(1) + '%"></i></span>' +
'<span class="val">' + fmt(ms) + ' ms</span></li>';
} else {
html += '<li data-empty="1"><span>' + (i + 1) + '</span><span class="bar"></span><span class="val">—</span></li>';
}
}
roundsEl.innerHTML = html;
/* round chips under the panel */
var dhtml = "", st;
for (i = 0; i < ROUNDS; i++) {
st = typeof times[i] === "number" ? "done" : ((i === times.length && mode !== "idle" && mode !== "done") ? "live" : "todo");
dhtml += '<span class="dot" data-s="' + st + '"><b>R' + (i + 1) + '</b>' +
(typeof times[i] === "number" ? fmt(times[i]) : "·") + '</span>';
}
dots.innerHTML = dhtml;
/* verdict */
if (!s.n) {
verdictEl.textContent = "";
} else if (full) {
verdictEl.innerHTML = (isSample ? "Sample run. " : "") + verdictFor(s.avg) +
' <b>' + fmt(s.avg) + ' ms</b> includes your screen and mouse — everyone carries that same 20–50 ms.';
} else {
verdictEl.textContent = (ROUNDS - s.n) + " round" + (ROUNDS - s.n === 1 ? "" : "s") + " to go.";
}
/* how this run stacks up against the one before it */
if (full && !isSample && prevAvg !== null) {
var diff = Math.round(s.avg) - Math.round(prevAvg);
deltaEl.hidden = false;
deltaEl.textContent = deltaText(s.avg);
if (diff === 0) { deltaEl.removeAttribute("data-dir"); }
else { deltaEl.setAttribute("data-dir", diff < 0 ? "faster" : "slower"); }
} else {
deltaEl.hidden = true;
deltaEl.removeAttribute("data-dir");
}
renderDist(s);
shareEl.textContent = s.n
? ("reaction timer · avg " + fmt(s.avg) + " ms · best " + fmt(s.best) + " · " + s.n + "/" + ROUNDS +
" rounds · " + jumps + " jump" + (jumps === 1 ? "" : "s") + (isSample ? " · sample" : "") + " · sloprun.dev")
: "reaction timer · no rounds yet · sloprun.dev";
}
function renderDist(s) {
var old = plot.querySelectorAll(".mark, .needle, .needlab, .median, .medlab"), i;
for (i = 0; i < old.length; i++) { old[i].parentNode.removeChild(old[i]); }
var add = "";
var mp = pos(MED);
add += '<span class="median" style="left:' + mp.toFixed(2) + '%"></span>';
add += '<span class="medlab" style="left:' + clampPct(mp, 10).toFixed(2) + '%">most people ~' + MED + '</span>';
for (i = 0; i < times.length; i++) {
add += '<span class="mark" style="left:' + pos(times[i]).toFixed(2) + '%" title="round ' + (i + 1) + ': ' + fmt(times[i]) + ' ms"></span>';
}
if (s.n) {
var ap = pos(s.avg);
add += '<span class="needle" style="left:' + ap.toFixed(2) + '%"></span>';
add += '<span class="needlab" style="left:' + clampPct(ap, 13).toFixed(2) + '%">avg ' + fmt(s.avg) + '</span>';
}
plot.insertAdjacentHTML("beforeend", add);
if (s.n) {
var faster = Math.round((1 - cdf(s.avg)) * 100);
faster = Math.max(1, Math.min(99, faster));
pctEl.textContent = "Your " + fmt(s.avg) + " ms average beats roughly " + faster +
"% of people, going by the usual spread of click-and-react times.";
} else {
pctEl.textContent = "Typical click-and-react times pile up around " + MED + " ms.";
}
}
/* ---------- panel copy ---------- */
function setPanel(state, word, sub, isNum) {
mode = state;
panel.setAttribute("data-state", state);
pw.className = "pw" + (isNum ? " num" : "");
pw.innerHTML = word;
ps.textContent = sub;
}
function say(msg) { live.innerHTML = msg; }
/* ---------- flow ---------- */
function clearTimer() { if (armTimer) { clearTimeout(armTimer); armTimer = 0; } }
function arm() {
clearTimer();
goTs = 0;
var n = times.length + 1;
armedAt = performance.now();
setPanel("wait", "Wait", "Green is coming. Don't move until it does.", false);
say("Round " + n + " of " + ROUNDS + " · armed · waiting for green");
render();
armTimer = setTimeout(go, WAIT_MIN + Math.random() * WAIT_SPAN);
}
function go() {
armTimer = 0;
setPanel("go", "Now", "Hit it.", false);
goTs = performance.now();
requestAnimationFrame(function (ts) { if (mode === "go" && ts > goTs) { goTs = ts; } });
say("Green · go");
}
/* a round that produced no usable number. "early"/"guess" are jumps and count as such;
"slow"/"left" are not the same sin, so they stay out of the jump tally and off the red panel. */
function voidRound(kind) {
clearTimer();
goTs = 0;
var n = times.length + 1;
if (kind === "early") {
jumps++;
setPanel("foul", "Too soon", "You went before the green. Round " + n + " replays — press to try it again.", false);
say("False start on round " + n + " · nothing recorded");
} else if (kind === "guess") {
jumps++;
setPanel("foul", "Jumped", "Under " + MIN_REAL + " ms is a guess, not a reaction. Round " + n + " replays.", false);
say("Anticipated round " + n + " · nothing recorded");
} else if (kind === "slow") {
setPanel("score", "Missed it", "Over " + (MAX_REAL / 1000) + "s is a look-away, not a reaction. Round " + n +
" replays — press when you are set.", false);
say("Missed round " + n + " · nothing recorded");
} else {
setPanel("score", "Paused", "You left the tab, so round " + n + " does not count. Press when you are back.", false);
say("Round " + n + " voided · you left the tab · nothing recorded");
}
render();
}
function score(ms) {
times.push(ms);
var s = stats();
if (times.length >= ROUNDS) {
setPanel("done", fmt(s.avg) + '<small>ms avg</small>', "Five rounds done. Press again for a fresh set.", true);
say("Run complete · average " + fmt(s.avg) + " ms · best " + fmt(s.best) + " ms" +
(prevAvg !== null ? " · " + deltaText(s.avg) : ""));
} else {
setPanel("score", fmt(ms) + "<small>ms</small>", "Round " + times.length + " of " + ROUNDS + " logged. Press for the next one.", true);
say("Round " + times.length + " · " + fmt(ms) + " ms · press to continue");
}
render();
}
function freshRun() {
clearTimer();
if (times.length >= ROUNDS && !isSample) { prevAvg = stats().avg; } /* never race the canned sample */
times = []; jumps = 0; isSample = false;
render();
arm();
}
function showSample() {
clearTimer();
times = SAMPLE.times.slice(); jumps = SAMPLE.jumps; isSample = true;
setPanel("idle", "Click to start", "An example run is already loaded. Press to record your own.", false);
say("Sample run loaded · press <kbd>space</kbd> to run your own");
render();
}
function act(t) {
switch (mode) {
case "idle":
freshRun();
break;
case "wait":
if (performance.now() - armedAt < SETTLE) { break; } /* tail of a double-click, not a jump */
voidRound("early");
break;
case "go":
var ms = t - goTs;
if (ms < MIN_REAL) { voidRound("guess"); }
else if (ms > MAX_REAL) { voidRound("slow"); }
else { score(ms); }
break;
case "score":
arm();
break;
case "foul":
arm();
break;
case "done":
freshRun();
break;
}
}
function stamp(e) {
var now = performance.now();
var t = e && typeof e.timeStamp === "number" ? e.timeStamp : 0;
return (t > 0 && t <= now + 1 && now - t < 5000) ? t : now;
}
/* ---------- input ---------- */
panel.addEventListener("pointerdown", function (e) {
if (e.button !== undefined && e.button !== 0) { return; }
e.preventDefault();
pointerHandled = true; /* the click that follows is the SAME press, however long the hold */
panel.focus({ preventScroll: true });
act(stamp(e));
});
panel.addEventListener("click", function (e) {
if (pointerHandled) { pointerHandled = false; return; } /* already handled on pointerdown */
act(stamp(e)); /* no pointer events: synthetic / assistive-tech click */
});
panel.addEventListener("pointercancel", function () { pointerHandled = false; });
document.addEventListener("pointerup", function (e) { /* released off-panel: no click will follow */
if (pointerHandled && !panel.contains(e.target)) { pointerHandled = false; }
});
panel.addEventListener("contextmenu", function (e) { e.preventDefault(); });
/* a backgrounded tab throttles the arm timer — the green can come and go unseen.
Void the in-flight round instead of banking a bogus multi-second "reaction". */
document.addEventListener("visibilitychange", function () {
if (document.hidden && (mode === "wait" || mode === "go")) { voidRound("left"); }
});
document.addEventListener("keydown", function (e) {
if (e.key !== " " && e.key !== "Spacebar" && e.key !== "Enter") { return; }
if (e.repeat || e.ctrlKey || e.metaKey || e.altKey) { return; }
var el = e.target;
if (el && el !== panel && el.hasAttribute && el.hasAttribute("data-btn")) { return; } /* let buttons act natively */
e.preventDefault();
act(stamp(e));
});
$("again").addEventListener("click", function () { freshRun(); panel.focus({ preventScroll: true }); });
$("samp").addEventListener("click", function () { showSample(); panel.focus({ preventScroll: true }); });
shareEl.addEventListener("focus", function () {
var r = document.createRange();
r.selectNodeContents(shareEl);
var sel = window.getSelection();
if (sel) { sel.removeAllRanges(); sel.addRange(r); }
});
/* ---------- boot ---------- */
showSample();
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.