1import { BANK } from "./questions.js"; 2import { 3 LEVELS, QUESTIONS_PER_LEVEL, TOTAL_WORDS, LENGTHS, 4 estimate, feedbackLine, verdict, 5} from "./scoring.js"; 6 7const app = document.getElementById("app"); 8const LS_BEST = "vocabowl.best"; 9const LS_MASTERED = "vocabowl.mastered"; 10const REVEAL_MS = 1300; 11const LABELS = ["1", "2", "3", "4"]; 12const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; 13 14/* ---------- persistence ---------- */ 15const loadBest = () => parseInt(localStorage.getItem(LS_BEST) || "0", 10); 16const loadMastered = () => { 17 try { 18 const a = JSON.parse(localStorage.getItem(LS_MASTERED) || "[]"); 19 return new Set(Array.isArray(a) ? a.map(String) : []); 20 } catch { return new Set(); } 21}; 22const saveMastered = (s) => localStorage.setItem(LS_MASTERED, JSON.stringify([...s])); 23 24/* ---------- utils ---------- */ 25const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ( 26 { "&": "&", "<": "<", ">": ">", '"': """ }[c])); 27function shuffle(a) { 28 for (let i = a.length - 1; i > 0; i--) { 29 const j = Math.floor(Math.random() * (i + 1)); 30 [a[i], a[j]] = [a[j], a[i]]; 31 } 32 return a; 33} 34 35/* ---------- state ---------- */ 36let mastered = loadMastered(); 37let state = null; 38let timer = null; 39 40function buildRun(perLevel) { 41 const run = []; 42 for (const lvl of LEVELS) { 43 const fresh = BANK[lvl.id].filter((q) => !mastered.has(q.word)); 44 const pool = fresh.length >= perLevel ? fresh : BANK[lvl.id]; 45 const pick = shuffle(pool.slice()).slice(0, perLevel); 46 for (const q of pick) { 47 const options = shuffle([q.correct, ...q.distractors]); 48 run.push({ 49 levelId: lvl.id, word: q.word, correct: q.correct, 50 options, correctIndex: options.indexOf(q.correct), 51 }); 52 } 53 } 54 return run; 55} 56 57function startGame(perLevel = QUESTIONS_PER_LEVEL) { 58 clearTimeout(timer); 59 state = { 60 screen: "quiz", 61 perLevel, 62 run: buildRun(perLevel), 63 idx: 0, 64 selected: null, 65 locked: false, 66 levelScores: new Array(LEVELS.length).fill(0), 67 results: [], // { word, correct, isRight } 68 newMastered: 0, 69 }; 70 renderQuiz(); 71} 72 73/* ---------- start screen ---------- */
74function renderStart() { 75 state = { screen: "start" }; 76 const best = loadBest(); 77 const total = mastered.size; 78 app.innerHTML = ` 79 <section class="screen center stack"> 80 ${LOGO} 81 <div> 82 <h1 class="title">Voca<span class="bowl">Bowl</span></h1> 83 <span class="tagline">A hearty bowl of words</span> 84 </div> 85 <p class="lede">English has roughly ${TOTAL_WORDS.toLocaleString()} words worth knowing. 86 Take a quick 100-word taste test and we'll estimate how many are in your bowl.</p> 87 <div class="chips"> 88 ${best ? chip("Best estimate", best.toLocaleString()) : ""} 89 ${total ? chip("Words mastered", total.toLocaleString()) : ""} 90 </div> 91 <div class="lengths" id="lengths"> 92 ${LENGTHS.map((l) => ` 93 <button class="length ${l.total === 100 ? "feature" : ""}" data-n="${l.perLevel}"> 94 <span class="n">${l.total}</span> 95 <span class="lab">${l.label}</span> 96 <span class="sub">${l.note}</span> 97 </button>`).join("")} 98 </div> 99 <p class="muted" style="font-size:.72rem;margin:0">Pick a length â fewer words is quicker but a rougher estimate.</p> 100 <button class="btn ghost" id="how">How does this work?</button> 101 <div class="feats"> 102 <span>â¨ï¸ Keyboard-friendly</span><span>ð Private & offline</span><span>𥣠No sign-up</span> 103 </div> 104 <p class="credit">Inspired by <a href="https://vocabowl-870366514258.us-west1.run.app/">VocabOwl</a> which was itself inspired by <em>Hannah Fry & Michael Stevens</em> talking on a podcast. Rebuilt and improved, keyboard-first.</p> 105 </section>`; 106 app.querySelectorAll(".length").forEach((el) => { 107 el.onclick = () => startGame(+el.dataset.n); 108 }); 109 app.querySelector("#how").onclick = openMethodology; 110} 111const chip = (k, v) => `<div class="chip"><div class="k">${k}</div><div class="v">${v}</div></div>`; 112 113/* ---------- quiz screen ---------- */ 114function renderQuiz() { 115 const q = state.run[state.idx]; 116 const lvl = LEVELS[q.levelId]; 117 const progress = (state.idx / state.run.length) * 100; 118 app.innerHTML = ` 119 <section class="screen"> 120 <div class="qhead"> 121 <div class="qrow"> 122 <span class="level-tag">Lvl ${q.levelId + 1} · ${esc(lvl.name)}</span> 123 <span class="count">${state.idx + 1} / ${state.run.length}</span> 124 </div> 125 <div class="bar"><i style="width:${progress}%"></i></div> 126 </div> 127 <div class="qbody"> 128 <p class="prompt">Which meaning fits this word?</p> 129 <div class="word-card"><div class="word">${esc(q.word)}</div></div> 130 <div class="options" id="options"> 131 ${q.options.map((opt, i) => ` 132 <button class="opt" data-i="${i}"> 133 <span class="badge">${LABELS[i]}</span><span>${esc(opt)}</span> 134 </button>`).join("")} 135 </div> 136 <p class="hint"><kbd>1</kbd>â<kbd>4</kbd> to pick · <kbd>Enter</kbd> to submit · 137 or double-click an answer to do both</p> 138 </div> 139 <div class="qfoot" id="foot"></div> 140 </section>`; 141 142 const opts = [...app.querySelectorAll(".opt")]; 143 opts.forEach((el) => { 144 const i = +el.dataset.i; 145 // tap to select; tap the selected option again (or double-click) to submit 146 el.onclick = () => { if (state.selected === i && !state.locked) submit(); else select(i); }; 147 el.ondblclick = () => { select(i); submit(); }; 148 }); 149} 150 151function select(i) { 152 if (state.locked) return; 153 state.selected = i; 154 app.querySelectorAll(".opt").forEach((el) => 155 el.classList.toggle("selected", +el.dataset.i === i)); 156} 157 158function submit() { 159 if (state.locked || state.selected == null) return; 160 const q = state.run[state.idx]; 161 const isRight = state.selected === q.correctIndex; 162 state.locked = true; 163 state.levelScores[q.levelId] += isRight ? 1 : 0; 164 state.results.push({ word: q.word, correct: q.correct, isRight }); 165 166 if (isRight && !mastered.has(q.word)) { 167 mastered.add(q.word); 168 state.newMastered++; 169 } 170 171 app.querySelectorAll(".opt").forEach((el) => { 172 const i = +el.dataset.i; 173 el.classList.add("locked"); 174 el.classList.remove("selected"); 175 if (i === q.correctIndex) el.classList.add("correct"); 176 else if (i === state.selected) el.classList.add("wrong"); 177 else el.classList.add("dim"); 178 }); 179 180 const phrase = isRight ? pick(GOOD) : pick(BAD); 181 const last = state.idx + 1 >= state.run.length; 182 app.querySelector("#foot").innerHTML = ` 183 <div class="feedback ${isRight ? "ok" : "no"}">
184 <span class="mark">${isRight ? "â" : "â"}</span> 185 <span> 186 <span>${phrase}</span> 187 ${isRight ? "" : `<br><span class="meaning">${esc(q.word)}: ${esc(q.correct)}</span>`} 188 </span> 189 </div> 190 <button class="btn next ${isRight ? "green" : ""}" id="next">${last ? "See results" : "Next"} â</button>`; 191 app.querySelector("#next").onclick = advance; 192 193 if (!reduceMotion) timer = setTimeout(advance, REVEAL_MS); 194} 195 196function advance() { 197 clearTimeout(timer); 198 if (!state.locked) return; 199 state.idx++; 200 state.selected = null; 201 state.locked = false; 202 if (state.idx >= state.run.length) finish(); 203 else renderQuiz(); 204} 205 206const GOOD = ["Correct.", "Right.", "Spot on.", "Exactly.", "Nicely done.", 207 "That's the one.", "Well judged.", "Yes, correct."]; 208const BAD = ["Not quite.", "Incorrect.", "Not that one.", "Close, but no.", 209 "Afraid not.", "Wide of the mark.", "Just missed it.", "Not this time."]; 210const pick = (a) => a[Math.floor(Math.random() * a.length)]; 211 212/* ---------- result screen ---------- */ 213function finish() { 214 saveMastered(mastered); 215 const words = estimate(state.levelScores, state.perLevel); 216 const prevBest = loadBest(); 217 const isBest = words > prevBest; 218 if (isBest) localStorage.setItem(LS_BEST, String(words)); 219 220 const v = verdict(words); 221 const misses = state.results.filter((r) => !r.isRight); 222 223 app.innerHTML = ` 224 <section class="screen stack"> 225 <div class="result-card"> 226 <div class="result-hero"> 227 ${isBest && prevBest >
227 0 ? `<span class="best-flag">New best</span>` : ""} 228 <div class="k">Your estimated vocabulary</div> 229 <div class="big" id="counter">0</div> 230 <div class="of">words · out of ~${TOTAL_WORDS.toLocaleString()}</div> 231 </div> 232 <div class="result-body stack"> 233 <p class="verdict-line">â${feedbackLine(words)}â</p> 234 ${state.newMastered ? `<div class="note">You mastered ${state.newMastered} new word${state.newMastered > 1 ? "s" : ""}.</div>` : ""} 235 <div class="card-tint a"> 236 <h4>The verdict</h4><p>${esc(v.profile)}</p> 237 </div> 238 <div class="card-tint b"> 239 <h4>On the menu next</h4><p>${esc(v.study)}</p> 240 </div> 241 <div class="breakdown"> 242 <h4>How you did, band by band</h4> 243 ${LEVELS.map((lvl, i) => { 244 const sc = state.levelScores[i]; 245 const pct = Math.round((sc / state.perLevel) * 100); 246 return `<div class="brow"> 247 <span class="nm">${esc(lvl.name)}</span> 248 <span class="track"><i style="width:${pct}%"></i></span> 249 <span class="sc">${sc}/${state.perLevel}</span> 250 </div>`; 251 }).join("")} 252 </div> 253 ${misses.length ? ` 254 <div class="review"> 255 <details> 256 <summary>Review ${misses.length} word${misses.length > 1 ? "s" : ""} you missed</summary> 257 ${misses.map((m) => `<div class="miss"> 258 <a class="w" href="https://en.wiktionary.org/wiki/${encodeURIComponent(m.word.toLowerCase())}#English" 259 target="_blank" rel="noopener noreferrer">${esc(m.word)}</a> 260 â <span class="d">${esc(m.correct)}</span> 261 </div>`).join("")} 262 </details> 263 </div>` : `<div class="note">Flawless â not a single word missed.</div>`} 264 <div class="lifetime">Lifetime words mastered: ${mastered.size.toLocaleString()}</div> 265 </div> 266 </div> 267 <div class="stack" style="width:100%"> 268 <button class="btn green" id="again">Taste ${state.run.length} again</button> 269 <button class="btn ghost" id="home">Pick a different length</button> 270 <button class="btn ghost" id="how">How is this calculated?</button> 271 </div> 272 </section>`; 273 274 const sameLength = state.perLevel; 275 app.querySelector("#again").onclick = () => startGame(sameLength); 276 app.querySelector("#home").onclick = renderStart; 277 app.querySelector("#how").onclick = openMethodology; 278 countUp(app.querySelector("#counter"), words); 279} 280 281function countUp(el, target) { 282 if (reduceMotion || target === 0) { el.textContent = target.toLocaleString(); return; } 283 const dur = 1400, t0 = performance.now(); 284 const step = (t) => { 285 const k = Math.min(1, (t - t0) / dur); 286 const eased = 1 - Math.pow(1 - k, 3); 287 el.textContent = Math.round(target * eased).toLocaleString(); 288 if (k < 1) requestAnimationFrame(step); 289 }; 290 requestAnimationFrame(step); 291} 292 293/* ---------- methodology modal ---------- */ 294function openMethodology() { 295 const wrap = document.createElement("div"); 296 wrap.className = "modal-bg"; 297 wrap.innerHTML = ` 298 <div class="modal" role="dialog" aria-modal="true" aria-label="How VocaBowl works"> 299 <header><h3>The recipe</h3><button class="x" aria-label="Close">â</button></header> 300 <div class="content"> 301 <h4>The bands</h4> 302 <p>We split English into five frequency bands and serve you an equal slice from each â you pick a 303 total of ${LENGTHS.map((l) => l.total).join(", ")} words. The bands are sized to add up to a round 304 <b>${TOTAL_WORDS.toLocaleString()}</b>, so a perfect run reads exactly that, whatever length you 305 choose. (The number you'll see is an <em>estimate of your active vocabulary</em>, not a count of 306 every word in the dictionary. Fewer questions means a quicker but rougher estimate.)</p> 307 <ul class="bands"> 308 ${LEVELS.map((l) => `<li><span>${l.id + 1}. ${esc(l.name)}</span><b>
308~${l.band.toLocaleString()}</b></li>`).join("")} 309 </ul> 310 <h4>The maths</h4> 311 <div class="formula">estimate = Σ (your accuracy in a band à that band's size)</div> 312 <p class="muted" style="font-size:.82rem">Get 70% of the Intermediate words right and we credit you 313 with roughly 70% of that band.</p> 314 <h4>The honest caveats</h4> 315 <ul> 316 <li>It's a quick taste test, not a lab measurement â stratified sampling is a rough heuristic.</li> 317 <li>Four choices means a lucky guess can land 1-in-4; treat the figure as a fun ballpark.</li> 318 <li>Definitions were re-curated and answer lengths rebalanced, so always picking the 319 longest option now scores about 67% â down from roughly 80% in the original, though 320 not zero.</li> 321 </ul> 322 <p class="credit">Concept inspired by the original <em>VocabOwl</em> and popular vocabulary-size chatter 323 from Prof. Hannah Fry & Michael Stevens.</p> 324 </div> 325 </div>`; 326 const close = () => { wrap.remove(); document.removeEventListener("keydown", onKey); }; 327 const onKey = (e) => { if (e.key === "Escape") close(); }; 328 wrap.onclick = (e) => { if (e.target === wrap) close(); }; 329 wrap.querySelector(".x").onclick = close; 330 document.addEventListener("keydown", onKey); 331 document.body.appendChild(wrap); 332 wrap.querySelector(".x").focus(); 333} 334 335/* ---------- global keyboard ---------- */ 336document.addEventListener("keydown", (e) => { 337 if (!state || state.screen !== "quiz") return; 338 if (document.querySelector(".modal-bg")) return; 339 const key = e.key.toLowerCase(); 340 341 if (state.locked) { 342 if (e.key === "Enter" || e.key === " ") { e.preventDefault(); advance(); } 343 return; 344 } 345 // letter or digit selects (and a repeat-press / Enter submits) 346 let idx = null; 347 if (/^[1-4]$/.test(key)) idx = +key - 1; 348 const q = state.run[state.idx]; 349 350 if (idx != null && idx < q.options.length) { 351 e.preventDefault(); 352 if (state.selected === idx) submit(); // press the same key twice to confirm 353 else select(idx); 354 return; 355 } 356 if (e.key === "Enter" || e.key === " ") { e.preventDefault(); submit(); } 357}); 358 359/* ---------- bowl logo ---------- */ 360const LOGO = ` 361<svg class="logo" viewBox="0 0 120 120" role="img" aria-label="VocaBowl"> 362 <g fill="none" stroke="#b9923b" stroke-width="4" stroke-linecap="round" opacity="0.8"> 363 <path d="M46 34c-5-5 1-10-4-15"/><path d="M62 34c-5-5 1-10-4-15"/><path d="M78 34c-5-5 1-10-4-15"/> 364 </g> 365 <path d="M16 56h88a44 44 0 0 1-88 0z" fill="#fff5ea" stroke="#e0762f" stroke-width="3"/> 366 <rect x="10" y="52" width="100" height="11" rx="5.5" fill="#e0762f"/> 367 <text x="38" y="86" font-family="Georgia, serif" font-size="22" font-weight="700" fill="#c2611f">V</text> 368 <text x="54" y="90" font-family="Georgia, serif" font-size="17" font-weight="700" fill="#b9923b">o</text> 369 <text x="68" y="85" font-family="Georgia, serif" font-size="22" font-weight="700" fill="#3f9b6b">B</text> 370</svg>`; 371 372/* ---------- boot ---------- */ 373renderStart();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.