0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Flesch reading ease and Flesch–Kincaid grade level, computed live on whatever you paste, with every sentence shaded behind your text by how hard it is and the worst one pulled out and diagnosed. It opens on a paragraph of contract prose beside its plain rewrite: the 57-word opener scores −23 reading ease at grade 30, while every sentence of the rewrite lands between 74 and 101. The honest part is the syllable counter — English has no exact rule for syllables, so the page walks you through its guess (silent endings dropped, vowel runs counted), grades that guess against 150 hand-counted words, and publishes all 26 it gets wrong. Try the one very long sentence example to watch both dials run off their scales, then drag aim for grade down to 6 and see how much of your own writing clears it.
Source — the code you see is the code that just ran 1225 lines 57.0 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Readability Scorer — 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); }
/* ---- difficulty ramp (5 bands). No green in here: green is earned. ---- */
/* Pill text: --accent / --run sit at 4.2:1 and 4.5:1 on their own soft fills,
which misses AA at pill size in light theme. Darkened same-hue inks for light;
dark theme keeps the tokens, where they already clear 6:1. */
:root {
--accent-ink: #A34900;
--run-ink: #16702F;
--band0-bg: transparent; --band0-bar: #C3C7BE;
--band1-bg: #FBEFDD; --band1-bar: #E0A55B;
--band2-bg: #F9E0BE; --band2-bar: #D2822A;
--band3-bg: #F7CFA6; --band3-bar: #B85A0B;
--band4-bg: #F6C8C2; --band4-bar: #C93C37;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--accent-ink: #FFB454;
--run-ink: #3FB950;
--band0-bg: transparent; --band0-bar: #454B54;
--band1-bg: #2B2318; --band1-bar: #7A5B26;
--band2-bg: #3A2C18; --band2-bar: #B07C2A;
--band3-bg: #4B341A; --band3-bar: #E39A3C;
--band4-bg: #4A2320; --band4-bar: #F47067;
}
}
:root[data-theme="dark"] {
--accent-ink: #FFB454;
--run-ink: #3FB950;
--band0-bg: transparent; --band0-bar: #454B54;
--band1-bg: #2B2318; --band1-bar: #7A5B26;
--band2-bg: #3A2C18; --band2-bar: #B07C2A;
--band3-bg: #4B341A; --band3-bar: #E39A3C;
--band4-bg: #4A2320; --band4-bar: #F47067;
}
/* ---- shell ---- */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { font-size: 16px; line-height: 1.55; -webkit-text-size-adjust: 100%; overflow-x: hidden; }
.wrap { max-width: 1020px; margin: 0 auto; padding: 26px 18px 34px; }
h1 { font-size: 1.5rem; line-height: 1.2; margin: 0 0 7px; letter-spacing: -.015em; }
h2 { font-size: .95rem; margin: 0; font-weight: 600; }
.lede { margin: 0; color: var(--muted); font-size: .95rem; max-width: 70ch; }
.lede b { color: var(--ink); font-weight: 600; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.k-lab {
font-family: var(--font-mono); font-size: .63rem; letter-spacing: .12em;
text-transform: uppercase; color: var(--muted);
}
/* ---- cards ---- */
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px; margin-top: 14px;
}
.cardhead { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; margin-bottom: 11px; }
.cardhead .note { font-size: .78rem; color: var(--muted); margin-left: auto; }
.top { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; }
.top > .card { margin-top: 14px; }
@media (max-width: 720px) { .top { grid-template-columns: minmax(0,1fr); gap: 0; } }
/* ---- buttons ---- */
.btn {
font-family: var(--font-mono); font-size: .72rem; letter-spacing: .02em;
background: var(--bg); color: var(--ink); border: 1px solid var(--line);
border-radius: 999px; padding: 6px 11px; min-height: 32px; cursor: pointer;
white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis;
transition: border-color .15s, color .15s, background .15s;
}
.btn:hover { border-color: var(--accent); color: var(--accent-ink); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-ink); }
.btn[disabled] { opacity: .45; cursor: not-allowed; }
.btn[disabled]:hover { border-color: var(--line); color: var(--ink); }
.row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.row.tight { gap: 5px; }
/* ---- dials ---- */
.dials { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.dial { text-align: center; min-width: 0; }
.gaugewrap { position: relative; margin: 2px auto 0; max-width: 190px; }
svg.gauge { display: block; width: 100%; height: auto; }
svg.gauge .track { fill: none; stroke: var(--line); stroke-width: 9; stroke-linecap: round; }
svg.gauge .val {
fill: none; stroke: var(--accent); stroke-width: 9; stroke-linecap: round;
transition: stroke-dashoffset .45s cubic-bezier(.22,.61,.36,1);
}
svg.gauge .notch { stroke: var(--ink); stroke-width: 1.8; stroke-linecap: round; }
.gval {
position: absolute; left: 0; right: 0; bottom: 1px; text-align: center;
pointer-events: none;
}
.gnum {
font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: clamp(1.5rem, 7vw, 2.1rem); line-height: 1; letter-spacing: -.03em;
display: block;
}
.gsub { font-family: var(--font-mono); font-size: .62rem; color: var(--muted); letter-spacing: .06em; }
.dband {
font-family: var(--font-mono); font-size: .68rem; letter-spacing: .07em;
color: var(--accent-ink); margin-top: 5px; min-height: 1.2em;
}
.dscale {
display: flex; justify-content: space-between; padding: 0 3px; font-family: var(--font-mono);
font-size: .58rem; color: var(--muted); margin-top: 2px; letter-spacing: .04em;
}
.notchkey { font-size: .55rem; color: var(--muted); letter-spacing: .04em; margin-top: 3px; }
.offscale {
display: inline-block; font-family: var(--font-mono); font-size: .58rem;
border: 1px solid var(--line); border-radius: 999px; padding: 1px 6px;
color: var(--muted); margin-top: 4px;
}
/* ---- stat grid ---- */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(88px, 1fr)); gap: 9px; }
.stat { min-width: 0; }
.stat .v {
font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: 1.05rem; line-height: 1.2; display: block; letter-spacing: -.01em;
}
.stat .k { font-family: var(--font-mono); font-size: .59rem; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); display: block; margin-top: 1px; }
/* ---- grade goal control ---- */
.goalset { margin-top: 13px; border-top: 1px dashed var(--line); padding-top: 11px; }
.trow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.trow label { font-family: var(--font-mono); font-size: .66rem; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
input[type="range"] { flex: 1 1 130px; min-width: 110px; accent-color: var(--accent); height: 22px; }
input[type="range"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; }
.tval { font-family: var(--font-mono); font-size: .84rem; }
.passbar { height: 6px; border-radius: 999px; background: var(--line); margin-top: 10px; overflow: hidden; }
.passbar > i { display: block; height: 100%; background: var(--accent); transition: width .3s ease; }
.passbar.all > i { background: var(--run); }
.passline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 7px; font-family: var(--font-mono); font-size: .7rem; color: var(--muted); }
.pill {
font-family: var(--font-mono); font-size: .64rem; letter-spacing: .07em;
border-radius: 999px; padding: 3px 9px; border: 1px solid var(--line); white-space: nowrap;
}
.pill.ok { background: var(--run-soft); border-color: var(--run); color: var(--run-ink); }
.pill.warn { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-ink); }
/* ---- worst callout ---- */
.worst blockquote {
margin: 0; padding: 11px 13px; background: var(--bg); border: 1px solid var(--line);
border-left: 3px solid var(--danger); border-radius: 7px; font-size: .95rem; line-height: 1.6;
}
.mini { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; }
.mini > span { font-family: var(--font-mono); font-size: .72rem; color: var(--muted); white-space: nowrap; }
.mini b { color: var(--ink); font-weight: 500; font-variant-numeric: tabular-nums; }
.why { margin: 9px 0 0; font-size: .87rem; color: var(--muted); }
.why b { color: var(--ink); font-weight: 600; }
/* ---- editor ---- */
.stack { position: relative; margin-top: 10px; }
.mirror, .editor {
font-family: var(--font-sans); font-size: 1.02rem; line-height: 1.72;
padding: 13px; border: 1px solid var(--line); border-radius: 9px;
white-space: pre-wrap; overflow-wrap: break-word; text-align: left; margin: 0;
letter-spacing: normal; word-spacing: normal; font-weight: 400;
}
.mirror {
background: var(--surface); color: transparent; min-height: 190px;
pointer-events: none; user-select: none;
}
.editor {
position: absolute; inset: 0; width: 100%; height: 100%; display: block;
background: transparent; color: var(--ink); caret-color: var(--accent);
resize: none; overflow: hidden;
}
.editor:focus { outline: none; }
.stack:focus-within .mirror { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.editor::selection { background: var(--accent-soft); color: var(--ink); }
.s { border-radius: 3px; box-decoration-break: clone; -webkit-box-decoration-break: clone; }
.s.b0 { background: var(--band0-bg); }
.s.b1 { background: var(--band1-bg); }
.s.b2 { background: var(--band2-bg); }
.s.b3 { background: var(--band3-bg); }
.s.b4 { background: var(--band4-bg); }
.s.over { text-decoration: underline dotted var(--muted); text-underline-offset: 4px; text-decoration-thickness: 1px; }
.s.sel { box-shadow: inset 0 -2px 0 var(--accent); }
/* ---- legend ---- */
.legend { display: flex; flex-wrap: wrap; gap: 5px 12px; margin-top: 10px; }
.lg { display: inline-flex; align-items: center; gap: 5px; font-family: var(--font-mono); font-size: .63rem; color: var(--muted); }
.sw { width: 13px; height: 13px; border-radius: 3px; border: 1px solid var(--line); flex: 0 0 auto; }
.sw.b0 { background: var(--band0-bar); }
.sw.b1 { background: var(--band1-bar); }
.sw.b2 { background: var(--band2-bar); }
.sw.b3 { background: var(--band3-bar); }
.sw.b4 { background: var(--band4-bar); }
.sw.over {
background: none; border: 0; border-bottom: 2px dotted var(--muted);
border-radius: 0; height: 9px; align-self: center;
}
/* ---- sentence strip ---- */
.stripwrap { margin-top: 13px; }
.strip {
display: flex; align-items: flex-end; gap: 2px; height: 62px; overflow-x: auto;
padding: 0 0 2px; border-bottom: 1px solid var(--line);
}
.strip button {
flex: 1 1 auto; min-width: 7px; padding: 0; border: 0; background: none;
cursor: pointer; height: 100%; display: flex; align-items: flex-end;
}
.strip button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.strip button > i {
display: block; width: 100%; border-radius: 2px 2px 0 0;
transition: height .3s ease, background .2s;
}
.strip button > i.b0 { background: var(--band0-bar); }
.strip button > i.b1 { background: var(--band1-bar); }
.strip button > i.b2 { background: var(--band2-bar); }
.strip button > i.b3 { background: var(--band3-bar); }
.strip button > i.b4 { background: var(--band4-bar); }
.strip button[aria-pressed="true"] > i { outline: 2px solid var(--accent); outline-offset: 1px; }
.strip button:hover > i { filter: brightness(1.12); }
.striplabels { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: .58rem; color: var(--muted); margin-top: 4px; letter-spacing: .05em; }
/* ---- detail ---- */
.det { margin-top: 13px; border-top: 1px dashed var(--line); padding-top: 12px; }
.det blockquote {
margin: 8px 0 0; padding: 10px 12px; background: var(--bg); border: 1px solid var(--line);
border-radius: 7px; font-size: .92rem; line-height: 1.6;
}
.dethead { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
/* ---- lab ---- */
.labgrid { display: grid; grid-template-columns: minmax(0, 300px) minmax(0, 1fr); gap: 16px; }
@media (max-width: 700px) { .labgrid { grid-template-columns: minmax(0,1fr); } }
input.probe {
width: 100%; font-family: var(--font-mono); font-size: 1rem; padding: 10px 12px;
border-radius: 9px; border: 1px solid var(--line); background: var(--bg); color: var(--ink);
}
input.probe:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.wordview { font-family: var(--font-mono); font-size: 1.35rem; letter-spacing: .04em; word-break: break-all; }
.vg { background: var(--accent-soft); color: var(--accent-ink); border-radius: 3px; padding: 0 1px; }
.dropped { color: var(--muted); text-decoration: line-through; }
.steps { list-style: none; margin: 11px 0 0; padding: 0; }
.steps li {
font-family: var(--font-mono); font-size: .72rem; color: var(--muted);
padding: 3px 0 3px 15px; position: relative; line-height: 1.5;
}
.steps li::before { content: "·"; position: absolute; left: 4px; color: var(--accent); }
.steps li b { color: var(--ink); font-weight: 500; }
.steps li.ok { color: var(--run); }
.steps li.ok::before { content: "✓"; color: var(--run); font-size: .8em; }
.steps li.miss::before { content: "!"; color: var(--danger); }
.misstable { width: 100%; border-collapse: collapse; margin-top: 10px; font-family: var(--font-mono); font-size: .7rem; }
.misstable th { text-align: left; color: var(--muted); font-weight: 400; letter-spacing: .07em; text-transform: uppercase; font-size: .58rem; padding: 4px 8px 4px 0; border-bottom: 1px solid var(--line); }
.misstable td { padding: 3px 8px 3px 0; border-bottom: 1px solid var(--line); font-variant-numeric: tabular-nums; }
.misstable td.rule { color: var(--danger); }
.misstable td.hand { color: var(--ink); }
.tablescroll { max-height: 230px; overflow: auto; margin-top: 4px; }
/* ---- method ---- */
details.method {
margin-top: 14px; border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); padding: 0;
}
details.method > summary {
cursor: pointer; padding: 12px 14px; font-family: var(--font-mono); font-size: .74rem;
letter-spacing: .04em; color: var(--muted); list-style: none;
}
details.method > summary::-webkit-details-marker { display: none; }
details.method > summary::before { content: "▸ "; color: var(--accent); }
details.method[open] > summary::before { content: "▾ "; }
details.method > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius); }
.method .body { padding: 0 14px 14px; font-size: .88rem; color: var(--muted); max-width: 74ch; }
.method .body p { margin: 9px 0; }
.method .body b { color: var(--ink); font-weight: 600; }
.method .body code { font-family: var(--font-mono); font-size: .85em; color: var(--ink); }
.method .body ul { margin: 9px 0; padding-left: 18px; }
.method .body li { margin: 4px 0; }
.formula {
font-family: var(--font-mono); font-size: .72rem; color: var(--ink); background: var(--bg);
border: 1px solid var(--line); border-radius: 7px; padding: 9px 11px; margin: 9px 0;
overflow-x: auto; white-space: pre; line-height: 1.6;
}
footer { margin-top: 24px; font-family: var(--font-mono); font-size: .7rem; color: var(--muted); }
@media (max-width: 380px) {
.wrap { padding: 20px 13px 30px; }
.card { padding: 12px; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Readability Scorer</h1>
<p class="lede">Paste any writing. It scores how hard each sentence is to read — <b>reading ease</b>,
<b>school grade</b>, and a shade behind every sentence — entirely inside this page. Nothing is uploaded.</p>
</header>
<div class="top">
<section class="card" aria-labelledby="score-h">
<div class="cardhead">
<h2 id="score-h">Whole text</h2>
<span class="note mono" id="scope-note">7 sentences</span>
</div>
<div class="dials">
<div class="dial">
<div class="k-lab">reading ease</div>
<div class="gaugewrap">
<svg class="gauge" viewBox="0 0 120 70" aria-hidden="true" focusable="false">
<path class="track" d="M12,60 A48,48 0 0 1 108,60" pathLength="100"></path>
<path class="val" id="arc-ease" d="M12,60 A48,48 0 0 1 108,60" pathLength="100"
stroke-dasharray="100" stroke-dashoffset="100"></path>
</svg>
<span class="gval"><span class="gnum" id="ease-num">—</span><span class="gsub">flesch</span></span>
</div>
<div class="dband mono" id="ease-band"> </div>
<div class="dscale"><span>0 hard</span><span>easy 100</span></div>
<div id="ease-off"></div>
</div>
<div class="dial">
<div class="k-lab">school grade</div>
<div class="gaugewrap">
<svg class="gauge" viewBox="0 0 120 70" aria-hidden="true" focusable="false">
<path class="track" d="M12,60 A48,48 0 0 1 108,60" pathLength="100"></path>
<path class="val" id="arc-grade" d="M12,60 A48,48 0 0 1 108,60" pathLength="100"
stroke-dasharray="100" stroke-dashoffset="100"></path>
<line class="notch" id="grade-notch" x1="0" y1="0" x2="0" y2="0"></line>
</svg>
<span class="gval"><span class="gnum" id="grade-num">—</span><span class="gsub">kincaid</span></span>
</div>
<div class="dband mono" id="grade-band"> </div>
<div class="dscale"><span>0</span><span>18</span></div>
<div class="notchkey mono">tick marks your goal</div>
<div id="grade-off"></div>
</div>
</div>
<p class="sr-only" aria-live="polite" id="live"></p>
</section>
<section class="card" aria-labelledby="counts-h">
<div class="cardhead">
<h2 id="counts-h">Counts</h2>
<span class="note mono" id="readtime">—</span>
</div>
<div class="stats" id="stats"></div>
<div class="goalset">
<div class="trow">
<label for="goal">aim for grade</label>
<input type="range" id="goal" min="4" max="16" step="1" value="8" aria-describedby="passline">
<span class="tval mono" id="tval">8</span>
</div>
<div class="passbar" id="passbar"><i style="width:0%"></i></div>
<div class="passline" id="passline"></div>
</div>
</section>
</div>
<section class="card worst" aria-labelledby="worst-h">
<div class="cardhead">
<h2 id="worst-h">Hardest sentence</h2>
<span class="note mono">highest grade level</span>
</div>
<blockquote id="worst-text">—</blockquote>
<div class="mini" id="worst-mini"></div>
<p class="why" id="worst-why"></p>
<div class="row" style="margin-top:11px">
<button class="btn" id="worst-jump" type="button">select it in the text ↓</button>
</div>
</section>
<section class="card" aria-labelledby="text-h">
<div class="cardhead">
<h2 id="text-h">Your text</h2>
<span class="note">click any sentence to inspect it</span>
</div>
<div class="row tight" role="group" aria-label="Example texts">
<span class="k-lab" style="margin-right:2px">examples</span>
<button class="btn" type="button" data-sample="mixed" aria-pressed="true">contract + plain rewrite</button>
<button class="btn" type="button" data-sample="rain" aria-pressed="false">how rain works</button>
<button class="btn" type="button" data-sample="long" aria-pressed="false">one very long sentence</button>
<button class="btn" type="button" data-sample="plain" aria-pressed="false">short and plain</button>
</div>
<div class="row tight" style="margin-top:9px" role="group" aria-label="Shade sentences by">
<span class="k-lab" style="margin-right:2px">shade by</span>
<button class="btn" type="button" data-mode="ease" aria-pressed="true">reading ease</button>
<button class="btn" type="button" data-mode="length" aria-pressed="false">sentence length</button>
<button class="btn" type="button" data-mode="syll" aria-pressed="false">syllables per word</button>
</div>
<label class="sr-only" for="ta">Your text. Move the caret into a sentence to inspect it.</label>
<div class="stack">
<div class="mirror" id="mirror" aria-hidden="true"></div>
<textarea id="ta" class="editor" spellcheck="false" autocomplete="off"></textarea>
</div>
<div class="legend" id="legend"></div>
<div class="stripwrap">
<div class="k-lab" id="strip-lab">sentence profile · reading ease · taller = harder</div>
<div class="strip" id="strip" role="group" aria-label="Sentence profile: select a sentence"></div>
<div class="striplabels"><span>first sentence</span><span>last sentence</span></div>
</div>
<div class="det" id="det"></div>
</section>
<section class="card" aria-labelledby="lab-h">
<div class="cardhead">
<h2 id="lab-h">Syllable counter</h2>
<span class="note">the shakiest part of the whole score</span>
</div>
<p class="why" style="margin-top:0">Both formulas need a syllable count, and there is no exact rule for
English — only guesswork over vowel groups. Here is the guess this page makes, step by step,
plus every word it is known to get wrong.</p>
<div class="labgrid" style="margin-top:12px">
<div>
<label class="k-lab" for="probe">try a word</label>
<input id="probe" class="probe" type="text" value="readability" autocomplete="off" spellcheck="false">
<div class="row tight" style="margin-top:9px" role="group" aria-label="Example words">
<button class="btn" type="button" data-word="queue">queue</button>
<button class="btn" type="button" data-word="science">science</button>
<button class="btn" type="button" data-word="video">video</button>
<button class="btn" type="button" data-word="business">business</button>
<button class="btn" type="button" data-word="wishes">wishes</button>
<button class="btn" type="button" data-word="wanted">wanted</button>
<button class="btn" type="button" data-word="little">little</button>
<button class="btn" type="button" data-word="notwithstanding">notwithstanding</button>
<button class="btn" type="button" data-word="17">17</button>
</div>
</div>
<div>
<div class="wordview" id="wordview"></div>
<ul class="steps" id="steps"></ul>
</div>
</div>
<div style="margin-top:14px; border-top: 1px dashed var(--line); padding-top: 12px">
<p class="why" style="margin-top:0" id="acc-line"></p>
<button class="btn" type="button" id="miss-toggle" aria-expanded="false" aria-controls="miss-wrap">show the misses</button>
<div id="miss-wrap" hidden></div>
</div>
</section>
<details class="method">
<summary>how the score is worked out — and where it lies to you</summary>
<div class="body">
<p>Two formulas from the 1940s–70s, both built from the same three counts: words, sentences, syllables.</p>
<div class="formula">reading ease = 206.835 − 1.015 × (words / sentences) − 84.6 × (syllables / words)
grade level = 0.39 × (words / sentences) + 11.8 × (syllables / words) − 15.59</div>
<p>That is the whole of it. <b>Neither formula reads anything.</b> They count length and nothing else,
so a sentence of short familiar words scores well even if it is nonsense, and a short sentence of
long precise words scores badly even if it is perfectly clear. Treat the number as a smoke alarm,
not a judge.</p>
<p>Reading ease is meant to land between 0 and 100, but the formula has no ceiling or floor: very short
plain sentences overshoot 100, and very long ones go negative. The dials clamp; the numbers do not.</p>
<p><b>Syllables are estimated, not counted.</b> The rule: lowercase the word, drop a silent
<code>-e</code> / <code>-es</code> / <code>-ed</code> ending, drop a leading <code>y</code>, then count
runs of <code>a e i o u y</code>. One patch on top: a word ending in consonant + vowel + <code>-ing</code>
(<code>be·ing</code>, <code>go·ing</code>) gets one more. A word containing digits is scored as
one syllable per digit. Against a built-in list of hand-counted words the rule is right about four times in
five — the exact score and the full list of failures are in the panel above. Words on that list use the
hand count; everything else uses the rule.</p>
<p><b>Sentence splitting is a guess too.</b> The page breaks on <code>.</code> <code>!</code> <code>?</code>
followed by whitespace, then unbreaks the obvious traps: common abbreviations, single-letter initials,
decimals, and a full stop followed by a lowercase word. Semicolons do not split, so a semicolon-spliced
sentence is scored as the one long sentence it really is.</p>
<p>Everything here runs in this page: no network requests, no storage, no analytics. Your text stays in the tab.</p>
</div>
</details>
<footer>demo · sloprun.dev</footer>
</div>
<script>
(function () {
"use strict";
var $ = function (id) { return document.getElementById(id); };
function esc(s) {
return String(s).replace(/[&<>]/g, function (c) {
return c === "&" ? "&" : c === "<" ? "<" : ">";
});
}
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
/* ------------------------------------------------------------------ *
* sample texts
* ------------------------------------------------------------------ */
var SAMPLES = {
mixed:
"In the event that the subscriber elects to discontinue the aforementioned service prior to the conclusion " +
"of the initial commitment period, an early termination adjustment, calculated in accordance with the schedule " +
"appearing in the attached appendix, shall be assessed and subsequently applied to the final invoice, " +
"notwithstanding any promotional consideration previously extended at the time of enrollment. Notification of " +
"such discontinuation must be submitted in writing and will be considered effective upon receipt rather than " +
"upon transmission.\n\n" +
"You can cancel whenever you like. If you cancel in the first year, we add a fee to your last bill. The fee " +
"gets smaller every month you stay with us. Your welcome discount does not change it. Send us a note to cancel, " +
"and the clock starts when we read it, not when you send it.",
rain:
"Warm air holds more water than cool air. When a parcel of air rises, it expands and cools, and the water it " +
"carries begins to condense on tiny specks of dust. Those droplets are far too small to fall on their own, so " +
"they drift and collide, merging into bigger drops. Once a drop grows heavy enough to beat the updraft holding " +
"it, it falls, and we call it rain.",
long:
"Although the editorial board had, after several postponements occasioned by scheduling conflicts among its members, " +
"resolved to publish the revised style handbook before the end of the quarter, the volume of feedback received during " +
"the review window, much of which addressed matters that had been considered settled at an earlier stage, " +
"made it necessary to defer publication until such time as a consolidated response could be prepared, edited, " +
"and signed off.",
plain:
"The sun came up. The boat rocked once and was still. He watched the line go slack and then draw tight. " +
"He did not speak. The fish was there, deep and slow, and he could feel it think."
};
/* ------------------------------------------------------------------ *
* hand-counted words. Used as ground truth for the accuracy check AND
* as a lookup when scoring text, so the honest miss list is not a lie.
* ------------------------------------------------------------------ */
var LEX = {
one:1,two:1,three:1,four:1,five:1,six:1,seven:2,eight:1,nine:1,ten:1,
year:1,years:1,queue:1,quiet:2,quite:1,science:2,poem:2,area:3,idea:3,
being:2,going:2,doing:2,create:2,created:3,creating:3,creation:3,
beautiful:3,wanted:2,played:1,wishes:2,boxes:2,makes:1,
table:2,little:2,whole:1,style:1,mile:1,simple:2,people:2,business:2,
because:2,before:2,above:2,again:2,water:2,over:2,other:2,under:2,never:2,ever:2,
agreement:3,condition:3,conditional:4,information:4,situation:4,education:4,
organization:5,communication:5,about:2,around:2,between:2,however:3,therefore:2,whenever:3,
rhythm:2,someone:2,anyone:3,nothing:2,something:2,
change:1,changed:1,changes:2,moved:1,moves:1,moving:2,
service:2,services:3,customer:3,invoice:2,payment:2,monthly:2,cancel:2,contract:2,promise:2,
sentence:2,syllable:3,reading:2,readable:3,writing:2,written:2,language:2,grammar:2,paragraph:3,
measure:2,measured:2,estimate:3,approximate:4,heuristic:3,
maybe:2,only:2,also:2,always:2,often:2,sometimes:2,
through:1,thought:1,tough:1,laughed:1,though:1,
onion:2,union:2,opinion:3,special:2,social:2,usual:3,
piano:3,radio:3,video:3,audio:3,studio:3,lion:2,diet:2,poet:2,
apple:2,purple:2,circle:2,middle:2,handle:2,
happen:2,happened:2,listen:2,listened:2,open:2,opened:2,
label:2,level:2,model:2,total:2,local:2,
finally:3,similar:3,computer:3,together:3,remember:3,another:3,
technology:4,category:4,awake:2,alone:2,early:2
};
/* ------------------------------------------------------------------ *
* syllable estimate — vowel-group heuristic, steps exposed for display
* ------------------------------------------------------------------ */
function ruleSteps(raw) {
var w = String(raw).toLowerCase().replace(/[^a-z]/g, "");
var out = { word: w, kept: w, dropped: "", droppedWhy: "", leadY: false, groups: [], bump: false, count: 0, tiny: false };
if (!w) { return out; }
if (w.length <= 3) { out.count = 1; out.tiny = true; return out; }
var t = w;
if (/ed$/.test(t) && !/[td]ed$/.test(t) && !/[aeiouy]ed$/.test(t)) {
t = t.slice(0, -2); out.droppedWhy = "silent -ed";
} else if (/es$/.test(t) && !/(?:[sxzcg]|ch|sh)es$/.test(t) && !/[aeiouy]es$/.test(t)) {
t = t.slice(0, -2); out.droppedWhy = "silent -es";
} else if (/e$/.test(t) && !/[^aeiouy]le$/.test(t) && !/[aeiouy]e$/.test(t)) {
t = t.slice(0, -1); out.droppedWhy = "silent -e";
}
out.kept = t;
out.dropped = w.slice(t.length);
var body = t, shift = 0;
if (/^y/.test(body)) { out.leadY = true; body = body.slice(1); shift = 1; }
var re = /[aeiouy]+/g, m;
while ((m = re.exec(body)) !== null) {
out.groups.push({ i: m.index + shift, len: m[0].length, txt: m[0] });
}
var n = out.groups.length || 1;
if (/[^aeiouy][aeiou]ing$/.test(w)) { out.bump = true; n += 1; }
out.count = Math.max(1, n);
return out;
}
function ruleSyllables(w) { return ruleSteps(w).count; }
function syllables(word) {
if (/\d/.test(word)) { return Math.max(1, (word.match(/\d/g) || []).length); }
var k = String(word).toLowerCase().replace(/[^a-z]/g, "");
if (!k) { return 1; }
if (Object.prototype.hasOwnProperty.call(LEX, k)) { return LEX[k]; }
return ruleSyllables(k);
}
/* accuracy of the bare rule against the hand counts */
var ACC = (function () {
var total = 0, right = 0, misses = [];
for (var w in LEX) {
if (!Object.prototype.hasOwnProperty.call(LEX, w)) { continue; }
total++;
var r = ruleSyllables(w);
if (r === LEX[w]) { right++; } else { misses.push({ w: w, rule: r, hand: LEX[w] }); }
}
misses.sort(function (a, b) { return a.w < b.w ? -1 : 1; });
return { total: total, right: right, misses: misses, pct: Math.round((right / total) * 100) };
})();
/* ------------------------------------------------------------------ *
* sentence splitting
* ------------------------------------------------------------------ */
var ABBR = { mr:1,mrs:1,ms:1,dr:1,prof:1,st:1,jr:1,sr:1,vs:1,etc:1,eg:1,ie:1,fig:1,no:1,
inc:1,ltd:1,co:1,approx:1,est:1,al:1,dept:1,ed:1,vol:1,pp:1 };
function isAbbrev(text, dot) {
var i = dot - 1, s = "";
while (i >= 0 && /[A-Za-z.]/.test(text.charAt(i))) { s = text.charAt(i) + s; i--; }
s = s.replace(/\./g, "").toLowerCase();
if (!s) { return false; }
if (s.length === 1) { return true; }
return Object.prototype.hasOwnProperty.call(ABBR, s);
}
function splitSentences(text) {
var out = [], i = 0, n = text.length, TERM = ".!?", CLOSE = "\")']’”»";
while (i < n) {
while (i < n && /\s/.test(text.charAt(i))) { i++; }
if (i >= n) { break; }
var start = i, j = i, end = -1;
while (j < n) {
var c = text.charAt(j);
if (TERM.indexOf(c) >= 0) {
var k = j + 1;
while (k < n && TERM.indexOf(text.charAt(k)) >= 0) { k++; }
while (k < n && CLOSE.indexOf(text.charAt(k)) >= 0) { k++; }
if (k >= n || /\s/.test(text.charAt(k))) {
if (c === "." && isAbbrev(text, j)) { j = k; continue; }
var m = k;
while (m < n && /\s/.test(text.charAt(m))) { m++; }
if (c === "." && m < n && /[a-z]/.test(text.charAt(m))) { j = k; continue; }
end = k; break;
}
j = k; continue;
}
j++;
}
if (end < 0) { end = n; }
while (end > start && /\s/.test(text.charAt(end - 1))) { end--; }
if (end > start) { out.push({ start: start, end: end }); }
i = end > start ? end : start + 1;
}
return out;
}
/* ------------------------------------------------------------------ *
* scoring
* ------------------------------------------------------------------ */
var WORD_RE = /[A-Za-z0-9]+(?:['’\-][A-Za-z0-9]+)*/g;
function wordsOf(s) { return s.match(WORD_RE) || []; }
function ease(W, S, SY) { return 206.835 - 1.015 * (W / S) - 84.6 * (SY / W); }
function grade(W, S, SY) { return 0.39 * (W / S) + 11.8 * (SY / W) - 15.59; }
function analyze(text) {
var ranges = splitSentences(text), sents = [], W = 0, SY = 0, LONG = 0;
for (var i = 0; i < ranges.length; i++) {
var r = ranges[i], t = text.slice(r.start, r.end), ws = wordsOf(t), sy = 0, lw = 0;
for (var k = 0; k < ws.length; k++) {
var c = syllables(ws[k]);
sy += c;
if (c >= 3) { lw++; }
}
var wc = ws.length;
/* a "sentence" of pure punctuation ("...", "!!") has no words: it would
divide by zero downstream and score a green "all clear". Skip it. */
if (!wc) { continue; }
sents.push({
start: r.start, end: r.end, text: t, wc: wc, sy: sy, longw: lw,
spw: wc ? sy / wc : 0,
ease: wc ? ease(wc, 1, sy) : 0,
grade: wc ? grade(wc, 1, sy) : 0
});
W += wc; SY += sy; LONG += lw;
}
var SEN = sents.length;
return {
sents: sents, W: W, SY: SY, SEN: SEN, LONG: LONG,
ease: (W && SEN) ? ease(W, SEN, SY) : null,
grade: (W && SEN) ? grade(W, SEN, SY) : null,
wps: SEN ? W / SEN : 0,
spw: W ? SY / W : 0,
longPct: W ? (LONG / W) * 100 : 0
};
}
/* ------------------------------------------------------------------ *
* shading modes
* ------------------------------------------------------------------ */
var MODES = {
ease: {
strip: "sentence profile · reading ease · taller = harder",
band: function (s) { var e = s.ease; return e >= 70 ? 0 : e >= 60 ? 1 : e >= 50 ? 2 : e >= 30 ? 3 : 4; },
diff: function (s) { return clamp((110 - clamp(s.ease, -20, 110)) / 130, 0, 1); },
names: ["very easy", "easy", "fair", "hard", "very hard"],
cuts: ["70+", "60–70", "50–60", "30–50", "under 30"]
},
length: {
strip: "sentence profile · words per sentence · taller = longer",
band: function (s) { var w = s.wc; return w <= 14 ? 0 : w <= 20 ? 1 : w <= 28 ? 2 : w <= 40 ? 3 : 4; },
diff: function (s) { return clamp(s.wc / 50, 0, 1); },
names: ["short", "medium", "longish", "long", "very long"],
cuts: ["≤14 w", "15–20 w", "21–28 w", "29–40 w", "41+ w"]
},
syll: {
strip: "sentence profile · syllables per word · taller = longer words",
band: function (s) { var v = s.spw; return v <= 1.4 ? 0 : v <= 1.6 ? 1 : v <= 1.8 ? 2 : v <= 2.0 ? 3 : 4; },
diff: function (s) { return clamp((s.spw - 1) / 1.4, 0, 1); },
names: ["plain words", "mostly plain", "mixed", "long words", "very long words"],
cuts: ["≤1.40", "≤1.60", "≤1.80", "≤2.00", "over 2.00"]
}
};
var EASE_BANDS = [
[90, "very easy"], [80, "easy"], [70, "fairly easy"], [60, "plain english"],
[50, "fairly hard"], [30, "hard"], [-1e9, "very hard"]
];
function easeBandName(e) {
for (var i = 0; i < EASE_BANDS.length; i++) { if (e >= EASE_BANDS[i][0]) { return EASE_BANDS[i][1]; } }
return "very hard";
}
/* ------------------------------------------------------------------ *
* state + elements
* ------------------------------------------------------------------ */
var state = { text: "", mode: "ease", goal: 8, sel: 0, A: null, worst: -1 };
var ta = $("ta"), mirror = $("mirror"), strip = $("strip"), det = $("det");
var liveTimer = null;
function fmt(n, d) { return (Math.round(n * Math.pow(10, d)) / Math.pow(10, d)).toFixed(d); }
function plural(n, one, many) { return n + " " + (n === 1 ? one : many); }
/* ---------- mirror ---------- */
function buildMirror(A) {
var text = state.text, html = "", pos = 0;
var mode = MODES[state.mode];
for (var i = 0; i < A.sents.length; i++) {
var s = A.sents[i];
if (s.start > pos) { html += esc(text.slice(pos, s.start)); }
var cls = "s b" + mode.band(s);
if (s.grade > state.goal) { cls += " over"; }
if (i === state.sel) { cls += " sel"; }
html += '<span class="' + cls + '" data-i="' + i + '">' + esc(s.text) + "</span>";
pos = s.end;
}
html += esc(text.slice(pos)) + "\n ";
mirror.innerHTML = html;
}
/* ---------- dials ---------- */
function setArc(el, frac) {
el.setAttribute("stroke-dashoffset", String(100 - clamp(frac, 0, 1) * 100));
}
function renderDials(A) {
var offE = $("ease-off"), offG = $("grade-off");
if (A.ease === null) {
$("ease-num").textContent = "—"; $("grade-num").textContent = "—";
$("ease-band").innerHTML = " "; $("grade-band").innerHTML = " ";
setArc($("arc-ease"), 0); setArc($("arc-grade"), 0);
offE.innerHTML = ""; offG.innerHTML = "";
var nz = $("grade-notch");
nz.setAttribute("x1", "0"); nz.setAttribute("y1", "0");
nz.setAttribute("x2", "0"); nz.setAttribute("y2", "0");
return;
}
$("ease-num").textContent = String(Math.round(A.ease));
setArc($("arc-ease"), clamp(A.ease, 0, 100) / 100);
$("ease-band").textContent = easeBandName(A.ease);
offE.innerHTML = (A.ease > 100 || A.ease < 0)
? '<span class="offscale">off the 0–100 scale</span>' : "";
$("grade-num").textContent = fmt(A.grade, 1);
setArc($("arc-grade"), clamp(A.grade, 0, 18) / 18);
var g = A.grade;
$("grade-band").textContent = g < 6 ? "primary school" : g < 9 ? "middle school"
: g < 13 ? "high school" : g < 17 ? "degree level" : "specialist";
offG.innerHTML = (g > 18 || g < 0) ? '<span class="offscale">past the end of the dial</span>' : "";
var th = Math.PI * (1 - clamp(state.goal, 0, 18) / 18), nx = Math.cos(th), ny = Math.sin(th);
var notch = $("grade-notch");
notch.setAttribute("x1", fmt(60 + 41 * nx, 2)); notch.setAttribute("y1", fmt(60 - 41 * ny, 2));
notch.setAttribute("x2", fmt(60 + 55 * nx, 2)); notch.setAttribute("y2", fmt(60 - 55 * ny, 2));
}
/* ---------- counts ---------- */
function renderStats(A) {
var cells = [
[String(A.W), "words"],
[String(A.SEN), "sentences"],
[String(A.SY), "syllables"],
[A.SEN ? fmt(A.wps, 1) : "—", "words / sent"],
[A.W ? fmt(A.spw, 2) : "—", "syll / word"],
[A.W ? (fmt(A.longPct, 0) + "%") : "—", "3+ syll words"]
];
var html = "";
for (var i = 0; i < cells.length; i++) {
html += '<div class="stat"><span class="v">' + esc(cells[i][0]) + '</span><span class="k">' + esc(cells[i][1]) + "</span></div>";
}
$("stats").innerHTML = html;
var mins = A.W ? Math.max(1, Math.round(A.W / 238)) : 0;
$("readtime").textContent = A.W ? ("~" + mins + " min read") : "—";
$("scope-note").textContent = A.SEN === 1 ? "1 sentence" : A.SEN + " sentences";
}
/* ---------- grade goal / pass ---------- */
function renderTarget(A) {
var pass = 0;
for (var i = 0; i < A.sents.length; i++) { if (A.sents[i].grade <= state.goal) { pass++; } }
var n = A.sents.length;
var bar = $("passbar");
bar.firstChild.style.width = (n ? (pass / n) * 100 : 0) + "%";
if (n && pass === n) { bar.classList.add("all"); } else { bar.classList.remove("all"); }
var line = $("passline");
if (!n) { line.innerHTML = '<span>no sentences yet</span>'; return; }
var html = "<span>" + pass + " of " + plural(n, "sentence", "sentences") +
" at or below grade " + state.goal + "</span>";
html += (pass === n)
? ' <span class="pill ok">all clear</span>'
: ' <span class="pill warn">' + (n - pass) + " above goal</span>";
line.innerHTML = html;
}
/* ---------- worst sentence ---------- */
function findWorst(A) {
var best = -1, bestG = -Infinity, i, s;
for (i = 0; i < A.sents.length; i++) {
s = A.sents[i];
if (s.wc >= 6 && s.grade > bestG) { bestG = s.grade; best = i; }
}
if (best < 0) {
for (i = 0; i < A.sents.length; i++) {
s = A.sents[i];
if (s.grade > bestG) { bestG = s.grade; best = i; }
}
}
return best;
}
function whyHard(s) {
var bits = [];
if (s.wc > 40) { bits.push("It runs <b>" + s.wc + " words</b> without a full stop — most readers lose the thread well before that."); }
else if (s.wc > 25) { bits.push("At <b>" + s.wc + " words</b> it asks you to hold a lot at once."); }
if (s.spw >= 2.0) { bits.push("Its words average <b>" + fmt(s.spw, 2) + " syllables</b>, which is Latinate-heavy territory."); }
else if (s.spw >= 1.75) { bits.push("Its words average <b>" + fmt(s.spw, 2) + " syllables</b> — longer than everyday speech."); }
if (!bits.length) {
bits.push("Short words, short span: <b>" + plural(s.wc, "word", "words") + "</b> at <b>" +
fmt(s.spw, 2) + "</b> syllables each. Nothing here to trip on.");
}
var commas = (s.text.match(/,/g) || []).length;
if (commas >= 2 && s.wc > 25) {
var parts = s.text.split(",").map(function (p) { return wordsOf(p).length; }).filter(function (v) { return v > 0; });
if (parts.length >= 3) {
bits.push("Split at its commas it would give chunks of <b>" + parts.join(" · ") + "</b> words.");
}
}
return bits.join(" ");
}
function miniHTML(s) {
return '<span>words <b>' + s.wc + "</b></span>" +
'<span>syllables <b>' + s.sy + "</b></span>" +
'<span>syll/word <b>' + fmt(s.spw, 2) + "</b></span>" +
'<span>ease <b>' + Math.round(s.ease) + "</b></span>" +
'<span>grade <b>' + fmt(s.grade, 1) + "</b></span>";
}
function renderWorst(A) {
var i = state.worst;
if (i < 0) {
$("worst-text").textContent = "—";
$("worst-mini").innerHTML = "";
$("worst-why").textContent = "Add some text and the hardest sentence shows up here.";
$("worst-jump").disabled = true;
return;
}
var s = A.sents[i];
$("worst-jump").disabled = false;
$("worst-text").textContent = s.text;
$("worst-mini").innerHTML = miniHTML(s);
$("worst-why").innerHTML = "Sentence " + (i + 1) + " of " + A.SEN + ". " + whyHard(s);
}
/* ---------- legend ---------- */
function renderLegend() {
var m = MODES[state.mode], html = "";
for (var i = 0; i < 5; i++) {
html += '<span class="lg"><span class="sw b' + i + '"></span>' + esc(m.names[i]) +
' <span style="opacity:.75">' + esc(m.cuts[i]) + "</span></span>";
}
html += '<span class="lg"><span class="sw over"></span>above grade ' + state.goal + "</span>";
$("legend").innerHTML = html;
$("strip-lab").textContent = m.strip;
}
/* ---------- strip ---------- */
function renderStrip(A) {
var m = MODES[state.mode], n = A.sents.length, i;
var hadFocus = strip.contains(document.activeElement);
if (strip.children.length !== n) {
var html = "";
for (i = 0; i < n; i++) { html += '<button type="button" data-i="' + i + '" tabindex="-1"><i></i></button>'; }
strip.innerHTML = html;
}
for (i = 0; i < n; i++) {
var s = A.sents[i], b = m.band(s), btn = strip.children[i], bar = btn.firstChild;
bar.className = "b" + b;
bar.style.height = (10 + m.diff(s) * 90) + "%";
btn.setAttribute("aria-label", "Sentence " + (i + 1) + " of " + n + ": " + s.wc +
" words, grade " + fmt(s.grade, 1) + ", " + m.names[b]);
btn.setAttribute("aria-pressed", i === state.sel ? "true" : "false");
btn.tabIndex = i === state.sel ? 0 : -1;
}
if (hadFocus && strip.children[state.sel]) { strip.children[state.sel].focus(); }
}
/* ---------- selected sentence ---------- */
function renderDetail(A) {
if (!A.SEN) {
det.innerHTML = '<p class="why" style="margin:0">Nothing to score yet — type or paste something above.</p>';
return;
}
var i = clamp(state.sel, 0, A.SEN - 1), s = A.sents[i], m = MODES[state.mode], b = m.band(s);
var ok = s.grade <= state.goal;
var html = '<div class="dethead"><span class="k-lab">selected</span>' +
'<span class="mono" style="font-size:.8rem">sentence ' + (i + 1) + " / " + A.SEN + "</span>" +
'<span class="pill' + (b >= 2 ? " warn" : "") + '">' + esc(m.names[b]) + "</span>" +
(ok ? '<span class="pill ok">meets grade ' + state.goal + "</span>"
: '<span class="pill">grade ' + fmt(s.grade, 1) + " · above goal</span>") +
"</div>";
html += "<blockquote>" + esc(s.text) + "</blockquote>";
html += '<div class="mini">' + miniHTML(s) + "</div>";
html += '<p class="why">' + whyHard(s) + "</p>";
det.innerHTML = html;
}
/* ---------- selection without a full rebuild ---------- */
function applySel() {
var A = state.A;
if (!A) { return; }
var spans = mirror.querySelectorAll("span.s"), i;
for (i = 0; i < spans.length; i++) { spans[i].classList.remove("sel"); }
if (spans[state.sel]) { spans[state.sel].classList.add("sel"); }
for (i = 0; i < strip.children.length; i++) {
strip.children[i].setAttribute("aria-pressed", i === state.sel ? "true" : "false");
strip.children[i].tabIndex = i === state.sel ? 0 : -1;
}
renderDetail(A);
}
function caretSel(A) {
var p = ta.selectionStart || 0, idx = 0;
for (var i = 0; i < A.sents.length; i++) { if (A.sents[i].start <= p) { idx = i; } }
return idx;
}
function announce(A) {
if (liveTimer) { clearTimeout(liveTimer); }
liveTimer = setTimeout(function () {
if (!A.SEN) { $("live").textContent = "No text to score."; return; }
var over = 0;
for (var i = 0; i < A.sents.length; i++) { if (A.sents[i].grade > state.goal) { over++; } }
$("live").textContent = "Reading ease " + Math.round(A.ease) + ", grade " + fmt(A.grade, 1) +
", " + A.SEN + " sentences, " + over + " above grade " + state.goal + ".";
}, 700);
}
/* ---------- full render ---------- */
function render(keepSel) {
var A = analyze(state.text);
state.A = A;
state.worst = findWorst(A);
if (!A.SEN) { state.sel = 0; }
else if (keepSel === "caret") { state.sel = caretSel(A); }
else if (keepSel === "worst") { state.sel = Math.max(0, state.worst); }
else { state.sel = clamp(state.sel, 0, A.SEN - 1); }
buildMirror(A);
renderDials(A);
renderStats(A);
renderTarget(A);
renderWorst(A);
renderLegend();
renderStrip(A);
renderDetail(A);
announce(A);
}
/* ------------------------------------------------------------------ *
* syllable lab
* ------------------------------------------------------------------ */
function renderProbe(raw) {
var first = (String(raw).match(/[A-Za-z0-9][A-Za-z0-9'’\-]*/) || [""])[0];
var view = $("wordview"), steps = $("steps");
if (!first) {
view.innerHTML = '<span style="color:var(--muted);font-size:.9rem">type a word</span>';
steps.innerHTML = "";
return;
}
/* anything with a digit never reaches the vowel rule — show that path honestly */
if (/\d/.test(first)) {
var nd = Math.max(1, (first.match(/\d/g) || []).length);
view.innerHTML = '<span class="vg">' + esc(first) + "</span>";
steps.innerHTML = "<li>this contains a digit, so the vowel rule never runs</li>" +
"<li>one syllable per digit → <b>" + nd + "</b></li>" +
'<li class="miss">a crude stand-in: <b>17</b> scores 2, but "seventeen" is spoken as 3</li>';
return;
}
var st = ruleSteps(first), w = st.word;
var hand = Object.prototype.hasOwnProperty.call(LEX, w) ? LEX[w] : null;
/* word with vowel groups marked */
var marks = new Array(st.kept.length), gi;
for (gi = 0; gi < st.groups.length; gi++) {
for (var k = 0; k < st.groups[gi].len; k++) { marks[st.groups[gi].i + k] = 1; }
}
var html = "", run = "", cur = 0, ci;
if (st.tiny) {
html = esc(w);
} else {
for (ci = 0; ci <= st.kept.length; ci++) {
var isV = ci < st.kept.length ? (marks[ci] ? 1 : 0) : -1;
if (isV !== cur || ci === st.kept.length) {
if (run) { html += cur ? '<span class="vg">' + esc(run) + "</span>" : esc(run); }
run = ""; cur = isV;
}
if (ci < st.kept.length) { run += st.kept.charAt(ci); }
}
if (st.dropped) { html += '<span class="dropped">' + esc(st.dropped) + "</span>"; }
}
view.innerHTML = html;
var li = [];
if (st.tiny) {
li.push("<b>" + esc(w) + "</b> is 3 letters or fewer → counted as <b>1</b> without looking");
} else {
if (st.droppedWhy) { li.push("dropped <b>" + esc(st.dropped) + "</b> (" + st.droppedWhy + ")"); }
if (st.leadY) { li.push("leading <b>y</b> ignored — it is a consonant here"); }
var gtxt = [];
for (gi = 0; gi < st.groups.length; gi++) { gtxt.push(st.groups[gi].txt); }
li.push("vowel runs: " + (gtxt.length ? "<b>" + esc(gtxt.join(" · ")) + "</b>" : "<b>none</b>, so 1 by default") +
" → <b>" + (gtxt.length || 1) + "</b>");
if (st.bump) { li.push("ends consonant + vowel + <b>-ing</b> → <b>+1</b>"); }
li.push("rule says <b>" + st.count + "</b> syllable" + (st.count === 1 ? "" : "s"));
}
if (hand !== null) {
if (hand === st.count) {
steps.innerHTML = "<li>" + li.join("</li><li>") + '</li><li class="ok">' +
"hand count agrees: <b>" + hand + "</b> — this word is on the check list</li>";
return;
}
steps.innerHTML = "<li>" + li.join("</li><li>") + '</li><li class="miss">hand count is <b>' + hand +
"</b> — the rule is wrong here, so this page uses <b>" + hand + "</b></li>";
return;
}
li.push("not on the check list, so <b>" + st.count + "</b> is what this page uses");
steps.innerHTML = "<li>" + li.join("</li><li>") + "</li>";
}
function renderAccuracy() {
$("acc-line").innerHTML = "Against the <b>" + ACC.total + "</b> hand-counted words baked into this page, " +
"the rule alone gets <b>" + ACC.right + "</b> right — <b>" + ACC.pct + "%</b>. " +
"The " + ACC.misses.length + " it misses are patched by hand; every other word in your text rides on the rule.";
var html = '<div class="tablescroll"><table class="misstable"><thead><tr>' +
"<th>word</th><th>rule</th><th>hand</th><th>miss</th></tr></thead><tbody>";
for (var i = 0; i < ACC.misses.length; i++) {
var m = ACC.misses[i], d = m.rule - m.hand;
html += "<tr><td>" + esc(m.w) + '</td><td class="rule">' + m.rule + '</td><td class="hand">' + m.hand +
"</td><td>" + (d > 0 ? "+" + d : d) + "</td></tr>";
}
html += "</tbody></table></div>";
$("miss-wrap").innerHTML = html;
}
/* ------------------------------------------------------------------ *
* events
* ------------------------------------------------------------------ */
function markSample(active) {
document.querySelectorAll("[data-sample]").forEach(function (o) {
o.setAttribute("aria-pressed", o === active ? "true" : "false");
});
}
ta.addEventListener("input", function () {
state.text = ta.value;
markSample(null); /* it is the visitor's text now, not a sample */
render("caret");
});
["click", "keyup", "select", "focus"].forEach(function (ev) {
ta.addEventListener(ev, function () {
if (!state.A || !state.A.SEN) { return; }
var i = caretSel(state.A);
if (i !== state.sel) { state.sel = i; applySel(); }
});
});
document.querySelectorAll("[data-sample]").forEach(function (b) {
b.addEventListener("click", function () {
state.text = SAMPLES[b.getAttribute("data-sample")];
ta.value = state.text;
try { ta.setSelectionRange(0, 0); } catch (e) { /* ignore */ }
markSample(b);
render("worst");
});
});
document.querySelectorAll("[data-mode]").forEach(function (b) {
b.addEventListener("click", function () {
state.mode = b.getAttribute("data-mode");
document.querySelectorAll("[data-mode]").forEach(function (o) {
o.setAttribute("aria-pressed", o === b ? "true" : "false");
});
render();
});
});
$("goal").addEventListener("input", function () {
state.goal = parseInt(this.value, 10);
$("tval").textContent = String(state.goal);
render();
});
strip.addEventListener("click", function (e) {
var btn = e.target.closest ? e.target.closest("button") : null;
if (!btn || !strip.contains(btn)) { return; }
selectSentence(parseInt(btn.getAttribute("data-i"), 10), false);
});
strip.addEventListener("keydown", function (e) {
var n = strip.children.length;
if (!n) { return; }
var i = state.sel, moved = true;
if (e.key === "ArrowRight" || e.key === "ArrowDown") { i = Math.min(n - 1, i + 1); }
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { i = Math.max(0, i - 1); }
else if (e.key === "Home") { i = 0; }
else if (e.key === "End") { i = n - 1; }
else { moved = false; }
if (!moved) { return; }
e.preventDefault();
selectSentence(i, false);
strip.children[i].focus();
});
function selectSentence(i, focusText) {
var A = state.A;
if (!A || !A.SEN) { return; }
var idx = clamp(i, 0, A.SEN - 1), s = A.sents[idx];
/* move the caret first: focusing fires the caret sync, which would otherwise
re-select whatever sentence the caret was sitting in before. */
try {
if (focusText) { ta.setSelectionRange(s.start, s.end); ta.focus(); ta.setSelectionRange(s.start, s.end); }
else { ta.setSelectionRange(s.start, s.start); }
} catch (e) { /* ignore */ }
state.sel = idx;
applySel();
}
$("worst-jump").addEventListener("click", function () {
if (state.worst < 0) { return; }
selectSentence(state.worst, true);
mirror.scrollIntoView({ block: "center" });
});
$("probe").addEventListener("input", function () { renderProbe(this.value); });
document.querySelectorAll("[data-word]").forEach(function (b) {
b.addEventListener("click", function () {
$("probe").value = b.getAttribute("data-word");
renderProbe($("probe").value);
});
});
$("miss-toggle").addEventListener("click", function () {
var open = this.getAttribute("aria-expanded") === "true";
this.setAttribute("aria-expanded", open ? "false" : "true");
this.textContent = open ? "show the misses" : "hide the misses";
$("miss-wrap").hidden = open;
});
/* platform theme */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
}
});
/* ------------------------------------------------------------------ *
* boot — never an empty form
* ------------------------------------------------------------------ */
state.text = SAMPLES.mixed;
ta.value = state.text;
render("worst");
renderProbe($("probe").value);
renderAccuracy();
})();
</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.