kanji-buddy
Web app & quiz
The screens, and the review loop underneath them — including two bugs that only showed up in use.
The screens
| Route | What it does |
|---|---|
| / | Dashboard: what's due, a week's forecast, level progress, the SRS matrix, and three subject lists. |
| /lessons | Teach a batch of five, then quiz it. |
| /lessons/picker | “Advanced”: choose what the next session teaches, or unlock more. |
| /reviews | Everything due, until the queue empties or you leave. |
| /item-spread | The dashboard's SRS matrix opened up: every item, by stage then type. |
| /levels/[level] | One level, grouped by type. |
| /radicals · /kanji · /vocabulary | One type across a band of levels. |
| /subjects/[id] | Everything about one item, with notes and synonyms. |
| /profile | Who you are, days studied, activity, and the Wall of Shame. |
| /settings | App settings: lessons, reviews, extra study, audio, dashboard cards. |
| /settings/account | Account settings: time zone and Vacation Mode. |
| /login | Paste a token. The only way in without the portal. |
The visual language is copied from WaniKani deliberately — blue radicals, pink kanji, purple vocabulary — because those colours are a memory aid, used consistently in the tiles, the item spread and the highlighted words inside mnemonics.
Item state is shown, not just stored
Two CSS custom properties carry the subject type — --tile-color for the outline and label, --tile-fill for the filled state — and the state classes vary fill and opacity between them. That is why a radical looks like a radical whether it is locked or burned. It was one property until dark mode: a colour dark enough to read as text on a white card is also dark enough to carry white text as a background, so on a light page one value did both jobs. On a dark one they pull apart.
The character-less radicals
19 radicals have no Unicode character and exist only as SVGs. They are drawn with a CSS mask and currentColor, not an <img>: WaniKani’s SVGs are strokes coloured by a CSS variable, and an SVG loaded through <img> is an isolated document that cannot see the page’s variables — so it always rendered black. As a mask the shape is filled with currentColor and picks up the tile’s colour in every state. Everything routes through one SubjectGlyph component, because the first version of this used characters ?? slug and printed the English word “pope” where a radical belonged.
Dark mode follows the system
No toggle and no stored preference: :root sets color-scheme: light dark and every design token is a light-dark() pair, so the browser picks a column from the OS setting. The light half of every pair is the value the app has always shipped — dark mode added a second column rather than restyling the first.
The palette is not the light one dimmed. WaniKani’s colours are unusually saturated — #ff00aa carries an OKLCH chroma of 0.276, near the edge of sRGB — and colour that vivid on a dark page makes small text shimmer, because the long- and short-wavelength components focus on slightly different planes and the eye keeps refocusing. The dark inks sit at chroma ~0.115 and lightness ~0.80: same hue, so pink still means kanji, without the vibration. The ground is #19161b rather than black, which would maximise the step to the text on it and leave nowhere for a raised surface to go.
A stage colour must not land on the subject fill of its own hue
The light column keeps them a step apart — Apprentice #dd0093 below the kanji fill #ff00aa, Enlightened #0093dd below the radical #00aaff. The first dark column did not, and set five stage and status colours equal to, or nearly equal to, the subject fill of the same hue.
Two of those are visible in a second once you know: the Apprentice band in the Item Spread was the same pink as the kanji tiles sitting inside it, and the demotion chip after a review was the same purple as the vocabulary item it appeared under — identical, with only its inset shadow giving it an edge. Where a stage colour and a subject colour can meet on one screen, hue alone cannot separate them, and reduced chroma makes that more pressing rather than less.
The quiz loop
One component, QuizView, serves both lessons and reviews. It grades, advances a queue, and reports results — but persists nothing itself, because the two callers want opposite things.
onItemComplete? fires as each item finishes → reviews use this
onSessionEnd? fires once, when the queue empties → lessons use this| Lessons | Reviews | |
|---|---|---|
| Saves | The whole batch, at the end | Each item, as it finishes |
| Leaving early | Discards the batch — you start those five again | Keeps everything finished; only the card in hand is lost |
| Why | A half-applied batch strands two items in reviews and three in lessons. All-or-nothing keeps 'I gave up' meaning that. | A session can run to a hundred items. Losing an hour to a closed tab would be unforgivable. |
Neither callback is awaited before the queue moves. That is the fix for the first bug below, and it is why a caller passing neither gets a quiz that persists nothing at all — which is what extra study will need if it is ever built.
The Advanced screen
Advanced on the lessons card opens /lessons/picker, which holds two things that are easy to confuse and worth stating apart: one chooses among the lessons you have, the other creates more.
The Lesson Picker
A faithful copy of WaniKani’s. Every unlocked-but-unlearned item in one place, sectioned level then type, sorted by WaniKani’s own lesson_position. It unlocks nothing — what it offers is exactly what immediately_available_for_lessons already returns.
The pinned bar at the foot has one button with two states. Batch, Please, while nothing is selected, starts the default next batch immediately. Select anything and the same button becomes Start Lessons and runs the selection instead. It is never disabled: the commonest intention on this screen is “just give me my lessons”, and previously that had no button to press — the bar sat greyed out until you had picked something, and Batch, Please merely added to the selection for you to then confirm.
Interleave Lessons sits directly above that button rather than in the card at the top of the page. On a list long enough to scroll — which is the list this screen exists for — it was off screen by the time you reached the button whose behaviour it changes.
Why interleaving is proportional, not round-robin
Round-robin is the obvious implementation and the wrong one. With 3 radicals and 27 vocabulary it deals R,V,R,V,R,V and then 24 unbroken vocabulary — a mixed opening followed by exactly the monotony the option exists to prevent. Each type is instead placed at even fractions of the whole run, so three radicals land near ⅙, ½ and ⅚ whatever the other counts are. Ties break radical → kanji → vocabulary, so a radical still precedes the kanji it builds.
The selection is stored in localStorage and the lessons page reorders against it. The queue is a preference about order, never about availability: it is intersected with what the API actually returns, so a queue that has gone stale can shrink a session but can never conjure a lesson that isn’t really there. Items that were queued and have since been learned simply fall out rather than stranding the rest behind ghosts.
Unlocking ahead of schedule
At the foot of the picker, behind a confirmation. WaniKani has no equivalent and by design never will — the SRS clock is the product. This exists because KanjiBuddy is for people re-learning material they already know, who should not spend a fortnight proving they know Level 1 four hours at a time.
It relaxes exactly one word in each unlock rule, passed to started:
| Normal | Advancing | |
|---|---|---|
| A kanji unlocks when | all its radicals are passed (Guru) | all its radicals are started |
| A vocabulary unlocks when | all its kanji are passed | all its kanji are started |
| The next level opens at | 90% of the level's kanji passed | 90% started |
Everything else — the dependency graph, the order, the ratio — is untouched. It skips the waiting, not the learning: a kanji still cannot arrive before you have taken the lesson for every radical inside it, so nothing appears that you have no way to read. What you give up is the spacing, which is why it is confirmed rather than automatic, and why it sits at the foot of the picker instead of on the dashboard — it shouldn’t be under your thumb on the screen you open daily.
Two gates that look redundant and aren't
One tranche per call, rather than cascading to the end of the level: cascading would need the queue to advance faster than lessons can be taken, so it would unlock work depending on lessons still sitting untouched.
And untaken lessons block it explicitly, even though both rules key off started and would fail anyway. They’d fail as “nothing left to unlock”, which reads as a dead end when the truth is the opposite — the work is right there, waiting. The user is the bottleneck, not the schedule, and that is the one case where the button should say no.
Choosing what it opens
The button was all-or-nothing and unannounced: press it and the whole tranche arrived, with no way to see what was coming or to take less of it. The confirmation now lists the tranche, everything ticked, with a Select All / Deselect All above it.
Nothing starts selected. It began the other way, on the reasoning that the button’s existing behaviour was “open the next tranche” and selection was a way to take less — but that defaults an irreversible action to its largest form. The safe path required noticing the list and deliberately clearing it, while the ordinary press, the one that skips the reading, took everything. The work belongs on the side of the action that cannot be undone.
The list is ordered by when each item would have unlocked on its own, soonest first — not grouped by subject type. That is the question this list answers: an item you were going to get tomorrow is a different proposition from one three weeks out, and by type they are indistinguishable. The rule deciding what qualifies is unchanged; what is new is that it can now be asked as well as obeyed.
| Concern | Where it lives |
|---|---|
| Which subjects qualify | Server. findUnlockable in lib/advance.ts, read by both routes |
| When each would have unlocked | Server. projectedPassAt in lib/srs.ts, max across components |
| The ordering | Server. Sorted in previewUnlockable |
| Tick state, “in ~3 days” labels | Client. Presentation only |
A client computes none of the rule. It draws GET /unlockable in the order given and posts the ids back — see Building a client.
The dialog loads the tranche when it opens rather than with the page: it is a read nobody needs until they are considering the button.
Three states, kept apart on purpose
A failed read, an empty tranche and a loaded tranche were once two states — an array that was empty in the first two cases — and the difference between them is the entire message. Collapsed, the dialog could only offer a disabled button and no reason, which reads as a broken feature whichever had happened. Each now says which it is, and a failure offers a retry.
Keeping them apart also removed a trap. The request omitted subject_ids as a shortcut for “everything is ticked”, tested as chosen.size === candidates.length — which is equally true of an empty list, so the shortcut fired when nothing was selected, asking the server to unlock the lot. Only the button’s separate disabled condition stopped it reaching the server: two unrelated expressions had to agree for the safe outcome, and nothing said so. The dialog now always sends the ids it holds.
POST /api/v1/advance exports no GET. A write reachable by GET is a write a link prefetcher can perform — the same rule as /api/v1/unsubscribe, and a test pins it. The preview is a separate read-only route, GET /unlockable, rather than a GET handler on this one.
Vacation Mode, on screen
The mechanics are on SRS engine. What matters here is that the app has to refuse rather than merely show nothing: the schedule is shifted on return, not frozen while you are away, so the rows underneath stay due the whole time.
- The dashboard leads with a card instead of two counters reading zero. Zero is indistinguishable from having finished everything, which is a much nicer thing to wrongly believe.
/lessonsand/reviewsshow the same card instead of opening a session.- The nav hides its counters entirely — a pair of zeros still reads as a claim about your queue, and the queue is untouched.
The nav remembers the flag between visits
The bar must decide what to paint before it can possibly know, and both guesses are wrong for somebody: draw the counters and someone on vacation is shown work that is then taken away; hold them back and everyone else watches a gap fill in.
So the last answer is kept in localStorage under kb:on-vacation and applied in a layout effect — before paint, not after, which is the difference between a remembered answer and a second flash. Live data still wins the moment it arrives. It decides nothing: every count, every page that refuses a session, reads the server. The account page writes the flag on toggle, since that is the one place the setting changes without the nav refetching.
Empty states are drawn, not described
Seven of them share one component, with one of five inline-SVG scenes: a sprout for what has not grown yet, a finished stack for today’s maximum, an empty tray for nothing due, unlit firewood for nothing burned, empty shelves for nothing studied, an all-clear tick, and the beach for vacation. They are a dozen shapes each — no request, no asset pipeline, nothing to optimise.
Art is sized by what it is rather than one number for all of it. The scenes take their share of the card, because they are little pictures and only get more legible; the tick keeps icon size wherever it appears, because a filled disc scaled up reads as a status light shouting rather than a note that nothing is wrong.
Two of them mean the opposite of the others
Critical Condition and Recent Mistakes are not empty for want of data — they are empty because nothing is wrong. Giving them a “nothing here yet” scene would say the reverse of what those cards mean, so they get the tick.
Lesson tabs follow the data
A lesson shows Components, Meaning, Readings and Examples — but only the ones the subject has anything to put in. This used to be decided by subject type, and a rule per type has to be right about every type. It was not: counted over the imported set, kana vocabulary has no readings and no amalgamations in any of its 60 rows, yet was shown a Readings tab and an Examples tab headed “Vocabulary that uses this kanji” — which a kana word is not. Thirteen radicals had an empty Examples tab for the same reason.
Fixing that fixed a second thing. “Examples” meant amalgamations, so vocabulary — which has none — was excluded, and its patterns of use and 6,752 context sentences appeared on the subject page and in the quiz but never in the lesson, which is where you meet the word for the first time. Examples now means what gets built from a piece, or how a word is used, depending on which the subject has.
The kana pad
The ひ button in a reading question opens a chart you can also type with. On a phone the romaji conversion works but you cannot check it — you type sha and have to trust it. Seeing しゃ beside sha answers that, and tapping it sidesteps the question.
It carries both scripts. Katakana is derived from hiragana by code point rather than written out a second time — the exact inverse of the fold in lib/answer.ts — because two hand-written tables would be two chances to mistype ヂ and no way to notice. Inserting katakana is safe because grading already folds the scripts together: ドア and どあ both reduce to どあ.
Two bugs worth recording
The same radical, asked three times. The queue advanced only after await onComplete(...) — the network call that starts an assignment. So the card sat there for a round trip with its input cleared and, because an exactly-correct answer carries no message, no feedback at all. Indistinguishable from a dropped keystroke, so you answer again. The answers were registering the whole time.
Two changes: the queue now advances synchronously, before anything is persisted; and a correct answer gets a brief green flash, since previously it produced no acknowledgement whatsoever. The scheduling rule moved to lib/quiz.ts as a pure function — it had no test coverage at all, which is how this shipped.
The previous radical’s illustration. React reuses the same <img> between lesson items, and a browser keeps painting the old picture until the new one loads — so for a moment you saw the wrong drawing under the right mnemonic. In a memory app that teaches the wrong association. Fixed by keying the element on its URL so React builds a fresh one.
The same image was also invisible at first: it was marked loading="lazy", but an unloaded image has no intrinsic height, and a zero-height element never becomes visible enough for the lazy heuristic to fetch it. Chicken and egg.
Leaving a session
A worded End session button rather than an unlabelled icon, because what leaving costs differs: for reviews it warns only when the current item is part-answered and offers to finish it first; for lessons it says the batch will be discarded and names how many. The end screen then states the outcome plainly rather than staying silent about it.
Responsive
Below 860px the navigation collapses behind a hamburger into a full-width accordion. It has to: four dropdown triggers plus an avatar want about 550px, and a phone offers 390 — measured, the document scrolled to 544px and the avatar sat off-screen. The panels inside the sheet are the same components the desktop dropdowns render, so the two cannot drift.
The dashboard’s aggregate endpoint is described in The API; why it once took 31 seconds is in Build & deploy.