PageSourceSearch

https://vocabowl.netlify.app/src/app.js

js vocabowl.netlify.app collected 2026-10-03 08:20:58 UTC 15,366 bytes, 373 lines download raw bytes

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  { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 &amp; 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 &amp; 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 &amp; 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.