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:
| Declared | Generated mutators |
|---|---|
Bool | toggleX |
Array[T] |
pushInX, insertInXAt, setInXAt, removeInXAt |
Map[String, T] |
setInXAt, removeInXAt |
Set[String] |
addInX, removeInX, hasInX, toggleInX |
| any field | setX, 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.
send 'name' args…— to this component.sendAt &.rows[k] 'name' args…— to a position.&.rows[k]is the position;.rows[k]is what is there. This difference sends a late answer to the row that asked for it.intent [dyn|lex]… 'name' args…— to whoever answers.dynwalks the ancestors.lexwalks the handlers registered on the host. With no leg written, the intent walksdyn lex. See step 7.forward— pass the arriving message to the next hop. Its name stays.reply v/fail e— answer an intent that reached you. Either statement ends the walk.stop— end the walk and answer nothing.
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:
-
Precondition —
receive push requires canPush { … }. The checker asks it before the body runs, against the state as it arrived. If it does not hold, the transition does not happen. -
Postcondition —
receive pushAll requires canPush ensures hereEmpty { … }. The checker asks it after the body ran, against where it landed. It states what this handler was supposed to achieve. The all → button below is the one that must end withhereat zero. One clause of each kind is the limit. Both clauses on one header describe a transition with something to ask and something to promise. -
Invariant —
invariant conserved { … }, declared once at the top level. The checker runs it after every transition the block declares, including the ones written later that never mention it. Thus the rule belongs to the component, not to a handler.
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
| Keyword | Body | Answers |
|---|---|---|
receive name(a, b) | statements | an ADDRESSED name: one an @on slot writes, or a case of the schema's receive |
intent name(a) | statements | a case of the schema's intent: an intent that arrived here |
compute name | one expression | $name in a value slot |
pred name | one expression | a condition slot, or @when |
invariant name | one expression | the same, plus every transition |
enrich name | statements over @binds | @enrich-with inside a loop |
bindWith name | statements 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
| Family | Operators | Chaining |
|---|---|---|
| logic | and or | free |
| compare | is is not < <= > >= | exactly two operands |
| implies | implies | exactly two operands |
| add | + - | free |
| multiply | * / mod | free |
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.
-
No imports and no closures. A handler that walks a
dispatch path, or hands a MoonBit function to the framework, is a
handler that needs
genand a module. -
No record literal, but a record. A record is
written where it is used, as a constructor call, so
it.labels.push(Label(text: it.draft))is how a list of records grows. A collection inside one arrives empty, because there is no list literal to fill it with. -
Several components, but no reading into one. A
file may name a component in each of
spec:,logic:andview:; the root is the first one, or the one marked~root. A card even builds children at run time —Todo(text: it.draft)beside aTodoinspec:asks the host to make one. What it cannot do is reach into a child: the instance belongs to the host and the card holds a token, so.rows[0].textis refused when the card compiles. Send the child a message instead. -
No
<template>, no card. State and script describe a component. A view makes one visible. -
No MoonBit in a refused handler.
genrefuses a handler it cannot compile by name and hands it back as anupdate~argument you write yourself. A card has nowhere to write that argument: what the compiler refuses is left out of the module, and the host treats that handler's name as unhandled.
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
- The card playground — the same compiler with more panels: the state tree, the dispatch log, the card's
tests:scenes run against an in-memory DOM, the compiled module as Wax, and a structured editor that shows one section of the file at a time. - The landing page — the same framework with the compiler included. It compiles MoonBit in your browser.
- Storybook — every ported example, compiled ahead of time, with a Lint panel per story.
- Source — the card compiler is
tgc/emit/, the format istgc/SPEC.md, the language istscript/.