Someone I know has a kid who doesn't talk yet, and can't read yet either. The diagnosis is autism. What works for him, right now, is a tile you touch with a finger, and it says the word for him. I got asked whether I could build that.
Serious AAC (augmentative and alternative communication) apps already exist. Proloquo2Go, TouchChat, LAMP Words for Life: vocabularies of several thousand words, built by speech therapists over years, priced between 150 and 300 dollars on iOS, up to 550 pounds for Grid 3 on desktop. Free and open alternatives exist too, CBoard (backed by UNICEF) and AsTeRICS Grid (University of Applied Sciences Vienna), both serious and actively maintained. None of that was missing from the world. What was missing was a simple grid, right now, for one specific kid, with nothing to get through before a finger can hit a tile.
I spent a weekend, then several weeks, building Mes mots: Vue 3, TypeScript, no server, no account. The code is on GitHub under the AGPL license. Here's what building it taught me, and why it doesn't look like a typical AAC app.
A tile that never moves
The spec fit in a conversation, not a document. A kid who can't read yet learns by the position of his finger, not by the word printed under the picture. If the "eat" tile moves to a different corner from one day to the next because a word got added elsewhere, the kid starts over from zero. That constraint drove the whole board architecture: tiles get added, they never reorganize themselves.
That's not a claim dropped into a README. It's checked by tests, and by a mutation testing harness that makes sure those tests actually bite, more on that below.
Nothing leaves the tablet
Second constraint, just as non-negotiable as the first: no data leaves the device. The photos added to a tile are the family's own, not generic pictograms. The voices recorded are the family's own, a kid recognizes his mother's voice before he recognizes a word. Sending that to a server, even encrypted, even for a perfectly harmless service, was out of the question.
Concretely, everything lives in IndexedDB, four separate object stores accessed through a single local repository. The code comment spells out exactly what that means:
/**
* Local configuration repository (requirement T1). Nothing leaves the tablet.
*
* Image and sound stores are keyed by tile id: a family photo or voice, never
* the configuration itself, which only carries a text reference
* (`perso/<tileId>`, see `customIdentifier` in planche.ts).
*/
interface SchemaMesMots extends DBSchema {
config: { key: string; value: Configuration | ConfigurationV2 | Planche | number }
images: { key: string; value: Blob }
sons: { key: string; value: Blob }
journal: { key: number; value: EntreeJournal }
}
No account, no sync, no automatic cloud backup. The trade-off, acknowledged and reminded by
the app itself, is that the family has to back up by hand. The backup format isn't proprietary:
it's an .obz file, the
Open Board Format
standard, readable by other AAC apps the day the family needs to switch tools.
The app runs as a PWA, installable and usable in airplane mode. That deserves its own article, the pitfalls of an app that never asks for the network, and how it still gets updated. That's next.
Vue 3 with zero UI libraries
No component library, no CSS framework. Every button, every tile, every transition is
hand-written, in <style scoped> per component. Not out of purism: for a
grid of about fifteen tiles and two screens (child, parents), a component library adds more
code to maintain than time it saves, and it imposes its own default accessibility where this
one needed to be designed for a specific use case. CSS you write yourself, you know exactly
what it does and why.
A mutation harness, because a green suite proves nothing
611 Vitest unit tests, 234 Playwright end-to-end tests, across two profiles: an 800×1280 tablet and an iPhone 13 on Safari. On paper, comfortable coverage. Except a fully green suite only proves nothing broke, not that something is actually being checked.
A typical unit test calls a function, compares the result to what it expects, and turns green
as soon as the call succeeds, even if the assertion barely checks anything. The only way to
know whether a test actually bites is to deliberately break the code it's supposed to protect,
and check that the test goes red. That's what mutation testing does, and that's what
mutation.sh does in the project: one mutation per backlog task, not per file,
pattern by pattern.
The comment at the top of the script explains why it exists: "at step E1 the audio interruption could have vanished entirely without a single check flinching." In other words, a full regression on interrupting audio, not a detail, an entire behavior, had slipped through more than 600 green tests. The matching mutation pattern caught it.
The script has its own set of traps to stay honest. The first one: a forgotten
vite preview server on port 4173 would keep serving the old code on every
mutation, and every mutation would "pass" as caught while the tests actually run against code
that never changed.
# A forgotten server on the port would serve the old code and the mutation would pass as caught.
if ss -lptn 'sport = :4173' 2>/dev/null | grep -q LISTEN; then
echo " port 4173 already in use, end-to-end mutations would be skewed"
exit 1
fi
The second trap is subtler: a mutation that breaks compilation leaves dist/
untouched, so vite preview keeps serving the old bundle. Without a
vite build before every end-to-end attempt, the harness would blame the tests for
missing the mutation, when it was really the server serving code that never got rebuilt.
What it doesn't do, and why that matters
No eye tracking, no switch access, it's used with a finger. No vocabulary of several thousand words built over years by speech therapists, the way Proloquo2Go or LAMP Words for Life carry. No grammar, no conjugation: you place words, you don't build complex sentences. No multi-device sync, by choice, not by oversight.
It's also not a medical device or a treatment. An AAC app is chosen together with a speech therapist, who knows the child and can say whether a grid of pictograms fits, and which ones. This app was written for one specific child, based on needs his family described. I'm publishing it because it might serve someone else, not because it would suit everyone.
Conclusion
The hardest part wasn't Vue, or IndexedDB, or even the mutation harness. It was resisting the urge to add more: more words, more options, more settings. A kid who can't read yet gains nothing from a grid of two hundred tiles. He needs ten, that never move.
The code is out there if someone else needs it. So is the demo.