Cards

A complete component in one file, compiled to wasm in your browser

Eight steps, nine live examples. Edit any example, and it re-mounts while you type.

What a card is

A card is a tutuca view file: a state schema, a script block, and one or more templates. The page compiles the card. It compiled the first example below into a WebAssembly module and instantiated it where it stands: no MoonBit toolchain, no worker, no build step, and no server.

This is the same framework as the rest of this site, with a different compiler. The landing page compiles MoonBit in your browser, and that costs a 5.5 MB payload. This page compiles the card language instead, and that compiler is part of the runtime the page already loaded. The schema still generates mutators. The checker still reads the script block and reports the same diagnostics with the same line and column numbers. The views are the views. What changes is when: a card is compiled at mount rather than ahead of time. What you give up for that is the subject of the last section.

The file has three kinds of section, and only the last is required:

spec:   fields, types, payloads
logic:  handlers, derived values
view:   the views

A file may carry two more, and no example here does: fixtures: (named starting states, each one a fixture a host can mount) and tests: (scenes that drive the card and assert against the DOM).

Every example below is one such file. Each one is complete and runnable. The bar above each example shows the component name and the checker result: ok, or the number of issues. The checker reads both halves of the file — the logic: section against the spec, and the views against the same spec — so a .field that no field answers is an issue here rather than a blank in the render. Each issue has a button. Press it to select the exact characters of that issue. You can break an example on purpose. The checker then reports the problem. Press reset to put the original text back.

Step 1 The schema, on its own

This card has no script block. It still answers three events. Every field that a state block declares gets mutators named after the field: setName, resetName, and nameLen. loud is a Bool, so it also gets toggleLoud. A view that calls a mutator needs no handler.

The field kind decides what the field generates. The kind is the declared type. It is not a choice at the call site:

DeclaredGenerated mutators
BooltoggleX
Array[T] pushInX, insertInXAt, setInXAt, removeInXAt
Map[String, T] setInXAt, removeInXAt
Set[String] addInX, removeInX, hasInX, toggleInX
any fieldsetX, resetX, xLen

That card shows two spellings. They are the two halves of every view. :value=".name" is a value slot: it reads. @on.input=".name = e.value" is a handler slot that writes a field back. A handler slot naming a word instead — @on.click="add" — dispatches that word as a message, and the arguments after it are resolved from the event: value, valueAsInt, key, isAlt, and more.

Step 2 The script block

A receive handler answers a name addressed to this component. A view writes such a name, or another handler sends it. Use a handler when no mutator gives the transition you want: two fields change together, or you clamp an argument before you store it, or you need a branch.

Statements end at a newline or a ;. The handler rebinds the state after each statement. The second line of step below reads what the first line wrote. Thus the trail records where the counter ended.

That card also answers init. A card starts at the schema zero: an Int is 0 and an Array is empty. There is no MoonBit here to pass a seed through. tutuca also has no lifecycle. Nothing in the framework calls an initialiser. The host instead dispatches init at mount, and a card that wants a different start answers it like any other message. Every card after this one seeds itself that way.

Note what step does not say: nothing annotates dir. The language infers the argument types from what the templates write at the call site. So @on.click="step 1" makes the argument a number. If a call site does not agree with the body, the checker reports an issue. It does not coerce silently.

Step 3 Values the state does not hold

A compute is one expression with a name. A template calls it as $name. A pred is the same construct, but its answer is declared as a boolean. A condition slot can therefore check a pred, not only evaluate it. Neither can assign. Thus neither can be the reason a render is wrong.

if appears twice in this language, with two meanings. In a statement body, if is a branch. There the else part is optional. In a value position (a compute, a pred, an interpolation), both arms are required. An expression must have a value.

The $'…' in status is a string template: single quotes with a leading $, and a {…} holds any expression. A bare '…' is a plain string literal. It can span lines as it is, and it has five escapes: \', \\, \n, \t and \r. Nothing else in a view or a body interpolates. An unquoted {.x} in an attribute is not a template. It is text.

Step 4 Lists

@each walks a field. For each row, it binds @key and @value. In this card, the remove button writes .songs.removeAt @key. That reaches a generated mutator directly from the view, with no handler between. Moving a row is instead a receive handler. It writes two positions, and no single mutator does that.

Inside a body, write to a collection through its receiver: .songs.push v is pushInSongs. The infix part drops because the receiver is written out. Sequences and maps have push, insertAt, setAt and deleteAt. Sets and maps have add, remove and toggle. The parser also accepts clear, set and the aliases removeAt and delete. No backend implements those four, so a card that writes one is refused by name when it compiles rather than doing nothing when it runs.

A list of records needs one more thing. The language has no literal to write one with. new <Type> builds a value at its type's zero and makes it the active target. The statements under it fill the value in through cur. Then it is pushed like anything else. This card's songs is an Array[String] and needs none of this. But suppose the schema declared struct Song { title : String, plays : Int } and songs : Array[Song]. Then a row is built like this:

receive init {
  new Song
  cur.title = 'Ramble On'
  cur.plays = 0
  .songs.push cur
}

Write the type the way the state block spells it: new Song, new Array[String], new Map[String, Int]. The type must be one that the block declares. A new resets the target. So building two rows takes two news. A path into the target works too. So new Song followed by cur.tags.push 'rock' fills a list inside the record. cur belongs to the handler that built it. It is gone when the handler ends, and it never reaches a view. So a template that reads cur reads nothing.

The nested-state card in the card playground shows this live. There, an Array[Label] is seeded and grown with new. That card also makes a nested write, .labels[key].done = not .labels[key].done. That nested write is the other half of what records give you. It is also the one pair a view slot cannot spell.

Step 5 How a loop calls the block

Two directives on an @each element call the script block. @when names a pred. That pred keeps the row or drops it. @enrich-with names an enrich. That enrich answers a question about the row. The loop binds the answer for the template to read.

The checkbox reads @picked and does not call a predicate. The reason is this scoping rule: a compute or a pred called from a value slot receives only the state and its own arguments. @value is not in scope inside one. @when and @enrich-with resolve through the loop instead. Those two are handed the row.

This rule is also why the two directives spell their arguments differently. @when="matches" names a predicate bare. The loop resolves that name. @show="$any" reads a value. So it carries the $ that says a callable answers it.

Step 6 Messages

One keyword covers both halves of an addressed name. Where the name is written decides where its arguments come from. The checker compares a receive that a view names against the call sites, as it did with step in step 2. A receive that no view names is different: another handler raises it, a parent raises it, or the host raises it at mount. The checker cannot read those call sites. So the schema's receive block is the only place to declare it, and the only place to type its payload.

send is one of the effects that a body may perform. These effects are the vocabulary that a dynamic WebAssembly component answers with — and a card compiles into one, so the two lists are the same list rather than two that agree. Neither a card nor a component knows what the host will do with them.

Two of those effects are addressed and two are routed. The distinction matters: a message names one component and stops there. An intent names a job and walks until something answers.

Effects run only if the body finished. A statement that cannot run (a missing key, an index past the end, a value of the wrong shape) drops the whole transition, effects included. Nobody can reason afterwards about half a transition.

Step 7 Ask for what you do not have

intent is the second channel. It is the asynchronous one. A handler names a piece of work, for example ask("load_quote", ~route: lex), and gives it to a route rather than to an address. lex is the leg that searches the handlers registered on the host. Because of that leg, the same component can run against a real fetch in production and a fixture in a test. dyn is the other leg: the ancestors, from this component's parent upward. Write no leg and the intent walks dyn lex: first the tree, then the host.

An intent has exactly three ends. Each end is its own arm with its own shape: <name>Ok when a hop replied, <name>Error when one failed, and <name>Unhandled when the route ran out with nobody claiming it. They arrive as ordinary receive messages. So a handler cannot tell an answer from a message that a parent sent, and does not need to. There is no exception to catch and no rejected promise: a failure is a value. It arrives on the same path as a success, at the component that asked.

That card shows its third arm live, with nothing mocked. This page registers no handler for loadQuote. The route runs out, and the card hears loadQuoteUnhandled. This result is not an error: nobody claimed the intent, and that is a different statement. Version 1 of this framework could not say it. It made every declining handler invent a failure. So a page with no fetch and a fetch that broke read identically.

The block deliberately does not carry two things. First, an intent's options stay in MoonBit. Options name the three answers yourself, and choose whether a path is pinned at dispatch time or resolved again on arrival. A small language would model these parts badly. When you write no options, the generator fills them in from the three arms the schema declares. Second, ask(…, ~route: dyn) needs an ancestor to reach. A card mounted on its own is the root, so the dyn leg from one has nowhere to walk. Both points follow the same rule as everywhere else on this page: a card is one component, and what needs a tree needs a module.

Step 8 Invariants, pre- and postconditions

A pred is where a rule about the state gets a name. That is the whole mechanism. What the rule is depends on where you attach it:

None of the three needs a rollback. The framework gives this, not the contract: a body either finishes or changes nothing. A rule that does not hold abandons the whole transition. The state the caller passed in is still the state. The queued effects drop with it, so nothing went out about a move that did not happen. This is why the checker can check a postcondition after the body: by then the successor exists, but it is a fresh value nobody else has seen, and the sends are still a queue.

implies is in the language because these rules take that shape. a implies b is (not a) or b. It takes exactly two operands. A chain of them is refused. People write "a implies b implies c", and readers disagree about what it means.

The checker reports a refusal. It does not hide one. Press cheat. The numbers do not move, and the console says contract: `cheat` was abandoned — it broke the invariant `conserved`: 8 + 1 is not 8 — a token came from nowhere. That report is the main benefit of attaching a rule. "The transition did not happen" is already the framework's answer to everything. But that answer is invisible: a state that did not move looks exactly like a state that had nothing to move. A contract says which of those silences is a bug. The report is where that answer reaches a host.

The half after the colon is the rule's own format. It is the sentence the rule states when it does not hold. You write it above the body. It is evaluated against the state that was rejected, so the numbers in it are the ones that broke the rule. The /// comment and the format do different jobs, which is why a rule uses both. The comment says statically what the rule is. The format says what went wrong this time. It is an ordinary $'…' template, so there is no second interpolation syntax to learn. It describes the false case, because a predicate is true when things are fine.

A host that wants more than a line asks for the record. @tutuca.on_runtime_notice switches on a channel that carries the code (PRECONDITION, INVARIANT, NO_HANDLER, …), what was asked for, which rule said no, and that sentence. It also carries the state that was rejected, which is the thing you actually want to look at. The channel is off until someone asks. A declining handler is not a refusal. The generated setter behind it is the design there. One dispatch produces at most one record. In a test, @harness.no_refusals(…) turns the channel into a failure. That failure makes a test about a guarded button mean something. Without it, clicking one that correctly declines and clicking one whose selector is a typo both pass today.

The rule stays readable from everywhere it was before. An invariant is a pred with a role, so $conserved still works in a badge and in a @when, and a test asserts the same name after a dispatch with an ordinary assert_eq. Know two limits. A contract takes a name, and the rule it names takes no arguments. A rule that needs an argument is about this dispatch rather than about the component, and if is still where that goes. Also, an invariant covers the transitions the block declares. The generated setters a card answers by default are not among them.

Two ways to style a card

Every card on this page is styled by class name. None of them contains a line of CSS. The views name margaui's component classes (card, btn, input, badge, join, stats) with Tailwind utilities beside them. The page compiles them in your browser. The mounted card publishes the class names its views used. A ~0.5 MB wasm build of the same compiler that the CLI's gen-margaui-css uses turns them into CSS.

The other way needs nothing extra. A <template> may hold a <style> block. The framework scopes it to that view, so its rules cannot reach another component. Bare declarations at the top of the block style the view's own root element:

<template>
  <style>
    display: grid;      /* the root element */
    gap: .6rem;
    .row { display: flex; }
  </style>
  <div class="row">…</div>
</template>

Which one to use is the usual question. A class list gives you a design system and costs a compiler. A <style> block costs nothing and styles only what you write. The compiler is opt-in for that reason: write <mb-card src="…" margaui>. A page whose cards style themselves never fetches it.

Know two things before you embed one. First, the CSS is scoped to the preview. margaui's stylesheet carries Tailwind's preflight and a :root theme. Injected as written, it would not style the card. It would restyle the whole page around it. Second, class names must be literal in the views. The collector reads what the templates say, so a name assembled at run time never reaches the compiler. This is why @if.class switches between whole class lists rather than appending a word to one.

The complete language, on one screen

The language is small on purpose, and the two ideas below carry the grammar. First, application is juxtaposition (clamp n 1 10, with no commas and no parentheses around the call). Second, parentheses are required wherever precedence would otherwise be implicit. There is no precedence table to memorize and none to get wrong.

Declarations

KeywordBodyAnswers
receive name(a, b)statementsan ADDRESSED name: one an @on slot writes, or a case of the schema's receive
intent name(a)statementsa case of the schema's intent: an intent that arrived here
compute nameone expression$name in a value slot
pred nameone expressiona condition slot, or @when
invariant nameone expressionthe same, plus every transition
enrich namestatements over @binds@enrich-with inside a loop
bindWith namestatements over @binds@enrich-with on a scope

A /// comment above a declaration is its documentation and travels with it. There is no when keyword. A filter and a boolean compute are the same construct, so @when is a use of a pred. The two transition kinds take contract clauses between the name and the body, as in receive push requires canPush ensures moved { … }. Each clause names a pred, with at most one of each kind.

Statements

it.count := 0                     assign
it.count += d   it.count -= d    add, subtract
tag = @str{@(value)}              bind (in an enrich)
it.items.push(v)                  a collection method,
it.items.delete_at(i)             receiver first
it.by_id.set_at(k, v)
it.labels.push(Label(text: "hi")) a record, where it is used
send("flash", it.msg)             an effect
send("done", 1, ~to: it.rows[k])
ask("picked", key, ~route: dyn)   …routed rather than addressed
ask("load", ~route: lex)
forward()         reply(v)
fail(e)           drop()
if c | a | b                      a branch

A place can be nested. it.a.b and it.a[k].b are exactly what a view slot cannot spell. That is the reason the logic: section exists at all.

Expressions

FamilyOperatorsChaining
logicand orfree
compareis is not < <= > >=exactly two operands
impliesimpliesexactly two operands
add+ -free
multiply* / modfree

Mixing two families in one unparenthesized chain is a parse error. The message names the parentheses to add. So a and b and c is fine. a + b * c is refused. a < b < c is refused too. Prefix not and - negate. A negative literal in an argument list needs its own parentheses (send 'nudge' (-1)). The reason: - is an operator here rather than part of the number.

The atoms are the view's atoms, spelled identically: .field, @bind, $method, *dyn, 'text', $'…{expr}…', numbers and booleans. This is deliberate: what is new in a body is operators, statements and nesting. So someone who can read a tutuca view can read a handler without learning a second vocabulary.

The read vocabulary

The list is closed, and closed on purpose. The language is total because it cannot call anything the runtime did not put here.

empty? x    truthy? x   falsy? x    null? x
len xs      has xs k    contains s sub
min a b     max a b     clamp x lo hi     int x    num x
str x       lower s     upper s     trim s

Where a card stops

The limit is one sentence long: a card cannot name a MoonBit value. Everything below follows from it.

Reach past any of those and you are writing a component. Nothing is thrown away in the move. The same file gains a tutuca gen step, which reads these same three blocks and emits typed MoonBit: the state struct, the codec, the descriptor, the message enums and the compiled view tree. Every handler the block could compile stays exactly as written. The ones it cannot are refused by name, with the reason, and come back as an update~ argument.

Know the other direction too. Because a card needs no toolchain, checking one needs no browser. The same runtime this page loads exposes __tutucard.check(source, name). That call returns the report you see above the previews: the component's name and every issue, with lines and character ranges. It compiles nothing and mounts nothing — __tutucard.compile is the call that answers a module. A build step over a directory of cards, or an agent that just generated one, validates with that call and no DOM. It is what keeps every example on this page loading.

Keep going