Thanks for looking. This is a small tool with a fixed scope, so the most useful contribution is usually a bug report rather than a feature.
This project has a written scope. Requests outside it are closed without much discussion, however good the idea.
Ruled out by design. Most would need the server this app's value depends on not having.
- Multi-user, sharing, collaboration, presence, or real-time sync
- Login, accounts, or user management
- Any server component, backend, or hosted storage
- Free-form node positioning — the layout is always computed
- Images, file attachments, rich text, or Markdown rendering inside nodes
- Themes beyond the light and dark modes already measured
- PNG / SVG / PDF export
- AI or LLM features of any kind
- Analytics, telemetry, cookie banners, onboarding tours, or first-load modals
- Animations beyond the small set already in place
Forking is the right answer for any of these, and the licence permits it.
- Bug reports, especially with a reproduction
- Accessibility problems — treated as bugs, not enhancements
- Correctness problems in the layout engine, the document invariants, or the save path
- Documentation that is wrong or out of date
- Browser-specific breakage, particularly around storage durability
lib/localStore.tsis the only module that reads or writes 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. New behaviour goes in the pure half, where the tests can reach it. - 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, and never remove a node from layout withdisplay: none. - 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, with an undo entry and a stored write. - Production dependencies are capped at ten and currently number six. Adding one needs a reason in the pull request.
- Colours are checked, not eyeballed. Every pair in the palette — both
themes, including the blend an inherited swatch composites to — is asserted
against its WCAG floor in
lib/contrast.test.ts. Change a hex inindex.cssand runnpm test; it names the pair, the theme and the ratio. - A string that appears in two places lives in one module.
lib/formats.tsholds the export-format descriptions because the map's ⋯ menu and Settings are two doors onto the same exports, and they had already drifted into describing them differently.
Several comments in the codebase explain why a line is the way it is. They are there because the obvious alternative was tried and broke something — read them before simplifying the code they sit above.
No first person in anything a user reads — not "we", not "I". Product copy
describes the tool, never its author. It does not position itself against other
products, states trade-offs without announcing its own honesty, and carries no
maxims: a line that reads well but gives the reader nothing to act on belongs in
a commit message. This covers the landing page, /about, the metadata in
lib/site.ts, the seed map and every in-app string.
Each of these has an obvious-looking alternative that breaks something. The comment above the line says so too; this is the list you get before you go looking.
- Pointer capture is taken lazily, once a gesture is definitely a pan.
Capturing on
pointerdownalso redirects the compatibility mouse events, soclickis dispatched to the common ancestor of the down and the up — the surface, not the node. That silently breaks selection, double-click-to-edit, and every keyboard action that needs a selection. - The pan-to-lift hand-off goes through a mutable claim object, not state. Touch pans for the first moment and then hands the same pointer to a lift. A React state flag is one frame late, which reads as the map jumping as the node comes up.
- A lifted subtree is hidden with
opacity: 0.visibility: hiddenremoves it from the accessibility tree and makes it unfocusable — and lifting selects the node.display: nonedoes that and collapses the box to 0x0, which theResizeObserverduly reports and the layout believes. - Three separate things stop the measure pass looping, and it needs all
three: a per-id cached ref callback (an inline arrow has a fresh identity every
render, so React reattaches and re-measures), an idempotent
observe(ResizeObserver.observe()fires once per call, so re-observing an element emits a spurious measurement), and a half-pixel epsilon on size changes. - A guard that reads and writes in separate transactions is not a guard.
IndexedDB serialises
readwritetransactions over overlapping stores, so the check and the write have to sit in the same one.seedIfEmptyreads its flag and writes both the flag and the maps in a single transaction. Split across two, both of two concurrent callers clear the check before either writes, and the browser opens with two identical welcome maps — which StrictMode's double-invoked effects produce reliably, and two tabs produce in production. revguards the document and nothing else. It is bumped inside the writing transaction, and the pre-flight before every write is built on it.renameMapdeliberately does not bump it — making a rename look like a document change would pause an open editor in another tab over a title that editor doesn't hold.- Optional props are passed by conditional spread.
{...(x !== undefined ? { x } : {})}rather thanx={x}, becauseexactOptionalPropertyTypesis on and an explicitundefinedis not the same as an absent property. will-change: transformon the canvas is held only while a gesture runs, and dropped ~180ms after the last event. Setting it once and leaving it there is the obvious optimisation and is what made every node's text soft: the hint promotes the surface to its own compositor layer and declares that the transform will keep changing, so the layer is rasterised once and then scaled by the GPU rather than re-rasterised at the new zoom. Text becomes a stretched bitmap, and composited layers lose subpixel antialiasing on top of that. The trailing window is not tunable padding:wheelhas no end event, so nothing else can say when a trackpad gesture is over.Canvas.dom.test.tsxproves the hint is scoped — it cannot prove anything about sharpness, because jsdom has no rasteriser.
react-router high RSC Mode CSRF Bypass Allows Action Execution Before 400 Response
GHSA-qwww-vcr4-c8h2 fix available: react-router@8 (breaking)
Do not run npm audit fix --force. It will move you to react-router 8,
which requires React ≥19.2.7 — a framework upgrade, to patch code this app
cannot reach.
The advisory is specific to RSC (React Server Components) mode. This app has
no server, no server actions and no RSC: routing is createBrowserRouter in a
static SPA, so there is no action to execute and no request to respond 400 to.
The vulnerable path is not merely unused, it is unreachable.
The pin stays at ^7.18.2. It is also not a "just don't upgrade" position — 7.x
is where two genuinely reachable moderate advisories from 6.x were fixed (an open
redirect via a backslash in <Link>/useNavigate, and SSR hydration constructor
injection), so staying current within 7 matters.
Revisit only if this app ever gains a server, or if React 19 is adopted deliberately for its own reasons. The audit output is deliberately not suppressed: a filter here would hide the next advisory too, and that one might be real.
npm install
npm run dev
npm test # pure logic in node, behaviour in jsdom
npm run typecheck # tsc, no emit
npm run build # build, prerender, sitemap, robots
npm run verify # check the built output against the privacy claimTests, typecheck and build should all be clean before you open a pull request.
npm run verify is a constraint on what may be added, not merely a check that
the build ran. It fails on any third-party host appearing in the built output, a
font loaded off-origin, a content page that did not prerender, an app shell with
content in it, or anything shaped like a credential. A dependency that phones
home, or a <link> to a CDN, is caught here — which is what keeps the landing
page's claim true by construction rather than by memory.
Three things about the test suite worth knowing:
- Filename decides the environment.
*.test.tsruns in node,*.dom.test.tsxin jsdom. jsdom has no layout engine, so DOM tests prove behaviour and never geometry — geometry belongs inlayout.test.ts. - Storage races need concurrent tests. Calling a store function twice in sequence proves nothing about two tabs, or about an effect React invokes twice. More than one bug here has been invisible to a sequential test.
- A test on fake timers drives the DOM with
fireEvent, neveruserEvent. userEvent's awaited driver deadlocks againstvi.useFakeTimers(). Three files run on fake timers —Canvas.dom.test.tsx,Persistence.dom.test.tsxandReparent.dom.test.tsx— and each records the reason at the top.
README.md and this file stay present-tense and final. They describe how the
project is, never how it got there — no progress logs, no phase histories, no
records of what changed. That belongs in the commit message, where it is
attached to the diff it explains.
Keep them small and single-purpose. Explain why in the description — the reasoning is the part that is hard to recover later. Behaviour changes need a test.