plgg-view
v0.0.2
Published
A minimal Elm Architecture (TEA) for the browser built from scratch on the plgg framework: a typed Html<Msg> view tree, pure sandbox/application runtimes, and SSR renderToString
Readme
plgg-view
UNSTABLE / EXPERIMENTAL POC - Study work. Part of the plgg monorepo.
A complete Elm Architecture (TEA) built from scratch on plgg.
An app is pure values — an immutable Model, an update: (Msg, Model) =>
[Model, Cmd<Msg>], a view: (Model) => Html<Msg>, and an optional
subscriptions: (Model) => Sub<Msg> — driven by a tiny runtime. State is one
value, every change flows through update as data (Msg), and the view is a
pure function of the model. Cmd/Sub are pure DATA the app returns and the
runtime alone executes, so purity holds even WITH effects. It fits plgg's ethos
exactly: pure functions, immutable data, events as values, one runtime seam. The
only runtime dependency is plgg.
sandbox/application run over an in-place diff/patch renderer and now
carry Cmd/Sub effects (D2): HTTP via plgg-fetch, timers, or a WebSocket via
a custom subscription are all pure data the runtime performs — the app never
touches them directly.
What it is (and isn't)
| | |
|--|--|
| ✅ Model / Msg / pure update→[Model, Cmd] / view / subscriptions | ❌ effects never RUN inside update — it returns them as data |
| ✅ a typed Html<Msg> view tree (handlers produce Msg) | ❌ no JSX (Elm-style element builder functions instead) |
| ✅ sandbox + application (routing-aware) runtimes | ❌ no keyed-list reconcile or render batching (follow-ups) |
| ✅ virtual-DOM diff/patch — re-renders preserve focus/caret | ❌ no hydration (mount re-renders from init) |
| ✅ declarative enter/exit transitions (WAAPI, prefers-reduced-motion-aware) | ❌ no spring physics, keyed-FLIP reorder, or exit-while-list-churns (follow-ups) |
| ✅ pure SSR renderToString(Html) | |
The view tree — Html<Msg, T>
A plgg Box union — pure data, never a class instance or DOM node —
parameterized by the app's Msg and the element's tag T:
| Node | Type |
|------|------|
| intrinsic element | Box<"Element", { tag, attributes, children }> |
| text leaf | Box<"Text", { value: SoftStr }> |
| the union | Html<Msg, T> (T defaults to string) |
| an attribute | Attribute<Msg> = a static attr or an event Handler producing Msg |
The handler channel on Attribute<Msg> is what makes the tree Html<Msg>
rather than a passive string tree. Build it with Elm-style element builder
functions:
- elements:
el(tag, attrs, children)and helpersdiv/button/input/ul/li/a/span/h1/p/form/main_/… , plustext(value). - static attributes:
attr(name, value)and helpersclass_/href/type_/value_/name_. - event handlers:
on(event, toMsg)and helpersonClick(msg),onInput((value) => msg),onChange(...),onSubmit(msg). - transitions:
transition({ enter, exit })and presetsfadeIn(ms)/fadeOut(ms)/slideIn(offset, ms)— pureAnimdata (noMsg), so they drop into any attribute list. SSR drops them; the client renderer plays the enter motion on node creation and defers a node's removal until its exit motion ends, via the Web Animations API and honouringprefers-reduced-motion. The Model never learns animation exists — it stays outside theupdate/effect loop entirely (not even aSub). A node mid-exit still occupies its child slot, so rapid list churn can collide — the outroing-set + keyed-FLIP fix is a follow-up.
Html<Msg> is a functor over Msg: mapHtml(f)(html) lets a parent embed a
child component's view (Elm's Html.map).
Typed content model — T restricts children
The second type parameter T brands an element's tag at the type level (the
tag is already stored as data, so the brand is real — no cast). Each builder
declares what it is (its branded return type) and what it accepts (its
children parameter), so illegal structure is a compile error, not a runtime bug:
ul([], [li([], [text("a")])]); // ✅
ul([], [div([], [])]); // ❌ "div" is not assignable to "li"
span([], [ul([], [])]); // ❌ ul is flow, not phrasing
input([type_("text")], []); // ✅ void: children must be `readonly []`The categories are small tag-unions, applied selectively (not a full HTML content-model lattice — see type-driven-design, "introduce rich typing where confusion can occur, not over every value"):
| Category | Tags | Containers that accept it |
|----------|------|----------------------------|
| Phrasing<Msg> | inline: span/a/strong/em/label/button/input/text | span/strong/em/label/button/h1/h2/p |
| Flow<Msg> | block ∪ phrasing (excludes li) | div/section/header/main_/form/li/a |
| ListItem<Msg> | li only | ul |
| readonly [] | none (void) | input |
Restriction is producer-side. Unlike React/TSX — where each container must
remember to constrain its own children prop and an intrinsic <li> collapses
to ReactNode — here a value's own type says Html<Msg, "li">, so every
container that accepts li enforces it automatically. A custom component opts
into a strict slot by simply declaring what it is:
// declares it IS an <li>, so it drops into ul's li-only slot:
const todoItem = (t: Todo): Html<Msg, "li"> =>
li([class_("todo")], [text(t.title)]);
ul([], todos.map(todoItem)); // ✅A component left as the bare Html<Msg> (i.e. Html<Msg, string>) is rejected
by default from strict slots — you must positively declare the tag. That
"opt-in to strictness" is the safer default.
Cardinality is the second, independent axis — set by the children shape:
| Want | Children type |
|------|---------------|
| exactly one B | a single value Html<Msg, "b"> (or One<Msg, "b"> in array form) |
| fixed/ordered [B, C] | readonly [Html<Msg, "b">, Html<Msg, "c">] |
| one or more B | NonEmpty<Msg, "b"> |
| zero (void) | readonly [] |
| zero or more | ReadonlyArray<Flow<Msg>> (the default array form) |
el is the escape hatch — and its honest limit
el(tag, attrs, children) stays permissive: any children, tag branded only as
string. Because the brand is honest (it equals the real runtime tag, and el
takes a dynamic tag), an el(...) node is Html<Msg, string> and therefore does
not fit a typed builder's child slot — there is no cast to fake a category.
The mental model is typed islands vs. escape-hatch islands: a raw el child
does not interleave into a typed parent; build the whole untyped subtree with
el (its parent included). Use el for tags the model does not cover (<b>,
<img>, tables/selects) or transparency cases (<a>'s content depends on its
parent — left unmodeled). Likewise mapHtml rebuilds nodes at the default brand,
so a mapped child is Html<Msg, string> and won't re-enter a strict slot.
The runtimes (browser seam — plgg-view/client)
import { sandbox, application } from "plgg-view/client";
// sandbox: the purest TEA — no routing. init is [Model, Cmd].
const stop = sandbox({ init, update, view })(container); // → cleanup
// application: routing-aware (Browser.application-style)
const stop = application({ init, update, view, onUrlChange })(container);sandboxholds the liveModelin a closure (the single justified mutable seam — Elm's runtime is imperative here too). Adispatch(msg)sets[model, cmd] = update(msg, model), re-renders by diffing the newHtml<Msg>against the last and patching only what changed (makeRenderer) — so a re-render is O(changes) and a focused input keeps its focus, caret, and IME state — then runs the returnedcmdand re-diffs the subscriptions. Event listeners are wired once per node and re-pointed in place — never duplicated, never stale (the live handler is read on each event from a per-node registry).applicationadditionally owns the URL: it reads the entryUrlintoinit, intercepts same-origin in-app<a>clicks (preserving browser defaults for modifier-clicks,target/download/pass-throughrel, cross-origin and non-http(s)links),pushStates, and turns navigation +popstateintoonUrlChange(url): Msg. The app mapsUrl { path, search }to its own route value using plgg-router's purecompilePattern/matchSegments/parseQuery, so navigation is just data flowing throughupdate. URL reflection (toUrl/historyMode) is a reconciliation of model→address-bar, deliberately kept OUTSIDECmd(folding raw history writes into effects would reopen the loop hazard).
Both are shipped on the plgg-view/client subpath so window/DOM code never
reaches the SSR-safe core entry.
Effects — Cmd and Sub (pure data, runtime-executed)
import { cmdNone, cmdEffect, interval, subNone } from "plgg-view/client";
// update returns [nextModel, Cmd]; a pure branch pairs with cmdNone().
const update = (msg, model) =>
msg.kind === "load"
? [{ ...model, loading: true },
cmdEffect(async () => ({ kind: "loaded", data: await fetchIt() }))]
: [model, cmdNone()];
// subscriptions(model): the sources active for this model, diffed by key.
const subscriptions = (model) =>
model.live ? interval("poll", 1000, () => ({ kind: "tick" })) : subNone();Cmd<Msg>—cmdNone/cmdBatch/cmdEffect(() => Promise<Msg>).cmdEffectis the one custom-effect seam: HTTP, timers, or D12's ephemeral-key minting are all a deferred computation resolving to aMsg. Construction runs nothing; the runtime invokes the thunk after paint and dispatches the result (dropped if it resolves after teardown). Fold aResultto aMsginside the thunk so it always resolves.Sub<Msg>—subNone/subBatch/interval(key, ms, toMsg)/windowEvent(key, name, e => Option<Msg>)/custom(key, dispatch => cleanup). Every active leaf carries a stablekey; the runtime diffssubscriptionsby key each dispatch — starting new keys, tearing down removed ones, and leaving survivors running untouched.customis where a WebSocket/audio channel plugs in (its whole wiring is data the runtime invokes).
SSR — renderToString (core, SSR-safe)
import { renderToString } from "plgg-view";
renderToString(view(init)); // Html<Msg> → escaped HTML string; handlers droppedA pure Html<Msg> => SoftStr fold (text + attribute values escaped, unsafe
attribute names dropped, void elements self-closing). Event handlers are dropped
— there are no events on the server. The server renders view(init) for first
paint; the client runtime then takes over (the minimum re-renders from init on
mount — true hydration is a follow-up). plgg-server's View is a thin wrapper
over this.
Two entry points
plgg-view(core) — theHtml<Msg>/Attribute<Msg>model, the builders,foldHtml,mapHtml, and the pure SSRrenderToString/escape. No DOM — SSR-safe, importable anywhere.plgg-view/client(browser seam) —sandbox,application,makeRenderer(the diffing Html→DOM renderer), and theUrlmodel. The only code that touches browser globals.
Run the example
cd packages/plgg-view && npx tsx example.ts # type-checks; the demo mounts in a browserexample.ts is a TEA counter (Model/Msg/pure update/view
over sandbox). A fuller client-only TEA To-Do app lives in
../example.
Conventions
- Specs import from
"plgg-view"/"plgg-view/client"; internal modules use the"plgg-view/…"path alias, matching theplgg-serverconvention. as/any/ts-ignoreare prohibited (see rootCLAUDE.md); unknown inputs are narrowed with plgg type guards andOption. The runtime's mutable model ref and the renderer's previous-tree ref / DOM mutation are the justified imperative seams (confined tosandbox/application/render, commented).- After editing
plggcore, runnpm run buildinpackages/plggor this package won't see new exports (the dependency is a symlinkedfile:dist).
