case study
Lekh Patro Nepali Typing, OCR and Calendar
Type Nepali the way you already text it: write 'kasto chha' and it becomes कस्तो छ as you type, thanks to a phonetic engine and an 887-entry dictionary. It also reads documents in the browser with OCR, translates English↔Nepali with a line on how to say it, and carries a Bikram Sambat calendar and a Tools tab.
The problem
Most of us type Nepali in Latin letters. It isn't a fallback, it's the normal thing, because a Devanagari keyboard is one more layout to install and learn on a phone that already autocorrects English. So Lekh takes what people already type: write 'kasto chha' and it commits कस्तो छ as you go, IME-style, the way Gboard commits a word instead of making you finish a sentence and press convert. A phonetic engine does the transliteration and an 887-entry dictionary puts suggestion chips over the top of it, which is the part a purely phonetic engine can't do on its own. That covers the English we mix in and the way we actually text: school becomes स्कुल and tapaailai becomes तपाईंलाई, where spelling them out letter by letter gave स्चूल and तपाइलै. Romanized Nepali has no fixed spelling, so the same word arrives half a dozen ways and the chips have to cover the variants instead of insisting on one. Whatever the dictionary doesn't reach, a searchable Devanagari cheat sheet does: every glyph is one tap away.
Nothing you upload leaves the browser
The Translate tab takes a photo, a PDF, a DOCX or a TXT file, and none of them are uploaded anywhere; Tesseract.js does the OCR right inside the browser. Translation is online by default because the good model is large, but an optional NLLB-200 model downloads once and then runs fully on-device, so a document you wouldn't paste into a web form never has to be. The whole app also works offline from the very first visit: the service worker takes control of the page that installed it, so the shell and the OCR payload are cached on the visit that fetched them. That behaviour is one line of workbox config and it disappears silently (switch the plugin to prompt-style updates and it's gone without an error), so I check it by grepping the built service worker for clientsClaim instead of trusting it.
Why the festivals are tabulated
The calendar is really two problems wearing one coat, and only one of them can be solved in general. Bikram Sambat months run 29 to 32 days on a pattern set by solar transits instead of a formula, so every implementation ships a lookup table; the conversion works across BS 2000–2090 because that's how far the table goes. Festivals are different. Dashain, Tihar, Teej and most of the rest fall on a tithi, a lunar day, which needs real lunar ephemeris plus the panchang convention about which sunrise a tithi counts against. Computing that in the browser would mean a large bundle and a decent chance of landing a day out, and a Dashain on the wrong day is worse than no Dashain at all. So I tabulated the festivals (36 months, BS 2081 to 2083), and the app states the range it covers instead of showing an uncovered month as one that just happens to have no festivals. Holidays then refresh from an upstream almanac that re-scrapes daily, which is the calendar's only network call, and the calendar names the source of whichever month you're looking at.
Two guards, because BS 2084 lies convincingly
Nothing fetched at runtime has been reviewed, so live data has to prove itself twice. A month whose length disagrees with the conversion table is rejected, and so is a month with no festival names at all, which is what an unpublished year looks like. BS 2084 currently returns 31 days and zero festivals for a month whose first day is नयाँ वर्ष, and across the 36 bundled months the festival count runs 11 to 25 and is never zero, so zero is a signal and not a guess. The generator is just as suspicious at build time and drops any year where the two sources disagree: BS 2084 disagrees on five of twelve months, and shipping it would have put every festival from Jestha onward on the wrong square while looking completely normal. One more rule sits above all of it: never read these dates back with toISOString(). The converter returns a Date at local midnight and Nepal is UTC+05:45, so a UTC read moves every festival a day earlier. I made exactly that mistake once while verifying the feature, and it looked just like a data error.
Nepal changed its weekend in April 2026
Nepal moved to a two-day weekend on 6 April 2026, Chaitra 23, 2082, when the cabinet added Sunday to the Saturday it already had. The app encodes that as a rule with a start date instead of a global setting, so paging back to BS 2081 still shows a one-day weekend instead of rewriting history, and if the policy is ever reversed I add an end date instead of deleting the rule. It deliberately doesn't take the weekend from the festival data, whose own holiday flag marks 29 of 53 Saturdays in BS 2081 but 48 of 52 in BS 2082, and doesn't encode the Sunday policy at all. The change also only covers government offices and schools; several local levels rejected it and the private sector isn't covered, which is exactly the kind of detail a calendar either states carefully or gets wrong.
For someone who can't read Devanagari
A visitor to Nepal can get a correct translation and still be stuck, because कृपया मलाई ठमेल लैजानुहोस् is no use to someone who can't read it aloud. So every Nepali translation carries a plain-Latin line under it, Kripaya malai thamel laijanuhos, written for a reader rather than a linguist: no diacritics, vowel length folded, and the inherent vowel dropped where speech drops it, so नेपाल reads nepal and काठमाडौं reads kathmadaun. Show then puts the Nepali full-screen in large type, for holding up to a taxi driver. A phrasebook of 43 lines in five topics works offline, which matters because Translate needs a network or a 900 MB model and a trail at 4,000 metres has neither, and it sits under Nepal's emergency numbers as tap-to-dial links. The calendar gives about 70 festivals an English name, a line on what happens and, where it matters, a note like offices, banks and many shops closing from Fulpati until after Tika. I wrote those by hand from the government's 2083 public-holiday notice and standard references, and everything else stays in Nepali. An earlier English mode for the widget generated a name for every festival and too many came out wrong, so a missing line is the honest default.
Preeti is a font pretending to be a language
Government offices, banks and schools still keep years of documents in Preeti, which isn't an encoding at all: it's Latin keys whose glyphs happen to be Devanagari shapes, so नेपाल is stored as g]kfn and turns to gibberish outside that font. Converting it is a reordering problem as much as a table, because Preeti stores glyphs in the order they're drawn. The vowel sign ि is typed before its consonant, the reph र् after the syllable it sits on, and several letters are built from pieces, so m adds the stroke that turns उ into ऊ and प into फ. The Tools tab converts both ways, and every dictionary word survives a round trip. It also had to learn what isn't Preeti: someone typing mero naam into the box got भचय लबब back, a correct decoding of the wrong question. Text with a speaker's share of vowels and none of Preeti's punctuation is now read as romanized Nepali, which misreads 1 of 213 dictionary phrases and 0 of 178 Preeti ones, and a switch lets you overrule it. The same tab writes amounts out for a cheque, बाह्र हजार पाँच सय रुपैयाँ मात्र, from a table for the numbers below a hundred, because Nepali has no rule for them: 29 is उनन्तीस, one short of thirty.
The aurora cost 22 frames a second
An earlier design of mine put a drifting ambient gradient behind everything and frosted glass on top of it. Every frosted surface sampled that moving backdrop, which meant none of the blurs could ever be cached. Measured on the Type page while scrolling, the drifting version ran at 8.8 fps where a parked one ran at 30.9, and the gap held at 4x and 8x CPU throttling too, so it was compositor work and not script. Parking the drift recovered most of it, and deleting the aurora and the glass recovered the rest. Blur now survives only on the app bar and the mobile dock, where the page really does scroll underneath. One structural trap came out of the same work: backdrop-filter makes an element a containing block for its position:fixed descendants, exactly as transform does, so the app bar's blur lives on its ::before and not on the bar itself. With the filter on the parent, the bottom dock anchored to the bar and landed under the header.
Why there is a native Android app at all
A PWA can't provide an Android home-screen widget. The widgets member in the web app manifest is Microsoft's Windows 11 Widgets Board feature, not the Android home screen, so a native AppWidgetProvider is the only way to get one. And a calendar you have to open to read is a calendar you stop reading. So there's a small Kotlin widget showing today's BS date, tithi and festival, in four sizes, sharing the web app's calendar data instead of keeping a second copy; its own date algorithm was validated against the converter over 3,970 conversions. It ships inside a Capacitor wrapper whose server.url points at the live site, which means the web app and the Android app are the same deploy and most fixes reach both without a new build. On Android 12 and up, the app and the widgets take the palette Android derives from your wallpaper, held to the same contrast floors as my hand-picked one and measured per candidate instead of trusted, because Android's shade numbers only mean a given lightness on something close to AOSP, and several manufacturers substitute their own extraction. It's on Google Play.