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:
- App state — the persistent source of truth (user,
friends, contacts, the ledger, outbox)
held in memory and saved to IndexedDB. Owned by
app-state.js; see P2P. - Location — the ephemeral "where am I" held in
window.location.hash(e.g.#friends,#friend/<id>). Not persisted; it only selects which page to show.
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:
- A command mutates
stateand callspersistAndBuildView(state). - That normalises the state, saves it to IndexedDB, then calls
emitDataChange(state). emitDataChangerunsbuildView(state)— the pure derivation of a read-only, sanitised view (balances totalled, private keys stripped) — and fans it out to every subscriber registered viasubscribeToDataChanges.- 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:
- fetches the page's HTML template (cached) and calls
loadData()— the currentbuildView; - builds a fresh detached DOM for the page;
- sets route UI state — document title, active nav item, subpage flag — and refreshes the nav badges;
- calls that page's binder (
bindTally,bindFriends,bindFriendDetail, …), handing it the viewdatato populate the DOM and wire event handlers; - 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:
- Navigating = setting the hash. Nav buttons, in-page
links, and post-action redirects (e.g. creating a user jumps to
#tally) all just assignwindow.location.hash. - A
hashchangelistener records the hash in a small history stack (for Back) and callsloadActiveRoute()— the same entry point the data-change path uses. parseRoute()maps the hash to a route object: a main page (#tally,#friends,#logs,#settings), a subpage (#friend/<id>,#record/<id>,#trust/<id>,#add-friend,#request,#transfer), or#welcome. Unknown hashes fall back to Tally.resolveRouteForUserStateguards routes — with no user, any route redirects to#welcome.navigateTodoes smart Back: if the target hash is already in history it steps back to it (history.go) instead of pushing a duplicate, so tab-hopping doesn't pile up entries.getSlideDirectioncompares the old and new route against the nav order to pick the transition's slide direction; swipe gestures map to the same left/right navigation.
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.