The problem
Most calculators evaluate. I wanted one that explains the evaluation:
given 3 × (4 + 5), show the ordered PEMDAS trace — 4 + 5 = 9, then
3 × 9 = 27 — rendered as real math notation, with a plain-English
sentence underneath.
Github - github.com/fralsare/fralculator
Constraints I set for myself:
Offline by default. The only allowed network calls: an optional currency rate fetch and a one-time ~40 MB model download for voice.
No LLM required. Explanations are rule-based today; the LLM is a
pluggable backend that doesn't exist yet.
Electron + React + Vite + TypeScript, MIT licensed, packaged for
Windows and Linux.
It's called Fralculator. Source: github.com/fralsare/fralculator.
Here's how the interesting bits work.
1. The math engine: tokenizer → parser → evaluator with a trace
The engine is deliberately classic. A tokenizer turns the string into
tokens (numbers, operators, parens, unary minus), a recursive-descent
parser builds an AST with the usual precedence climbing, and the
evaluator walks it. The twist: the evaluator also records a step
trace as it reduces sub-expressions.
// Pseudocode of the reduction loop
function eval(node: AstNode, trace: Step[]): number {
switch (node.kind) {
case "binary": {
const left = eval(node.left, trace);
const right = eval(node.right, trace);
if (node.precedence < parentPrecedence) {
// Only record steps that are "outer" — inner ones already recorded
trace.push({
expr: ${fmt(left)} ${opSymbol(node.op)} ${fmt(right)},
result: left op node.op right,
});
}
return apply(node.op, left, right);
}
case "unary":
return eval(node.operand, trace);
case "literal":
return node.value;
}
}
Two design details that matter:
Steps are recorded at the precedence boundary. We don't log every
single reduction — we log each operation as the user would write it
(parenthesized sub-expressions first, in PEMDAS order). This is the
difference between "a log of the evaluator" and "a worked solution".
The trace is data, not DOM. The UI renders it with KaTeX in a
numbered list; the plain-English explainer and the "explain as image"
feature both consume the same trace structure. One source of truth,
three consumers.
Unary minus is the classic gotcha: -3^2 parses as -(3^2), and the
trace must say so, not silently agree with whatever the user meant.
2. Autocorrect: the "6 pls 2" story
Natural-language input in a calculator is either a tiny rule engine or
[Insert: fralculatorScreen_1.png — the graph tab, f(x) with 600 samples]
Natural-language input in a calculator is either a tiny rule engine or
an LLM. Offline means rule engine. And rule engines get weird input.
Testing the voice path, the Whisper transcript of someone saying "six
plus two" came back as 6 pls 2 — the model heard a word it knew.
Rather than special-case transcripts, I added word-shorthand rules
to the typed pipeline too:
// Word shorthands — applied only as whole words, case-insensitive.
// They run BEFORE numeric normalization so "6 pls 2" → "6 plus 2".
const WORD_SHORTHANDS: ReadonlyArray<[RegExp, string]> = [
[/\bplus\b/i, "+"],
[/\bpls\b/i, "+"],
[/\bminus\b/i, "-"],
[/\bmin\b/i, "-"],
[/\btimes\b/i, "×"],
[/\bmultiplied\b(?:\s+by)?/i, "×"],
[/\bdivided\b\s+by\b/i, "÷"],
[/\bto\b\s+the\b\s+power\s+of\b/i, " ^ "],
[/\bpercent\b/i, "%"],
];
The rules run in a specific order: shorthands first, then numeric
normalization (5x3 → 5×3, ** → ^, . at end of string), then
a single pass of balanced-parenthesis repair. Every edit is
accumulated into a human-readable description ("6 pls 2" → "6 plus 2" → 6 + 2) that the UI shows under the display — because in a math tool,
silently changing the user's input is a bug, not a feature.
The same aliases back the natural-language regexes (what is, solve,
compute, "x squared", "square root of"), so typed, voice, and
autocorrected input all converge on one grammar.
3. Offline voice: Whisper-tiny WASM in an Electron worker
Voice input uses the transformers.js WASM build of Whisper-tiny. The
layout in Electron:
The main renderer calls getUserMedia, records chunks, and hands a
Float32Array (resampled to 16 kHz) to a dedicated worker thread
— decoding a WASM model on the main thread would stutter the UI.
First run downloads the ~40 MB model; subsequent runs read it from an
IndexedDB cache. This is the app's only big download, and it's
announced in the UI before it happens.
The transcript goes through the same autocorrect pipeline as
typed input. Voice never gets special treatment in the math engine —
it's just text with more typos.
The payoff: after first launch, audio bytes leave the machine zero.
Every voice result auto-saves to history tagged with a mic icon,
searchable alongside typed entries.
[Insert: fralculatorScreen_2.png — history with voice-tagged entries]
4. Why a calculator ships a subnet calculator
The most surprising tab is EHCalc, a small security toolkit built
because I'm studying cybersecurity and kept wanting reference tools at
arm's length:
Subnet calculator (CIDR/mask → network, broadcast, wildcard, usable
hosts, range, class)
MD5 / SHA-1 / SHA-256 / SHA-512 via WebCrypto
10 encoders: Base64 ⇄ Hex ⇄ URL, ROT13, Atbash, ASCII ⇄ (with a
direction-aware error message when you paste text into a decode box)
Password strength (entropy, crack-time tiers) + a CSPRNG generator
using crypto.getRandomValues() with rejection sampling per character
class — and it never writes anything to storage, by design
Port lookup with risk notes (Telnet's clear text; SMB's… history)
Architecturally it's the same lesson as the math engine: pure,
testable functions with zero DOM imports, wrapped in small presentational
components. The unit tests for the encoders and subnet math are the
part I'd show an interviewer.
[Insert: fralculatorScreen_main2.png — the EHCalc subnet calculator]
5. Packaging: five installers, no backend
electron-builder produces NSIS + portable .exe on Windows and
AppImage + deb + rpm on Linux from the same workflow. CI runs
npm ci → tsc → vitest → vite build, then the release workflow builds
on both ubuntu-latest and windows-latest and uploads via
softprops/action-gh-release.
Honest caveat: the Windows binaries are currently unsigned (a code
signing cert is a real cost for a solo student project) — Windows
SmartScreen will warn. On Linux, AppImage is the recommended start.
What's next
The roadmap's headline item is the backend the code already reserves a
slot for: a local LLM (e.g. a small GGUF model via WASM/ONNX) for
the explanation and natural-language paths — still offline, still no
account. Until then, the rule engine is what you get, and it's decent.
Fralculator — MIT licensed, offline-first, Windows + Linux:
github.com/fralsare/fralculator
If you build offline-first Electron apps, the two things I'd tell you:
put the heavy model work in a worker, and make autocorrect tell the
user what it did.
Built this solo between classes. Issues and PRs are welcome; if you
use it for study, there's a donation link in the README that funds my cybersecurity coursework.
Top comments (0)