Quick Start
1
The composition root builds everything concrete
createApp takes its adapters injected, so the whole boot runs under test against fakes.2
The session is bridged to the engine with persistenceFor
boot.ts builds the session first, then hands it to the engine factory through the named adapter persistenceFor.3
Decide what changes (pure)
screenFor(route) maps a route to a ScreenId, and transition(from, to, live) returns a ScreenChange describing what to mount, hide, and remove. No DOM is touched here, so the decision is tested without a browser.4
Apply it to the page (thin)
createScreens(host).apply(change) mounts, hides, and removes nodes according to the ScreenChange. It mounts the next screen before hiding the current one, so there is never a blank frame.5
Read the layer graph
6
Check the boundaries
tools/depgraph.mjs reports every import that crosses a line it should not.Boot Order
The boot sequence runs in one order, and that order exists because the in-process engine writes through the session.The Route→Screen Seam
Navigation splits into a pure half and a thin half so the part worth testing has no DOM in it.
A
ScreenChange carries the plan: show, mount, unmount, hide, and noop. A retained screen appears in hide, never unmount — its nodes stay so scroll position and streaming survive.
A chat-to-chat move is a content change, not a screen change —
transition returns noop: true so the transcript is not rebuilt on navigation within the same screen.The Persistence Seam
The engine writes a completed turn through the same session the UI reads, joined at one named adapter.persistenceFor(session) in core/src/chat/session.ts is the one place the engine’s RunPersistence vocabulary and the Session vocabulary meet. The engine calls record(request, answer); the adapter forwards request.prompt to session.record(prompt, answer). Naming the adapter — rather than inlining a lambda at the call site — keeps this seam findable.
The wiring is enforced by the type: AppDeps.engines is a factory (persistence) => EngineChoice[], built from the session. There is no way to obtain the engine list without being handed the store engines write through.
Streaming Pacing
Tokens flow through a coalescer that flushes either when it has enough bytes or after a short delay, so short answers still paint incrementally. The coalescer paints on whichever bound is hit first.How It Works
Each layer declares what it may import.app sits at the top and wires everything; protocol sits at the bottom and imports nothing.
Why a Factory, Not an Array
AppDeps.engines is a factory whose type refuses a pre-built list.
persistence port existed, and nothing connected them — so record() never ran in a real turn and no conversation was ever saved. Taking a factory makes that impossible to express: there is no way to obtain the engine list without being handed the thing engines write through. The wiring is enforced by the type, not by a comment.
persistenceFor(session) is a named adapter in core/src/chat/session.ts, not an inline lambda. It is the one place the two vocabularies meet — Session.record(prompt, answer) versus RunPersistence.record(request, answer) — and a lambda buried in composition is a seam nobody can find later.Why Build-Enforced
A rule enforced only by review stops being enforced.tools/depgraph.mjs runs in CI (.github/workflows/mobile.yml) and fails the build on any crossing.
engines/src/praisonai-ts may import praisonai. Only adapters/src/tauri may import @tauri-apps/*. Everything above the seams is written against ports and cannot tell one implementation from another.
Choose Your Extension Point
Which directory you touch depends on what you are adding.Common Patterns
Boot fails loud when an engine cannot hold the contract.Best Practices
Build the session before the engine list
Build the session before the engine list
The in-process engine writes through the session, so a pre-built engine list cannot carry a live persistence. Always call
createSession first, then deps.engines(persistenceFor(session)).Name the bridge, do not inline it
Name the bridge, do not inline it
persistenceFor is exported by name. Inlining the adapter as a lambda at the call site hides the one seam where the session and the engine vocabularies meet.Keep the composition root injectable
Keep the composition root injectable
createApp takes every adapter as a parameter. A composition root that constructs its own dependencies is the one part of an app that can never be tested — and it is where ordering bugs live.Keep decisions out of the DOM layer
Keep decisions out of the DOM layer
Anything that decides what to keep or destroy belongs in
screens.ts as a pure function. mount.ts only carries out the plan, so a test can inspect the plan without a browser.Mount before hide
Mount before hide
Always mount the next screen before hiding the current one. The reverse order shows a blank page for one frame on every navigation.
Trust the persisted index, not the screen
Trust the persisted index, not the screen
A cancelled or errored turn stays on screen but is never written, so screen position and disk position diverge. Read the index the writer reports in
end, and treat null as “not on disk”.Add, never reach across
Add, never reach across
A new framework is a directory under
engines/src plus a conformance run — never an edit above the seam.Keep ui/ framework-free
Keep ui/ framework-free
ui/ returns descriptions of what to render, so a React Native port reimplements only the renderer and reuses everything else.Let CI hold the line
Let CI hold the line
Run
npm run boundaries locally; the same gate runs on every push and PR.Test pacing against a fake clock that ticks
Test pacing against a fake clock that ticks
A
TimePort.every fake that returns () => () => {} is worse than no test — it never fires the tick, so every test using it passes for the wrong reason. Pacing must be driven by a fake clock that actually advances, or a coalescer whose delay bound was never wired looks correct in green.Related
Overview
Retained chat and native navigation.
Engines
Which engine owns the write, and why only one does.
Shell & Adapters
The keyboard snapshot and the pinch-zoom guard.
Capabilities & Gaps
What each engine can and cannot report.
Native Shell
The Tauri shell — safe-area, keyboard, lifecycle, and back-gesture arbitration.
Shell & Adapters
The UI-shell seam in detail.
Protocol
The 11 events every engine speaks.

