Practise the words you nearly know.
A set of language-learning tools that runs entirely in your browser — not a course. Pick a language workspace and work on the words you've already met somewhere else: a deck you're mid-way through, a rule you keep looking up, a verb that never sticks.
- Four workspaces — English, Brazilian Portuguese, Dutch and Spanish, each with its own decks, notes and schedule. Switching one changes the whole app and takes you home.
- Flashcards on a real scheduler — a local SM-2 implementation with four grades. The labels under the buttons show the actual interval each one buys you, worked out from the card in front of you rather than fixed copy.
- Ask both ways — turn a deck around and each direction keeps its own schedule, because recognising brood and producing it from "bread" are two different things you know to two different degrees. A triage step lets you pick which cards survive the reversal, since plenty of phrases only work in one direction.
- Undo the last grade —
Z, ten deep, restoring the schedule exactly as it was rather than approximating it. - Grammar Notes — short explanations tagged the way your cards are, so the rule about de or het turns up while you're reviewing a noun.
Gopens it mid-review without ending the session. - Etymology — trace a word back through the languages it passed through. pond goes Middle Dutch → Old Dutch → Proto-West Germanic → Proto-Germanic → Latin, and
Epulls that up mid-review without ending the session. Open a word and it branches: woordenboek is woord and boek, each unfolding into its own descent. - CEFR levels — cards carry A1–C1, and the home screen breaks today's due count down by level.
- Illustrations — an OpenMoji glyph on a card, from a curated set of 526 vendored locally.
- Backup and restore — a single JSON file holding every deck, card, review and note across all four workspaces. Restoring adds what's missing and leaves what's there alone, so importing twice is harmless.
- Installable — add it to a home screen or dock and it runs in its own window, offline.
- Light and dark, a full keyboard path through review, and a dock built for a thumb on a phone.
Nothing you write leaves your device. There is no server, no account and no paid tier — your cards live in IndexedDB on your own machine, which is also why the backup file matters.
| Review | Space / Enter turn the card · 1–4 grade it · Z undo the last grade · G the rule for this card · E where the word comes from |
| Decks | N add a card |
| Anywhere | Esc closes a dialog or a menu |
This app uses lingo-ds, its design system, as a local file: dependency — so it expects lingo-ds checked out as a sibling directory:
some-folder/
├── lingotoolbox/ (this repo)
└── lingo-ds/
Built and tested on Node 20.
# 1. build the design system
git clone https://github.com/vgomx/lingo-ds
cd lingo-ds && npm install && npm run build
# 2. run the app
git clone https://github.com/vgomx/lingotoolbox
cd ../lingotoolbox && npm install && npm run devIf npm install can't resolve lingo-ds, it's because lingo-ds/dist is missing — build the design system again.
| Command | Does |
|---|---|
npm run dev |
Start the Vite dev server with hot reload. |
npm run build |
Typecheck, check the illustrations, build to dist/, copy index.html to 404.html. |
npm run preview |
Serve the production build locally. |
npm run typecheck |
tsc -b --noEmit. |
npm run check:illustrations |
Verify every referenced glyph exists. Runs on every build. |
npm run build:illustrations |
Re-download the OpenMoji set and regenerate the catalogue. |
npm run build:etymology |
Re-stream the Wiktionary dumps and rebuild the etymology shards. Slow; the output is committed. |
npm run check:etymology |
Verify the shards parse, are complete and still carry multi-step chains. Runs on every build. |
npm run build:icons |
Regenerate the PWA icons from the design system's lockup. |
npm run build:social-card |
Regenerate the card at the top of this file. |
- React 18 + TypeScript (strict), built with Vite.
- lingo-ds — the design system: tokens, components and sounds (showcase).
- idb over IndexedDB for storage; no ORM and no server.
- vite-plugin-pwa / Workbox for the service worker.
- OpenMoji for card illustrations.
- Wiktionary via wiktextract for etymologies (CC BY-SA 4.0).
src/
├─ data/ IndexedDB access, the scheduler, starter decks and notes
├─ state/ the one store the app reads from
├─ shell/ rail, deck sidebar, top bar, dock, language picker
├─ tools/ Flashcards, Grammar Notes, and empty states for the other three
├─ marketing/ the light-theme landing page
└─ styles/ app-level CSS (everything visual comes from lingo-ds tokens)
There is no src/assets/. Brand artwork is imported from the package — import mark from 'lingo-ds/assets/logo/mark-violet.svg' — so the app can't drift by holding a stale copy. The favicon is the exception, since it has to be a real file at a fixed URL; sync:assets copies it into public/ before dev and build, so it's generated rather than committed.
The scheduler. src/data/scheduler.ts is SM-2 adapted to four grades. New cards step 1m · 6m · 1d · 4d; after that intervals come from the card's ease factor. Reviews are fuzzed by ±5%, so cards introduced together stop arriving together forever. Grading writes the new state and the review-log entry in a single IndexedDB transaction, so a card can never advance unrecorded.
Directions. A card can be scheduled forwards, backwards, or both, and each direction carries its own interval, ease and due date. Turning a deck off doesn't discard the reverse schedule — it just stops asking, so a preference never behaves like a destructive action.
Notes. A note matches a card when their tags intersect. That's the whole mechanism; there's no per-card link to maintain, so tagging a new card correctly is what earns it the explanation.
Illustrations. A card stores the codepoint (1F436), never a filename, so a renamed file or a revised OpenMoji annotation can't orphan somebody's card. The 526 glyphs are curated in scripts/openmoji-selection.mjs — the entire smileys-emotion group plus ~360 concrete nouns. Expressions are taken whole because "annoyed" and "furious" are a vocabulary distinction eight faces can't draw. They're deliberately not precached: that would more than double a first visit to ship pictures most people never open the picker to see.
Type. The three faces are self-hosted WOFF2, generated by lingo-ds's build:fonts script and precached, so the app is set in its own type from the first visit with no request to a third party. They are variable fonts asked for as weight ranges — wght@400..800 returns one file covering every weight, where the discrete 400;500;600 spelling returns one file each and cost 528 KB for Nunito Sans alone.
The latin-ext cut of the two text faces is deliberately left out of the precache: the four workspaces are covered by latin, and unicode-range means a browser only fetches it if such a character appears. JetBrains Mono's latin-ext is precached — IPA lives in that range, so /ˈlɛkər/ needs it on the first card with a pronunciation.
Etymology. The chains come from Wiktionary via wiktextract, not from the obvious source. etymology-db publishes the same information as an edge list, one row per relation — so following a chain means joining across languages, and every extra step of depth costs a whole language's worth of rows. Measured that way, 99% of words dead-end after a single ancestor. kaikki denormalises the chain into each word's own record instead, so venster arrives already carrying Middle Dutch, Old Dutch and Latin: half of Dutch words have two or more steps against 0.8% in the edge list, and depth costs nothing extra because there is nothing to follow.
The shards total 4.6 MB across three workspaces and are not precached — you only ever need the language you are in, and most sessions never open the tool. English has no shard: it is the language the others are glossed into, and its dump is 3 GB. The screen says so rather than pretending.
Storage. IndexedDB at version 2. Upgrades are guarded on oldVersion, so a database that predates a store keeps everything it already had. The backup format carries its own version and stays able to read older files.
- Two tools are empty. Conjugation Drill and Phrasebook are designed and routed but not built. They're marked SOON in the rail and sort below what works.
- Deep links on Pages.
dist/404.htmlis a copy ofindex.htmlso the SPA boots; GitHub still returns a 404 status for those URLs, though the page renders.
Issues and pull requests are welcome. If you're planning something larger than a fix, opening an issue first is the quickest way to find out whether it fits.
Before opening a PR, please make sure npm run build passes — it runs the typecheck and the illustration check. The codebase leans on comments that explain why a piece of code is the way it is rather than what it does, usually because the obvious version was tried first and didn't work; matching that is the most useful style note.
MIT © 2026 Vitor Gomes
Icons are Lucide (ISC; 22 of the 76 used are Feather-derived and additionally MIT, © Cole Bemis). Illustrations are OpenMoji (CC BY-SA 4.0), used unmodified — 526 glyphs ship in public/openmoji/, so the attribution is a real obligation and is carried in src/legalNotices.ts. Typefaces are Baloo 2, Nunito Sans and JetBrains Mono (OFL 1.1), served from this origin as WOFF2 rather than fetched from Google — which is what makes the full licence a shipping obligation, carried in the same file.
Full notices for everything the app ships are in src/legalNotices.ts and surfaced in the app under Settings → Legal. Only things that actually reach the browser are listed — build tooling never ships, so it carries no obligation to end users.