Skip to content

Latest commit

 

History

History
203 lines (169 loc) · 10.4 KB

File metadata and controls

203 lines (169 loc) · 10.4 KB

Contributing

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.

Read this before opening an issue

This project has a written scope. Requests outside it are closed without much discussion, however good the idea.

Not planned

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.

Welcome

  • 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

Conventions that are load-bearing

  • lib/localStore.ts is 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. MapCanvas takes a SaveAdapter prop. 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.ts and 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 ResizeObserver on the real node overrides it. Never pin a node's width to the prediction, and never remove a node from layout with display: none.
  • View state never mutates the document. Search reveals its matches by handing layout() a forceExpanded set for one pass, and focusing a branch passes a focusRoot the same way. Writing collapsed: false instead 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 in index.css and run npm test; it names the pair, the theme and the ratio.
  • A string that appears in two places lives in one module. lib/formats.ts holds 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.

Copy

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.

Things that look wrong until you know why

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 pointerdown also redirects the compatibility mouse events, so click is 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: hidden removes it from the accessibility tree and makes it unfocusable — and lifting selects the node. display: none does that and collapses the box to 0x0, which the ResizeObserver duly 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 readwrite transactions over overlapping stores, so the check and the write have to sit in the same one. seedIfEmpty reads 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.
  • rev guards the document and nothing else. It is bumped inside the writing transaction, and the pre-flight before every write is built on it. renameMap deliberately 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 than x={x}, because exactOptionalPropertyTypes is on and an explicit undefined is not the same as an absent property.
  • will-change: transform on 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: wheel has no end event, so nothing else can say when a trackpad gesture is over. Canvas.dom.test.tsx proves the hint is scoped — it cannot prove anything about sharpness, because jsdom has no rasteriser.

npm audit reports one high, and it is expected

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.

Running it

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 claim

Tests, 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.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.
  • 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, never userEvent. userEvent's awaited driver deadlocks against vi.useFakeTimers(). Three files run on fake timers — Canvas.dom.test.tsx, Persistence.dom.test.tsx and Reparent.dom.test.tsx — and each records the reason at the top.

Documentation

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.

Pull requests

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.