Skip to main content
  1. Posts/

Verba — An Offline Latin Vocabulary Trainer on a USB Stick

· David Steeman · AI, DIY
Verba — An Offline Latin Vocabulary Trainer on a USB Stick

My son has to know 1051 Latin words. They come from the vocabulary list in the back of his school book, spread over seven caputs and 35 sections, and they arrive at the rate of a section or two per week. Flashcard apps exist, of course, but every one of them wants an account, a subscription, a phone in his hand and a network connection — three things I did not want in the same room as his homework.

So we built our own. It is called Verba, it is a single HTML file, and it lives on a USB stick. You double-click it and it works. No server, no installation, no internet, and — by design — not a single network request.

The Verba home screen

Why a single file
#

The constraint came first and everything else followed from it. One file that runs over file:// means no fetch(), no XMLHttpRequest, no service worker, no ES modules, no dynamic import(), no CDN, no web fonts. Vanilla HTML, CSS and JavaScript, icons as inline SVG, the system font stack. Everything that would normally be a separate file has to be inline instead: the finished app is about 900 KB, of which roughly 330 KB is the word data sitting in a <script> block and another 370 KB is the background image as a base64 data URI. Without that picture it is 410 KB.

That sounds like a limitation. In practice it is the feature. It cannot break because a server is down, it cannot nag, it cannot be blocked by school wifi, and it will still open in ten years. Progress lives in localStorage with a backup save/load to a JSON file, because localStorage over file:// works in Chrome and Firefox but is not something you want to bet a term’s revision on.

This is the second app in this shape. The first was FLUO, the same idea for the periodic table. Verba deliberately reuses its architecture, its learning engine and its gamification — if you know one, you know the other.

The word list is the hard part
#

Everything interesting about a vocabulary trainer is in the data, not the UI. A trainer with a wrong word list is worse than no trainer at all: it teaches the mistake, confidently, with a little green flash.

From the flatbed to markdown
#

There was no digital version of the list to start from, so I put the back of the book on the office scanner. That scanner does one thing with what it scans: it emails it. Out came a pile of PDF attachments spread over a series of messages, in scan order, which is not quite page order. I saved those emails as .msg files into a shared folder and pointed Claude Code at the folder.

From there it was one job: open the .msg files, pull the PDFs out of them, read the vocabulary pages, and write out every numbered entry as a markdown table — number, Latin word, genitive or second form, Dutch translation — grouped by caput and section, carrying the book’s own conventions along (~a, ~um for adjective endings, m./v./o. for gender, - for a form the book does not give). Marginal cross-references and the highlighter marks my son had left on the pages were explicitly ignored.

The output is woordenlijst.md: 1051 numbered words in plain markdown tables, readable by a human, with the provenance and the conventions written at the top of the file.

Cross-checking against the index
#

Transcribing a thousand entries from a scan produces errors. The book, usefully, contains its own checksum: an alphabetical index at the back, pages 70–84, with 1044 number references back into the vocabulary list. That is a second, independently printed copy of the same data.

So the second job was to scan that index too and check the whole list against it, mechanically: every number from 1 to 1051 present and unbroken, and every index entry pointing at the word it claims to point at.

The list came out clean. The index did not. Six entries in it are wrong, and in all six cases the vocabulary list itself is correct:

Index saysShould be
cōnfīdere → 913213
hīc → 346347
quod → 378376
regiō → 876878
laetus → 960961
continuere (1012)contingere

Five wrong number references and one misspelled lemma, in a printed school book that has presumably been through several editions. Finding them was a side effect of wanting to trust the data — but it is a good illustration of why the cross-check was worth doing at all. Without it I would have had a list that looked right and no way to know.

That header, including the six discrepancies, is written into the top of woordenlijst.md, so the reasoning stays with the data.

One source, two scripts
#

From woordenlijst.md on, everything is generated. A Python script turns it into latijn.json. A second script injects that JSON into an HTML template to produce verba/index.html. Three inputs, one artifact:

woordenlijst.md ──(maak-data.py)──▶ latijn.json ──(bouw.py)──▶ verba/index.html
                                    sjabloon.html ┘

The 1051 words exist in exactly one place. Correct a typo in the markdown, re-run both scripts, and the stick gets a new file.

Latin books abbreviate. The book prints bonus, ~a, ~um, where the tilde stands for the lemma minus its ending, and a bare ~ stands for the whole lemma (fortis~, fortefortis, forte). The generator expands all of that, and grading accepts both the printed form and the expanded one. Two words refuse to follow the rule and sit as explicit exceptions in the script. One of them is duo, where the stem is du and not duo. The other is ūnus, ~a, ~um; ~ūnius — a seventh printing error, this time in the vocabulary list itself. That tilde should not be there: run through the normal rule it expands to nonsense, and the genitive of ūnus is simply ūnīus. It only turned up because the expansion is done by a script rather than by eye.

Word class is derived, not typed in: verbs from their , ~ō / ~eō / ~iō endings plus the irregulars, adjectives from the shape of the second column, nouns from whatever is left with a real form. That gives 245 verbs, 165 adjectives, 345 nouns and 296 words with no second form at all — 1051, which is the check that the derivation is right.

Each word is asked in up to two directions: Latin → Dutch for all 1051, and Latin → form (“give the genitive of amīcus”) for the 755 that have one. 1806 learning items in total, each with its own progress.

Grading typed answers
#

This is where a vocabulary trainer either earns trust or loses it. Getting told you are wrong when you are right is the fastest way to make a fourteen-year-old close the app.

The rules that matter:

  • Macrons are never required. amici is accepted for amīcī. They are always shown, because he has to be able to read them, but typing them on a normal keyboard is not reasonable.
  • The article is optional. vriend and de vriend are both right.
  • One meaning is enough, and their order is free. For de plaats; de gelegenheid either half scores, and the feedback then shows the full translation with a quiet “Also correct: de gelegenheid.” Give both and the order does not matter: zorgen voor, verzorgen is as right as verzorgen, zorgen voor. Every part he gives must be correct, though — a wrong meaning thrown in for free is still wrong.
  • Parenthetical gloss is not part of the answer. forum (romeins marktplein) is satisfied by forum.
  • A typo counts as correct. Not “almost”, not half a point: the box moves up, the combo and the typing streak carry on, and it counts in the accuracy figures. Only the XP is slightly lower, and the feedback shows the correct spelling. Detection is Damerau-Levenshtein, so swapping two neighbours costs one edit and not two, with a tolerance that scales with length — nothing under four characters, one up to nine, two beyond that.

Two guards keep that from becoming a free pass. What he typed may not be a valid answer to a different word — de vijand for de vriend is confusion, not a slip, and stays wrong. And on a form question the ending has to be right, because the ending is the material being tested: an error in the stem is a typo, but amicō for amicī is not. A doubled letter and a swap of two neighbours are allowed anywhere, since neither can produce a valid alternative form.

A strict mode exists in the settings for the week of the test, where only the canonical answer counts and the typo tolerance is gone.

The most valuable test in the whole project is the invariant behind this: for all 1806 items, the answer the app itself displays must also be accepted by the app — with and without macrons, in lenient and in strict mode. It is one loop over the data, and it has caught more real bugs than anything else.

A typed question

The learning engine, and the two things it got wrong
#

Underneath is a six-box Leitner system. Correct moves an item up a box, wrong drops it to box 1 (not 0), and each box has a waiting time expressed in both other questions asked and elapsed time — box 3 wants twenty questions and a day, box 5 wants seventy questions and a week. The form question of a word only unlocks once its meaning question reaches box 2: first know what it means, then the form.

One thing is different from every spaced-repetition tool I have used, and it is the difference that made it usable. The engine works only inside the active learning package — a selection of sections he ticks on the home screen — because a school test is always about a part, and 1051 words as one undifferentiated heap is not a study plan. Progress, though, is stored globally per word and direction. Switching packages never loses anything, and a word that appears in two tests is learned once.

Two weeks in, he had two complaints, both of which turned out to be real.

“It’s nearly all multiple choice.” The question form hung purely off the box: boxes 1–2 multiple choice, box 3 and up typing. But box 2 has a ten-minute waiting time, so within one sitting almost the only thing that ever comes back is box 1 — and a wrong answer puts an item back there too. Measured: 27 % typed questions, and in the “weak spots” mode essentially zero, because weak items are by definition in a low box. The fix was to stop deciding on box alone: typing from box 2, a cap on how many multiple-choice questions in a row one item may get, and a floor on the typed share of a round. Plus a setting — how much typing — with three levels, because the right amount is a matter of taste. Measured after: 60–64 % typed on the default, 30 % versus 59 % between the extremes.

“The same word keeps coming back.” The only rule was “never the same word twice in a row”. With at most ten items in the air, a round of fifteen questions showed the same word up to five times, and the measured minimum distance between two turns of one word was one question — the form question of a word sometimes landed directly after its meaning question, because the introduction routine never looked at what had just been asked. Now there is a repeat window of five words (four to six, see below), a cap of two turns per word per round, and a layered choice: fresh material first, then repetition with a shrinking window, and if it comes to it, rather one more new word than a third turn for the same one.

A third fix came out of the same session. The combo multiplier was reset at both the end and the start of every round, so a run of ten correct answers always died at the round boundary. The combo belongs to the learner, not to the round — it now carries across rounds and across closing the app, and breaks only on a wrong answer.

“A typo shouldn’t cost me my streak.” The grading had an “almost” verdict for a one-character miss, which sounded generous and was not. The combo did not break, but it stopped growing, the typing streak froze, the item did not advance towards gold, the answer counted in neither accuracy total, and the round’s flawless bonus was gone, because that bonus requires zero “almost” answers. Worse, the detection was too narrow to catch the commonest slip of all: a transposition is distance 2 in ordinary Levenshtein, so amicsu was simply marked wrong. A typo is a motor slip, not a gap in knowledge, and it is now scored as such. The same round of feedback turned up the ordering bug in multi-meaning answers described above.

None of these were crashes. All four were the kind of bug that makes a tool quietly unpleasant to use, and none of them would have surfaced without someone actually revising Latin with it every evening.

Letting the pace find the learner
#

The number that governs how much new material arrives is the ceiling on items in the air — items sitting in box 1 or 2, half-learned. It was a constant: ten. That constant is wrong for everyone except the imaginary average learner. Ten half-known words at once is a wall for someone who is struggling, and for someone who is flying it means the round keeps coming back to words he already knows, because there is nothing else to ask.

So the ceiling now moves with the answers. Every answer in a learning round produces a fluency score between 0 and 1: wrong is 0, and correct is 1 if it came quickly, sliding down to 0.5 if it came slowly. The thresholds have to differ by question form, because clicking is not typing — 4 s and 10 s for multiple choice; for typing, 3 s + 0.22 s per character of the expected answer, and 2.2× that for the slow end. Without the length correction every long translation reads as hesitation. Anything over a minute is a coffee break, not slowness, and is clamped.

Those scores feed a running average with a memory of roughly the last twelve answers, which sets two things:

StrugglingMiddleFlying
Items in the air51014
Repeat window4 words56

The repetition side needs no separate rule, which is the part I like. If less new material is allowed in, the question-selection algorithm fills the round with due and maintenance questions by itself — more drilling of what is already half-known, which is exactly what someone who is struggling needs. Nothing had to be taught to “repeat more”.

The index lives in the profile, so it survives closing the app, and the settings screen has Learning pace: automatic, or pinned to slow (5), normal (10) or fast (14). It also shows what the automatic setting is currently doing — “now 10 words at a time” — because an invisible mechanism that changes how the app behaves is unsettling rather than clever.

The right answer to the wrong question
#

The last fix is the smallest and my favourite. A chunk of his wrong answers were not gaps in knowledge at all: he typed the genitive when the question asked what the word means, or the translation when it asked for the genitive. The app dutifully marked those wrong — box down, combo of eleven gone, typing streak broken — for a mistake that was about reading, not about Latin.

That is now detectable, because the app knows both answers to every word. If a typed answer is wrong for the direction that was asked but exactly right for the other direction of the same word, nothing is counted: no box change, no combo break, no XP, no accuracy, no pace index, not even the round’s question counter. The same question comes back with an empty field and a blue card — “↻ Read the question again — that is the genitive of this word, you are being asked for the meaning” — and the answer clock restarts, so the misread attempt does not drag the pace down either.

Once per question. A second wrong answer is simply wrong, otherwise it becomes a free hint. Two things kept it honest: the guard only accepts an exact match in the other direction, and words with only one direction can never trigger it. Both are in the test suite, along with a run that compares every counter before and after.

Making it worth opening
#

The learning engine is the point, but a fourteen-year-old does not open an app because it has a well-tuned Leitner scheduler.

The centrepiece is a mosaic of all 1051 words, one small cell each, grouped per caput in its own accent colour. Zero stars is a dark cell, one or two is a faint glow of the caput colour, three or four fills it, and five — every direction of that word at box 5 — turns it gold. Words outside the active package are dimmed but still there. He watches his entire vocabulary list slowly turn gold, and that image does more for motivation than any number.

Getting the star formula right took a revision. Rounding the box average down meant a word at L2N=1, L2V=0 showed as zero stars — “never seen” — for a word he was actively working on, which made the central visual a liar. It rounds up now, with gold strictly reserved for all-boxes-at-5.

Around that sit XP and twenty levels named after Roman ranks (Discipulus through to Iuppiter), a combo multiplier, a daily streak, twenty badges, and a mode called Verover (“conquer”): per section, type every word, 100 % or nothing. Sections vary wildly in size — section 1.0 has 153 words against a median of 20 — so large ones are cut into parts of at most 25 words, giving 59 conquest tests. Take all 59 and a 60-question final exam unlocks.

The conquest screen

And then there are the tesserae: twenty collectible pixel figures in Roman style, each a 16×16 grid drawn on a <canvas> at runtime and turned into an <img> with toDataURL() — no image files anywhere. Each has a Latin name, a rarity, one dry line of flavour text and a single unlock condition, and locked ones show as dark silhouettes with their condition and current count.

The tesserae collection

They were drawn as Python pixel grids, rendered to a contact sheet, looked at, corrected, and injected into the template. “Is it recognisable at 16×16” is an acceptance criterion in the spec, and judging that needed a human eye on a PNG.

The whole thing is themed “Roman mosaic at night”: a deep warm stone background with soft terracotta, purple and gold washes, seven caput colours borrowed from Roman materials, a Greek-key meander as a data-URI SVG tile, Roman numerals in medallions, laurel wreaths built in SVG around the badges. Gold is used for exactly one thing — mastery — and nothing else. Latin is always set in a serif stack and Dutch in the system sans, which sounds fussy but genuinely helps in a translation drill.

Behind all of that now sits a Roman city panorama — the Colosseum on the right, temples, an aqueduct and terracotta roofs on the left — in ligne claire, the flat-colour Tintin style. It took three goes. The first was a hand-drawn inline SVG of a trireme, a moon and the Colosseum as a band across the top of the home screen, which pushed the content down and, after two rounds of revision, still did not look good enough to keep; it was ripped out again the same day. What worked was an AI-generated image instead, placed position:fixed; inset:0; z-index:-1 so it is a real background behind every screen rather than an element on one of them. It sits at 38 % opacity (34 % on mobile) under a scrim that darkens from rgba(14,13,20,.42) at the top to .78 at the bottom, with saturate(.85) brightness(.9) on top, so the drawing dissolves into the dark theme instead of competing with the text. On a narrow screen the whole panorama turns to mush, so below 720 px the background position shifts to 72% 70% and it simply becomes the Colosseum.

The single-file rule applies to the picture too, of course: it is base64 in the HTML, which is where most of that 370 KB went. The favicon got the same treatment — a Roman temple façade as a 1 KB inline SVG data URI, so even the tab icon costs no network request.

Statistics

Specification first
#

The part of this project I would repeat everywhere is that the specification came before the code, and stayed ahead of it. FUNCTIONELE-SPECIFICATIE.md is 56 KB of binding, executable detail: the exact grading rules, the box waiting times, the XP table, all twenty badges with their conditions, the question-selection algorithm as pseudocode, the animation timings, and 31 numbered acceptance criteria at the end.

It is not documentation written after the fact. It is the input. Every change above — the typing mix, the repeat window, the running combo — was made in the spec first, with the reasoning written down as a block quote next to the rule, and only then in the app. Code reviews get a document to review against, and a year from now the answer to “why is it like this” is in the file rather than in my memory.

The whole app was built with Claude Code against that spec, in the same way as the Fri3d badge apps and the rocket launch controller . Nine Playwright smoke suites, 110 assertions, green means exit 0.

He is at level 8 and 120 of 259 words in caput 1. Six tesserae of twenty.

Resources
#

  • Verba, running — the app itself, the same single file that sits on the stick. Settings ⚙ has an App downloaden button that saves the file, so the web version can seed your own copy on a stick; the button hides itself once the app is running from file://, where there is nothing left to fetch.
  • Verba on GitHub — the app, the spec, the build scripts and the tests
  • Leitner system — the spaced-repetition scheme behind the boxes
  • Playwright — headless Chromium for the smoke tests
  • Claude Code