Demo: view here
Runs entirely in your browser.
No login | Easy Backups | Works offline | No tracking
Dump thoughts into a branching tree, rearrange them, come back later. No account, no server, no tracking. Your maps are stored in your browser's IndexedDB and the app makes no network requests at all after the page loads — open the Network tab and check.
Features: see here
Most of these would need the server this app's value depends on not having.
- No accounts, sharing, collaboration, presence, or sync between devices
- No cloud storage, backend, or serverless functions of any kind
- No AI or LLM features
- No analytics, telemetry, cookie banners, or onboarding tours
- No PNG / SVG / PDF export
- No images, attachments, or rich text inside nodes
- No free-form node positioning — the layout is always computed
- No themes beyond following your system's light/dark setting
If you want one of these, forking is the right answer; the licence permits it. Read CONTRIBUTING.md before opening an issue asking for one.
- Fast to think in.
Tabfor a child,Enterfor the next sibling. - Good on a phone. Built for a small screen first. Drag to rearrange, hold to lift, focus a single branch when the map outgrows the view.
- Paste a list, get a branch. Select a node, paste an indented outline — bullets optional, any indentation — and it becomes a subtree, in one undo step.
- In and out as JSON or Markdown. JSON keeps everything and is how a map moves between devices; Markdown is a lossy outline for getting text elsewhere. One map or all of them.
- Hard to lose. Autosave, a crash-recovery draft, one snapshot per map per day for 30 days, and a one-button backup of everything to a single file.
- Keyboard-complete on desktop, including reparenting.
Local-only costs something:
- Your maps are on one device, in one browser. They don't follow you. Restoring a backup elsewhere is how you move them.
- Clearing site data deletes them. So does uninstalling the browser.
- A browser short of disk space may evict them. Settings asks it not to via
navigator.storage.persist()and reports whether it agreed — browsers often decline a first-time visitor.
Settings → Download a backup writes every map to one JSON file. For one map, the download button on its card in the maps list and ⋯ → Download as JSON in its header write the same format, and all of them go back in through Restore or import maps.
npm install
npm run dev| Script | What it does |
|---|---|
npm run dev |
Dev server |
npm test |
Pure logic in node, behaviour in jsdom |
npm run typecheck |
tsc, no emit |
npm run build |
Type-check, build, prerender the content pages, write sitemap.xml and robots.txt |
npm run verify |
Check the built output against the privacy claim (run after build) |
npm run preview |
Serve the production build locally |
Vite + React 18 + TypeScript + Tailwind. Six production dependencies, no state-management library, and no graph library — the tidy-tree layout engine is hand-rolled, which is what makes the tapered branch edges possible.
npm auditreports one high-severity advisory against react-router. It is expected and unreachable here; don'tnpm audit fix --force, and see CONTRIBUTING.md for why.
src/
lib/ idb.ts promise wrapper over IndexedDB
localStore.ts the ONLY module that reads or writes map data
doc.ts the document model, validateDoc, serialize
layout.ts pure tidy-tree engine + branch geometry
vault.ts the JSON backup format — a whole vault, or one map
snapshots.ts daily-snapshot rules
markdown.ts outline import/export, and the paste parser
site.ts SITE_URL and the indexed-page metadata
state/ saveState.ts the pure save machine (debounce, conflict, retry)
useAutosave.ts the only impure half: clock and storage
mapState.ts document + history + edit session, as one reducer
routes/ Landing, About · MapsList, MapView, ArchivedList, Settings
prerender.tsx renders the content pages to HTML at build time
Four hand-offs, each with a pure half that holds the rules and a thin impure half that owns the clock, the DOM or the store:
mapState.tsturns the edit into a newMapDocthroughreducer.tsand pushes it ontohistory.ts.useMapDocis the React binding, and holds no rules of its own.saveState.tsdecides whether to write and when — debounce, retry, one save in flight, pause on conflict — as a reducer plus a singlenextAction(state, now).useAutosave.tsowns the timers, the four force-save triggers and the calls themselves. It decides nothing.localStore.tswrites, inside one transaction, bumpingrev.
rev is the conflict token: a counter rather than a timestamp, so no clock skew
or coarse resolution can confuse it. Every write pre-flights against it. A moved
rev is only suspicion — two tabs that independently arrive at the same text are
not in conflict — so a suspicion is settled by comparing the stored document
against the last one confirmed written. Renaming a map deliberately does not bump
rev, because the name is not part of the document.
Three stores, and the split between them matters:
| Where | What | Why there |
|---|---|---|
IndexedDB maps |
The row, its document serialized to a string | The store of record. A string rather than an object, so the dirty check can compare bytes |
IndexedDB snapshots |
One document per map per day, 30 kept | For a mistake noticed days later. Written after a save, never awaited by one |
localStorage mindmaps.draft.{id} |
The in-flight document | The only API that can finish during pagehide. Cleared solely on a confirmed save |
localStore.tsis the only module that touches stored map data. A different backing store means editing one file.- Storage is injected into components, never imported.
MapCanvastakes aSaveAdapterprop. That is the only reason its behaviour is testable, and every integration bug this project has had lived in that component tree. - Rules live in pure modules; timers, storage and the DOM live in a thin
binding around them.
layout.ts,saveState.ts,reparent.tsand friends have no side effects and are tested directly. - Layout measures what is actually painted. A hidden mirror predicts a node's
size and a
ResizeObserveron the real node overrides it. Never pin a node's width to the prediction — that makes the prediction unfalsifiable. - View state never mutates the document. Search reveals its matches by
handing
layout()aforceExpandedset for one pass, and focusing a branch passes afocusRootthe same way. Writingcollapsed: falseinstead would make typing in the search box an edit — an undo entry, a dirty document and a stored write, for something the owner only wanted to look at.
Tests split by filename: *.test.ts runs in node, *.dom.test.tsx in jsdom.
jsdom has no layout engine, so DOM tests prove behaviour and never geometry;
geometry belongs in layout.test.ts.
MIT — see LICENSE.