Patterns
A product is several apps over shared stores
The instinct from SPA work is one app with a client-side router. It is the wrong shape here and it costs real rework, so it is worth stating plainly.
Apps ARE the composition primitive. The environment serves each at /app/<env>/<App>, and every app in an environment shares the same stores. So a learning product is not one JapaneseStories with case model.screen; it is:
Dashboard writes Word, writes Story, calls AI (the workbench)
Reader reads Story, reads Word (focused reading)
Review reads Word, writes Word (study loop)
Each app is a small TEA loop with almost no branching. The graph then shows the system honestly: which app can touch what, and which is read-only (provably — it has no write edge).
Why the monolith actively hurts: navigation state (screen, reading, reviewing) has to live in the Model, but Model fields must be defaultable, so you cannot use a clean Screen enum and end up with stringly-typed nav in persisted state. Splitting deletes that problem rather than working around it.
Cross-app navigation
A relative single-segment href resolves to a sibling app in the same env:
<a href="Reader">Read your stories</a> # from /app/<env>/Dashboard
Standalone this is a normal navigation. Embedded in the console graph, the app page reports the intent to the console, which moves the camera to that app's node instead of loading another app inside a card. The graph shows these as nav edges, derived from the anchors in your views — so the map tells you where a user can go, not just where data flows.
Screens without an enum
When you genuinely need modes inside one app, express them with defaultable types and keep the branching in one place:
defmodel App.Model do
# ... durable-ish session fields
open : Option<Uuid> # a detail view: Some(id) is "open on this row"
editing : Bool
status : String # "" | "saving" | "analyzing" — also drives loading UI
end
def App.view(model : App.Model) : Html do
<div>
{case model.open do
Option.Some(id) -> App.detail(model, id)
Option.None -> App.list(model)
end}
</div>
end
status : String doubles as the loading flag — set it when you fire the effect, clear it in the handler, and branch on it in the view so a long call shows a spinner instead of a dead button.
List / detail over a store
The detail view filters the already-loaded rows rather than issuing a get: fewer effects, and the list stays the single source of truth.
def App.detail(model : App.Model, id : Uuid) : Html do
<div>
<button on-click={App.Msg.Close}>Back</button>
{List.map(List.filter(model.rows, fn (r : App.Row) -> r.id == id end), fn (r : App.Row) ->
<article><p>{r.text}</p></article>
end)}
</div>
end
Per-item handlers
An event attribute takes a fully-applied Msg, so binding an id per row works:
<button on-click={App.Msg.MarkDone(r.id)}>Got it</button> # applied: carries the id
<input on-change={App.Msg.SetName} /> # a constructor ref: gets the value
on-click wants a Msg value; on-change wants the constructor, which is applied to the input's value for you.
Loading rows on boot, then chaining
init fires one read; each arrival can trigger the next. This is the idiomatic way to hydrate several stores.
def App.init() : (App.Model, Cmd<App.Msg>) do
(%App.Model{...}, Cmd.store.list(fn (rows : List<App.Word>) -> App.Msg.WordsLoaded(rows) end))
end
App.Msg.WordsLoaded(rows) ->
(%App.Model{model | words: rows},
Cmd.store.list(fn (ss : List<App.Story>) -> App.Msg.StoriesLoaded(ss) end))
The model a read targets is inferred from the callback's element type — that is how store.list knows which store to read.
Styling
Views are JSX over an allowlist, styled with Tailwind utility classes. No <script>, no on* attributes, no javascript: URLs — the renderer rejects them. Write real design (spacing, hierarchy, states), not a bare prototype.