Render flow

How the screen updates. Everything — a command mutating state, an inbound peer message, a presence change, or the user navigating — converges on one render path that rebuilds the current page from a freshly derived view. There is no virtual DOM and no fine-grained reactivity: it re-renders the whole active page, which stays simple and predictable for an app this size.

Two kinds of state

The UI is a function of exactly two inputs:

The render is (app state × location). A change to either re-runs the same path below, so what you see always reflects both.

From data change to render

When state changes — the local user acting, or an inbound durable message applied during sync — the sequence is:

  1. A command mutates state and calls persistAndBuildView(state).
  2. That normalises the state, saves it to IndexedDB, then calls emitDataChange(state).
  3. emitDataChange runs buildView(state) — the pure derivation of a read-only, sanitised view (balances totalled, private keys stripped) — and fans it out to every subscriber registered via subscribeToDataChanges.
  4. The app shell's subscriber is scheduleRouteRefresh, which debounces (a zero-delay timer coalescing a burst of changes into one) and then re-renders the current route.

Peer presence (the green/amber/grey icon) and relay-status changes also feed scheduleRouteRefresh — via subscribeToPeerStatusChanges and a re-emit from the relay registry — so connectivity updates re-render through the exact same path, without touching persisted state.

Rendering a page

Whether triggered by a data change or a navigation, rendering funnels through loadActiveRoute() → loadPage(route), which:

  1. fetches the page's HTML template (cached) and calls loadData() — the current buildView;
  2. builds a fresh detached DOM for the page;
  3. sets route UI state — document title, active nav item, subpage flag — and refreshes the nav badges;
  4. calls that page's binder (bindTally, bindFriends, bindFriendDetail, …), handing it the view data to populate the DOM and wire event handlers;
  5. swaps the new page in with an animated transition.

Binders receive the whole view and rebuild their page's DOM each time — the UI holds no state of its own and never mutates app state directly; it only reads the view and calls commands. A navigationSequence guard drops any render whose newer navigation has already superseded it.

The URL: routing & navigation

Location is the hash, and it is the only thing that decides which page renders:

Page transitions

swapPage owns the slide — 320 ms, set once as the CSS variable --page-transition-duration in page-transitions.css and read from there by everything that has to wait for it: it mounts the incoming page offscreen, commits a paint, then animates the outgoing page out and the incoming page in, cleaning up when the transition ends. It sequences swaps so rapid navigation can't leave two pages half-animated.

Scroll position

The shell owns the viewport and never scrolls: the header and nav are fixed, and each page view is its own scroll container (overflow-y: auto). That is what makes the transition behave — the outgoing page keeps the offset the reader left it at while it slides away, and the incoming page, being a fresh element, starts at its top. No scroll correction is needed, and nothing can jump when the old page is removed.

One case does need help: re-rendering the same page (a data change or presence update) also builds a fresh element, which would silently send the reader back to the top. swapPage therefore carries the previous page's scrollTop across, re-applying it for a few frames because binders that finish asynchronously can grow the page after it mounts and clamp the first assignment short. app.js picks between the two by comparing route identity.

Testing note: after any navigation, wait out the transition before asserting on rendered output (or read the DOM directly, which is correct even mid-animation).

In one line

command → persist → buildView → notify → (debounce) → loadActiveRoute → loadPage → bind → swapPage, with the hash selecting which page loadPage builds — and a URL change entering the same pipeline at loadActiveRoute.