tutuca

    MoonBit port of the tutuca UI framework (value language, templates, vdom, components, app runtime, lint, CLI)

    ui
    framework
    vdom
    reactive
    moonbit
    Download zip
    Version
    0.50.2
    License
    MIT
    Last updated
    15 hours ago
    Downloads
    295

    #marianoguerra/tutuca

    A MoonBit port of tutuca, a small UI framework built around a reactive value language, HTML-ish templates, and a virtual DOM.

    It runs on all three MoonBit backends: wasm-gc (the default, for target-agnostic logic and the browser demos), js (the real-DOM adapter, via mizchi/js), and native (the CLI).

    Live demos, playground and storybook: https://marianoguerra.github.io/tutuca-moonbit/ — source: https://github.com/marianoguerra/tutuca-moonbit. The published mooncakes package carries the library packages, the component format (tgc/) and the CLI; the storybook's own stories, the demo and playground hosts live in the repo only (see exclude in moon.mod).

    #What's in it

    tutuca is a stack of small packages, each a MoonBit package with its own tests and formal spec.mbt. From the bottom up:

    LayerPackage(s)What it does
    Value languagecore/marianoguerra/tutuca/core (value_*.mbt, path_*.mbt)The value model and its evaluation, plus the reactive path/dispatch system (COW spine rebuild, handler dispatch, change sets). core never PARSES anything — one package by necessity, since value_* and path_* form a dependency cycle.
    Expression languagetscript/ (+ check/, emit_mbt/, conformance/)Reading the surface tutuca writes: the slot expressions in a view (.field, $method, @value) and the block language of <script type="tutuca/script">. Tokenizer, parser, checker, and a MoonBit emitter for the ahead-of-time path. There was an interpreter here too, for the card runtime; tgc/emit compiles a card now, so a card is mounted by instantiating a module rather than by running one where it stands. Above core rather than inside it, for the reason in the cell above.
    Templatesanode/ (+ anode/sanitize)Parses the HTML-ish view syntax into an AST: attributes, directives, x- ops, macros, whitespace handling, optimization. sanitize is the WHATWG Sanitizer API config model, applied statically to a view's literal names.
    Virtual DOMvdom/ (+ vdom/memdom, vdom/browser, vdom/wasm)Builds and incrementally morphs a VDOM against any DOM implementing the DomNode trait.
    Render-time filtersvdom/filter/ (+ url/, handler/, markup/, markdown/), markdown/, sinks/The half a static pass cannot decide: an attribute VALUE is only known once state has produced it. URL schemes, on* handlers, sanitized raw markup, and Markdown rendered straight into vdom nodes. markdown/ is a CommonMark+GFM parser vendored from mizchi/markdown.mbt — see markdown/UPSTREAM.md. sinks/ holds one four-bit type and imports nothing: which of these rules an element's attribute NAMES could concern, which render decides off the tree so the rules can skip what cannot concern them.
    Renderrender/Turns a parsed view + a value stack into a @vdom.Vdom tree (loops, scopes, event-path metas, resumed paths).
    Components / Appcomponent/, app/ (+ app/browser, app/wasm), transactor/Typed-state component definitions (a plain struct + one Dispatch update match), the app runtime, and the transactor that routes events at the root and settles state.
    Stylingcss/The one place stylesheets live: a Tailwind port plus embedded Tailwind and margaui bundles, so a host compiles its collected class names to CSS with no Node, no CDN and no checkout.
    Toolinglint/, storybook/inspector/, statedef/, viewfile/, viewgen/, cli/The linter (parse-issue rules + a WHATWG-tokenizer structural HTML linter), a schema inspector, the state schema language, the view-file splitter, the ahead-of-time view compiler, and the native tutuca CLI.
    Testingtesting/harnessA reusable harness to mount and drive a ModuleDef on the in-memory DOM.
    Component formattgc/abi/ (the frozen preamble), rt/ (the runtime module), value/ (CBOR + $-tagged JSON), emit/ (the card compiler), host/, policy/, persist/Loading a WebAssembly module from anywhere into a running app. Core wasm plus the GC proposal and nothing else: one file that carries its own manifest, and an instance a component can hold in its own state. See tgc/SPEC.md and tgc/SECURITY.md.
    Demos & docsdemo/, playground/, storybook/, tutucard/51 ported examples (storybook/examples/), browser/wasm demo hosts, an in-browser playground, the compiler-free card playground, and a compiled storybook gallery.

    The tutuca CLI does the work that happens outside the compiler: gen, watch, storybook, install-skill, feedback, agent-context and help. It does not inspect, document, lint or render components — this is an ahead-of-time port, so those questions belong to gen (which makes a bad field reference or an unhandled @on handler a build error), to moon check, and to moon test over testing/harness. There is no module path and no way to point the binary at one.

    #Views (views~ + gen)

    component(...) takes its views as views~ : Map[String,@anode.View] — a view is a built @anode.View, never a raw string. There is one component shape, and two ways to arrive at it.

    Ahead of time, with tutuca gen: the view file states the component, and the generated module hands component() its name, views, styles, schema and codec. This is the default and what the rest of this section describes.

    Late-bound, with @anode.View::new("main", raw_view="…"), for a view whose SOURCE only exists at run time: a guest module bundle arriving over the wire, a macro body a MoonBit function builds per call, markup assembled from a value the program computes. Being late is not being under-described — such a component still declares its schema and its codec, because component() requires both of every caller. There is no shape that specifies less and makes the runtime infer the difference.

    That requirement is also why View::new is the only function in anode that names the parser: a program built entirely ahead of time never calls it, so it does not link an HTML parser it cannot reach. Worth 44% of the counter demo's wasm bundle — see benchmarks/OPTIMIZATIONS.md.

    A component keeps its views in an .html file and compiles them ahead of time into a companion MoonBit module, so the view's vocabulary stops being strings the compiler cannot see:

    moon run --target native cmd/tutuca -- gen demo/counterlib/counter.html --name Counter # -> demo/counterlib/counter_view_gen.mbt (the view vocabulary as types) # -> demo/counterlib/counter_view_ir_gen.mbt (the compiled views + the wrapper) # both checked in; regenerate, never edit

    The file is either one bare view, or several <template> elements whose id attributes name them — the one with no id is main. A <style> inside a template is that view's style; one at file level is the component's common style, or its global style with data-global.

    For a component named Counter the generated module declares counter_views() (the built views, for views~) and — with a schema — counter_component(), the wrapper that passes them, CounterMsg — every addressed name, the schema's message cases and the views' @on handlers in one bucket, with payload types declared by the schema where it says anything and inferred from the call sites otherwise, plus CounterMsg::from_dispatch and CounterMsg::to_dispatch — and CounterCompute with counter_compute (the $-callables, as an exhaustive match). The package it lands in must import "marianoguerra/tutuca/core" @tutuca, "marianoguerra/tutuca/component" and "moonbitlang/core/debug".

    A view file may also declare its component's data contract, in a small language that spells its types the way MoonBit does, next to the templates that read it:

    <script type="tutuca/spec"> state Counter { label: String, count: Int, history: Array[Int] } handle Counter { message { resetTo(Int) } } </script>

    Then CounterState itself is generated — a plain struct with no derives, a zero(), a direct state↔Value codec, and one SchemaInfo carrying the whole contract as static metadata: every field with the kind the schema DECLARES rather than one guessed from the seed value, plus the handler names, the view names, the element ids and the fixture names. That descriptor is what an instance answers schema() with, so the inspector and the state editor build themselves from it with no component registry in hand. And every .field a view reads is checked against it, inside an @each body as well as at the root. A misspelt field is a generation failure naming the near miss, where before it rendered as null. The handle { message / intent } surfaces get typed enums too: those names are raised from MoonBit rather than written in a view, so the schema is the only place they can be declared.

    The payoff is in update (see demo/counterlib/ for the worked example):

    update=(s : CounterState, msg, _ctx) => match CounterMsg::from_dispatch(msg) {
    Some(Add(d)) => ... // `d` is a Double: `@on.click="add 1"`
    Some(Unknown(_, _)) | None => Unhandled
    }
    // `.count = default` and `.label = e.value` are writes the view performs
    // itself, so they raise no name and there is no case here for them.

    Adding @on.click="del 1" to counter.html and regenerating makes that match non-exhaustive — a compile error naming Some(Del(_)), where the old string-matched _ => None arm silently did nothing.

    #Several components, and macros

    A view file belongs to a MoonBit module, not to a single component — template ids say what each one is:

    id
    (none)the single unnamed component's main view
    row…its row view
    Counter:mainthe Counter component's main view
    Countershorthand for Counter:main
    macro:icona macro shared by every component in the file

    A component name is Uppercase-initial, which is what tells Counter (a component) from row (a view). A file either names its components or does not; mixing the two is an error.

    A macro's data-* attributes are the defaults for the ^var references in its body, and the generator expands every call ahead of time — which is why macros belong in the view file rather than being registered from MoonBit:

    <template id="macro:icon" data-size="'24'" data-color="'currentColor'"> <svg :width="^size" :height="^size" :stroke="^color"><path :d="^path"></path></svg> </template> <template id="Gallery"> <x:icon :size=".size" :path=".heart"></x:icon> </template>

    #The compiled tree

    gen also emits <stem>_view_ir_gen.mbt: the @anode.ANode tree and event table each view parses into, as MoonBit code, so the template parser never runs at startup. A component that declares a schema gets one more thing there — counter_component, a wrapper over component() with everything the view file already states filled in:

    counter_component(
    initial=CounterState::fresh(),
    update=(s, msg, _ctx) => ...,
    )

    Its name, its views, its styles, its direct codec and its schema are not arguments — the view file states them, and restating them is how a fact the generator learns fails to reach the component that needs it. Each is still a parameter, so views=counter_views_with_extra() or name="Root" overrides one when a component genuinely differs; only the handlers have nowhere else to come from. Being typed on CounterState rather than on a type variable, the wrapper is also what lets update be written (s, msg, _ctx) with no annotation.

    A component whose views are built in MoonBit has no views~ to default and so gets no wrapper; it calls @component.component(...) directly, passing the encode, decode and schema its view file's state block still generates.

    There is no serialization format and no decoder: the AST is pub(all), and the tree is written with anode's builders — plain constructors with the rarely-set fields defaulted, which a hand-written view or test can use just as well:

    @anode.View::from_ir(
    "main",
    @anode.h("div", [@anode.attr("class", "stat")], [
    @anode.h("button", [@anode.attr("class", "btn"), @anode.eid(0)], [
    @anode.text("+"),
    ]),
    @anode.dyn_text(Field("count")),
    ]),
    [[@anode.on("click", Method("inc"))]],
    )

    @anode.h decides ConstAttrs vs DynAttrs with the rule the parser applies (attrs_of_items), so the two paths cannot disagree. What the file does NOT carry is anything the load can recover: View::from_ir rebuilds the node table from the tree itself (every registered node carries its node_id), gives each handler list the id that is its position, stamps data-vid and runs the constant-subtree optimization — RenderOnce ids are process-global renderer memo keys, so they must be minted at load time, not baked in.

    A macro declared in the view file (<template id="macro:badge" data-label="'New'">) is expanded when the views are generated, so a view that calls one compiles to a tree like any other. A macro REGISTERED from MoonBit cannot be — its body is a runtime value — so a file using those keeps the source path; --no-ir opts out by hand.

    Regenerate through the task, not the CLI — moon fmt owns the layout of the generated pair:

    moon run --target native cmd/dev -- gen # generate + fmt git diff --exit-code # drift check

    While authoring, tutuca watch removes the regenerate step entirely:

    tutuca watch demo/counterlib # or a file, or bare for the whole project

    It generates every managed view once, then again on each save, so the types are always current and the MoonBit compiler is what tells you a view and a component have drifted apart. A directory contributes the .html files that already have a generated sibling — that is what distinguishes a view file from a page like index.html. A view that fails to generate prints and the watch keeps going; the next save is expected to fix it.

    #In the playground

    The playground runs the same generator in the browser. Its left pane has three tabs:

    Tab
    Componentthe MoonBit you write
    Viewthe .html its views live in (name the component with <!-- name: Counter -->)
    Generatedread-only: what gen makes of the View tab, updating as you type

    The generated modules are compiled as extra files of your package, so the Component tab names counter_views() / CounterMsg with no import — and adding an @on handler in the View tab fails the build with Partial match … Some(Del(_)) until the Component tab handles it. Load the "Counter (view tab)" example to see it.

    The generator reaches the browser as viewgen/ compiled to js (playground/viewgen_js), which publishes globalThis.__tutucaViewgen; playground/build/check-viewgen-tab.mjs drives that whole path headlessly (generate → compile → link) as part of the playground task.

    #vdom

    The virtual DOM (src/vdom.js in the original): Vdom trees built with h/text/comment/fragment, rendered and incrementally morphed against any DOM that implements the DomNode trait.

    • vdom/ — core types and algorithms (h, to_dom, diff_props, morph_node, morph_children, render, unmount), backend-agnostic.
    • vdom/memdom/ — in-memory DOM. Runs on every backend; the primary test substrate (unit suites ported from the JS tests plus quickcheck properties: morph ≡ fresh render, keyed-reorder identity preservation, diff_props roundtrip).
    • vdom/browser/ — js-backend adapter over the real DOM via mizchi/js (supported_targets = "js").

    ///|
    test "render a tree into memdom" {
    let doc = @memdom.document()
    let container = @vdom.DomNode::create_element(doc, "DIV", None, None)
    let opts = @vdom.RenderOpts::new(doc)
    let prev = @vdom.render(
    @vdom.h("ul", attrs={ "className": Str("list") }, childs=[
    @vdom.h("li", key="a", childs=[@vdom.text("one")]),
    @vdom.h("li", key="b", childs=[@vdom.text("two")]),
    ]),
    container,
    opts,
    )
    // incremental re-render: morphs in place, preserves keyed nodes
    let _ = @vdom.render(
    @vdom.h("ul", attrs={ "className": Str("list") }, childs=[
    @vdom.h("li", key="b", childs=[@vdom.text("two")]),
    @vdom.h("li", key="a", childs=[@vdom.text("one!")]),
    ]),
    container,
    opts,
    prev~,
    )
    inspect(
    container.to_html(),
    content=(
    #|<div><ul class="list"><li>two</li><li>one!</li></ul></div>
    ),
    )
    }

    In a browser (js backend):

    ///|
    let opts = @browser.window_opts()

    ///|
    let container = @browser.BrowserNode::from_element(
    @dom.window().document().getElementById("app").unwrap(),
    )

    ///|
    let prev = @vdom.render(view(state), container, opts)

    #Differences from the JS vdom

    • Attribute values are a closed enum (Str/Num/Bool/Html); dangerouslySetInnerHTML: { __html } is spelled Html("...").
    • Namespaces are an enum (Svg/MathMl/Other(uri), None = HTML), only converted to URI strings at the DOM boundary.
    • h() takes childs : Array[Vdom] — the JS iterable-flattening and primitive→text coercion don't apply; text() is explicit. Fragment children are still spliced.
    • key/namespace are labeled arguments (key="a", ns=Svg), though "key"/"namespace" entries in the attrs map are also honored.
    • Object/array-valued custom-element properties are spelled Data(json) (the JS h(tag, { items: [1, 2, 3] }) case); they always take the property path and diff by VALUE, so an equal-content new object does not re-invoke the element's setter (JS compares by reference).
    • Out of scope: event handlers (tutuca delegates events at the root; vdom never routed them).
    • Double::to_string matches JS String(n) for attribute-realistic values; extremes like 1e21 format differently.

    #Building, testing, running

    Common workflows live in a MoonBit task runner (cmd/dev) rather than loose commands:

    moon run --target native cmd/dev -- setup # npm install (happy-dom) + enable git hooks moon run --target native cmd/dev -- check # moon check across wasm-gc, js, native moon run --target native cmd/dev -- test # moon test across the three targets moon run --target native cmd/dev -- build # moon build wasm-gc + native CLI + js moon run --target native cmd/dev -- dist # assemble a self-contained dist/

    Run cmd/dev with no task to print the full list. The raw moon commands the tasks run underneath still work directly. See AGENTS.md for the tooling and testing details, and storybook/examples/README.md for how a JS example becomes a MoonBit one.

    dist produces dist/index.html (a landing page), the js and wasm-gc demos, the storybook gallery, and the native tutuca binary — serve it with any static file server (cd dist && python3 -m http.server) or dist/cli/tutuca storybook. The wasm pages need a browser with the JS String Builtins proposal (e.g. Chrome).

    #Targets

    preferred_target is wasm-gc, so a bare moon check / moon test covers only the target-agnostic packages. Full coverage needs all three: moon test (wasm-gc), moon test --target js (the browser adapters, happy-dom based) and moon test --target native (the CLI shells). The check / test dev tasks run all three for you.

    #License

    MIT — see LICENSE. This is a port of the MIT-licensed tutuca by the same author.

    Ctx

    What a handler can do besides transforming its leaf. Defined here so Handler can carry it without depending on the transactor; every method defaults to a no-op so tests and Path::update can run handlers without a dispatcher. The transactor implements the real thing.

    Domain

    What a field's declared domain is.

    The vocabulary is CLOSED, and the reason is what the whole feature is for: every arm can be read backwards as well as forwards. Forwards it rejects a value; backwards it produces one, which is what lets a generator draw states a component could actually reach instead of filtering for them. A relation that can only be read forwards is a pred — that is the line, and it is drawn at the declaration rather than here.

    DomainOp

    How a where compares. The runtime twin of @statedef.CmpOp.

    DomainOperand

    A comparison's right-hand side: a literal, or another field of this state.

    Expr

    An expression.

    Val is the same type under its older name — a slot's value and a block's expression are one language, and the alias is what keeps the ~1500 places that say Val reading as they did.

    FieldDomain

    One field's domain, as the schema carries it.

    A flat list of pairs and not a map, because several clauses may name one field and they CONJOIN: where n >= 0 beside where n <= 100 is one field with two entries, and a map would have made the second silently replace the first.

    IntentAnswer

    What a static intent handler answers.

    Pass is the case a walk needs: a handler that has nothing to say declines, and the walk goes on to the next one — the same freedom a component's update arm has when it answers Unhandled. Without it, a handler with nothing to contribute could only invent an error.

    IntentCall

    What a static intent handler is handed.

    from is the sender's position. A host reads it to decide whether the sender may ask — which is what tgc/SECURITY.md §7 says it cannot do today — and the walk needs it anyway to route the answer, so the handler gets it for free.

    This lives in core rather than beside Dispatch in component for a mechanical reason: transactor constructs one and component consumes one, and neither imports the other. It is pure data over core's own types, so this is the only place both can see it.

    IntentOpts

    Options for Ctx::intent.

    route is the walk itself. The three answer names are the three ways a walk can end, and each carries its own payload: a hop replied, a hop failed, or the route ran out with nobody answering — which is not the same answer as a handler failing. live_path opts out of pinning field-resolved keys at dispatch time.

    The middle one is Failed and not Error because that is what happened: a handler ran and did not succeed. An Error is a thing, and the arm reads as if one were being handed over; <n>Failed says the outcome, which is what the other two names say.

    There is deliberately no combined result-or-error arm. An outcome a handler has to discriminate is one it can discriminate wrongly.

    Leg

    A leg of an intent's route.

    Dyn walks the dynamic scope — the dispatch path, from the sender's PARENT up to the root, one pop_step() per hop — and its handlers are component instances with state. Lex walks the lexical scope — the registration chain — and its handlers are static functions with none. A route is a list of these, in the order written, and [Dyn, Lex] when nothing says otherwise.

    Lit

    Literal constant payload (string | number | boolean | null)

    Obj

    Component-instance protocol (the port of JS's runtime Records tagged with a hidden component symbol). ONE trait serves both hybrid representations: the Value-backed Instance (component package) and user-defined typed structs. Instances live INSIDE Value so they flow through every Value-typed seam (binds, handler args, dynamic lookups, iterated seqs). Fns returned by member / trigger and the Handler returned by handler are SELF-PRE-BOUND (they close over the concrete Self — the no-downcast analogue of late-bound this) and ignore args element 0. Every method defaults to an empty/None answer.

    OpFamily

    The four operator families. Mixing families in one unparenthesized chain is a parse error, and the message names the parentheses to add — so a and b and c and a + b - c chain freely, a + b * c is refused, and there is no precedence to get wrong.

    PathStep

    A step below the root.

    Place

    A PLACE: a position, not a value.

    .a.b and .a[k].b are the two things a view slot cannot spell — nested reads and nested WRITES. A slot's name lookup stays one level because nothing checks it; a body is checked code and the generator knows every type along the path.

    PlaceRoot

    What a place is rooted at.

    ProtocolIssueCode

    One vocabulary shared with the static protocol validator.

    Refusal

    One refusal: where it happened, what was asked for, which rule said no, the sentence that rule produced, and the state that was rejected.

    RefusalCode

    Why a dispatch produced nothing.

    Each case names a producer that exists in this repo. The design this comes from has a longer vocabulary — NO_REQUEST_FN, DECODE_FAILED, COERCED_TO_DEFAULT, OUT_OF_RANGE, TELEPORT_MISSING — and none of those is here, because a code nothing raises is a promise the runtime does not keep: a host filtering on it would conclude the failure never happens. A new case arrives with the site that raises it, in the same change.

    (@emit_mbt.Refusal is a compile-time namesake and unrelated: that one is a declaration the MoonBit backend will not compile, and it is answered to the generator rather than to a running page.)

    RuntimeNotice

    The single centralized runtime diagnostics channel.

    RuntimeProtocolNotice

    RuntimeResolution

    The graceful fallback the runtime chose after a protocol assumption failed.

    Span

    A half-open character range in the source that was parsed.

    Character indices, not bytes: they index source.to_array(), the same Array[Char] viewfile slices with and the WHATWG tokenizer counts in, so a span from a slot value and a span from the file agree for any input, astral characters included.

    Stack

    Evaluation environment (the stack). Every method defaults to Null so implementations (and test mocks) provide only the lookups they use. lookup_method returns the RESULT of the no-arg call — the stack invokes. lookup_trigger returns Null when no render-time handler answers the name.

    TplPart

    One piece of a $'…' template: literal text, or an interpolated expression.

    There is no third case for a placeholder that failed to parse. The slot parser used to keep one as a hole, which meant every reader of a template had to decide what a hole means; a placeholder that does not parse is a PARSE ISSUE, reported where issues are reported, and the part is dropped.

    from_macro rides on the TEXT case and not only on ELit, because a ^name that resolved to a string is text in the template that contains it — it prints as text and reads as text — and the one bit that still has to survive is whose source it was. That bit is what decides whether the enclosing template counts as a hand-written literal, and therefore whether it may pin a URL origin (tgc/policy/external_url.mbt).

    Value

    Dynamic runtime value: what eval(stack) reads and returns.

    Aligned with the tgc/1 value, arm for arm, because a format that carries something the host cannot hold is a lossy boundary in the one place there must not be one. tgc/abi's frozen rec group is the other half of this declaration and the two are meant to be read together.

    Three arms are newer than the rest, and each earns its place:

    • Int because CBOR and the wasm-GC types both have a 64-bit integer natively, and the contract this replaces conceded what its absence cost ("a 64-bit id is a str"). It does NOT make the value language two-numbered: tscript still has exactly one number and every arithmetic operation still answers a Num. Int is for a value that arrived from somewhere with integers — CBOR, a foreign module — and is read back out the same way it came in.
    • Bin because a component that holds bytes should not have to spell them as base64 inside a Str and hope every reader agrees which encoding it was.
    • Instant because "when" is the one fact a sandboxed component cannot compute for itself and therefore always receives from somewhere else. A wire type it can be received AS is the difference between one representation and one per host.

    A reader that only knows the older arms is not wrong about them; it is incomplete, and num / str / to_display_string answer for all three so that "incomplete" mostly means "less precise" rather than "broken".

    bad_payload

    fn bad_payload(asked : String, declared : String, args : Array[
    Value
    ]) -> Unit

    Report a dispatch whose arguments did not fit the payload its name declares.

    Raised from a generated from_dispatch, which is the one place that holds both halves: the runtime arguments, and the declared spelling the schema wrote. declared is that spelling — "Int, String", or "" for a case that declares no payload — and it is baked in at generation time, because nothing at runtime can reconstruct it.

    The decode's answer is unchanged: the case falls into Unknown, and an update that does not claim it answers Unhandled exactly as before. This only makes the failure SAYABLE — a mismatch used to be indistinguishable from a name nobody sent.

    broken_domain_sentence

    fn broken_domain_sentence(d :
    FieldDomain
    , fields : Map[String,
    Value
    ]) -> String

    What first_broken_domain found, as the sentence to say about it.

    A convenience over domain_sentence and worth its line: every caller of the first has the pair in hand and has to dig the value back out of the map to call the second, and three callers digging it out three times is three chances to dig out the wrong one.

    domain_failed

    fn domain_failed(handler~ : String, field~ : String, sentence? : String, state? :
    Value
    ) -> Unit

    A write left a field outside its declared domain, so it was abandoned.

    field goes where a rule's name goes, and that is the point rather than a convenience: a where has no name, and what a reader needs is the FIELD and the relation it left. sentence is composed from the relation (domain_sentence) rather than written by an author, which is why a where carries no format.

    first_broken_domain

    The first domain the WHOLE state breaks, with the field it is about.

    One function and not three, because there are three places that ask it and they have to get the same answer: the runtime's post-transition door, the guard gen weaves into a generated arm, and the dynamic-component host holding a guest's successor. A domain the woven guard admitted and the runtime then refused would be a component that abandons a transition its own compiled code said was fine — which is exactly the class of bug a second reading of one declaration produces.

    Source order, not sorted: this answers WHICH ONE to report, and a reader fixes them one at a time. broken_domains is the other question — every field at once — and sorts for its own reasons.

    invariant_failed

    fn invariant_failed(handler~ : String, pred~ : String, sentence? : String, state? :
    Value
    ) -> Unit

    An invariant did not hold after the body ran. Same abandonment, different reason: the rule is the component's rather than this handler's.

    no_span

    The empty span, for a value with no source of its own — a ^macro variable's expansion, a generated IR module's rebuilt AST, or a value built by hand.

    on_runtime_notice

    fn on_runtime_notice(f : (
    RuntimeNotice
    ) -> Unit) -> (() -> Unit)

    Switch the channel on. Returns the uninstall, which puts back whatever listener was there before — so a test that installs one inside another's lifetime cannot silently keep it.

    postcondition_failed

    fn postcondition_failed(handler~ : String, pred~ : String, sentence? : String, state? :
    Value
    ) -> Unit

    A postcondition did not hold after the body ran, so the transition was abandoned whole — state and effects together. state is the SUCCESSOR that was thrown away, which is the one worth looking at.

    precondition_failed

    fn precondition_failed(handler~ : String, pred~ : String, sentence? : String, state? :
    Value
    ) -> Unit

    A precondition did not hold, so the handler declined before touching anything. state is the state as it ARRIVED — nothing has moved yet.

    protocol_mismatch

    Report an exercised gradual protocol assumption that failed. Unlike a refusal, this is delivered immediately: the resolution already kept the UI usable, so there is no dispatch-chain deduplication to perform.

    refuse

    Report a refusal. A no-op when nothing is listening.

    Inside a dispatch the record is HELD rather than delivered, so the one that decided is the one that escapes. A refusal raised outside any dispatch — a render-time name that nothing answers — is its own chain end and goes straight out.

    refusing

    fn refusing() -> Bool

    Whether anything is listening.

    Producers ask this before they build a record, and take the warn path when the answer is no. It is the reason a rejected state can be carried at all: nobody pays for it until somebody wants it.

    Source Files