The Elm architecture — reasoning
Class:
reasoning. This file holds the why, the trade-offs, and every design we rejected. The descriptive shape we actually run is inelm-design.md, which by rule mentions none of this. If you want to know "why not X?" — X is here.
Why an Elm loop at all
The goal was never architectural purity — it was testing a GUI without a GUI. If a component's whole observable truth is one serializable Model, and the only way to change it is to feed it Msgs, then a test can drive it headless, snapshot the Model, and assert — no window, no GPU, no pixel-diffing. That single payoff (the f3 harness) is the reason the whole contract exists. Determinism, replay, and robot-testing all fall out of it. Everything below is in service of keeping that payoff true.
Rejected: a blanket impl<T: Elm> Facet for T
The tidy version is impl<T: Elm> Facet for T {} — write Elm, get Facet free, no macro. We rejected it for two hard reasons:
- The orphan rule + migration collision. During the T1→T4 migration, some components already had a hand-written
impl Facet. A blanket impl would collide with every one of those (conflicting implementations) — the codebase would not compile until all 36 were converted in a single atomic change. The macro let us migrate one component per commit, which is why the migration could run as parallel agent waves at all. state_jsonrichness. Some components publish a richer introspection json thanserde(state()). A blanket impl fixesstate_jsonfor everyone; the macro's form 3 (custom_state_json) lets a component override just that. A blanket impl has no seam for it.
So we pay one macro line per component to buy incremental migration and per-component overrides. That trade is the macro's entire justification.
Rejected: view(&mut self)
Letting view take &mut self is more convenient — you can flip a hovered flag inline. We rejected it because it destroys FC-9: if view can mutate, then "render" and "change state" are the same act, and the harness can no longer paint a component to inspect it without also changing it. Pure view(&self) returning Vec<Msg> keeps paint and mutation provably separate; the hover flag becomes a Msg like everything else.
Rejected: I/O inside update
update could just do the side effect (spawn the thread, write the file). We made update return Vec<Effect> — data describing the side work — instead. Reasons: a test can assert "this Msg should have requested effect X" without X actually happening; and a component that does no I/O declares enum Effect {} (uninhabited, FC-8), which makes "this component is pure" a fact the compiler checks, not a comment. Most facett components are Effect {}.
Rejected: animation/layout state inside Model
Caches — scroll interpolation, animation clocks, memoized layout — are state, so the naive place is Model. We keep them outside Model in Cell/RefCell (FC-7). Why: Model is PartialEq and gets snapshotted/diffed every test. If a 60fps animation clock lived in Model, every snapshot would differ from the last and no golden file would ever be stable. Model holds decisions; caches hold derived ephemera and stay out of the serialized truth.
Rejected: index-based selection
Selecting "row 3" by index is simplest. Rejected (FC-5): the moment the underlying list reorders or filters, index 3 points at a different thing, and a replayed Msg script selects the wrong item. Selections are stable ids; a Msg carries the id, and update resolves it against current data. Replay stays correct across reorders.
How we got here (the road, briefly)
facett-security was hand-rolled to the Elm shape first, as the proof it was livable. Once it was, the macro was written and the migration ran in waves — T1/T2 (simple views), T3/T3-b (graph/pipeline/git/filedialog/jobview), T4 (the wgpu/3D holdouts: map, geomap, map3d, graph3d, model3d, video, which need a lighter contract — camera/state Msgs with the GPU render as an Effect-free side surface). The one cross-repo scar worth remembering: jobview's toggle_fold was made private in the migration, which broke nornir's viz consumer; the fix was to drive it through Msg::ToggleFold like every other input — which is exactly the contract working as intended (there is no back door; everything is a Msg).