What a card is
A card is a tutuca view file — a state schema, a script block and one or more templates — interpreted instead of compiled. The page you are reading parsed the first example below and mounted it, in your browser, with nothing between the two: no MoonBit compiler, no worker, no build step, no server.
It is the same framework the rest of this site is about, with the compiler taken out. The schema still generates mutators, the checker still reads the script block and reports the same diagnostics with the same line and column, and the views are the views. What changes is when — a card is read at mount rather than at build — and what you give up for it is the subject of the last section.
The file has three kinds of block, and only the last is required:
<script type="tutuca/state"> fields, types, payloads
<script type="tutuca/script"> handlers, derived values
<template> the views
Every example below is one such file, whole and runnable. The bar
above each one shows the component's name and what the checker made
of it — ok, or the number of issues, each with a button
that selects the exact characters it is about. Break one on purpose:
that report is the point, and reset puts the
original back.
Step 1 The schema, on its own
This card has no script block at all, and still answers three events.
Every field a state declares comes with mutators named
after it — setName, resetName,
nameLen for any field, plus toggleLoud
because loud is a Bool. A view calling one
is a component that needed no handler.
The field's kind decides what it generates, and the kind is the declared type rather than a choice at the call site:
| Declared | What comes with it |
|---|---|
Bool | toggleX |
Array[T] |
pushInX, insertInXAt, setInXAt, removeInXAt |
Map[String, T] |
setInXAt, removeInXAt |
Set[String] |
addInX, removeInX, hasInX, toggleInX |
| any field | setX, resetX, xLen |
Two spellings in that card are worth naming now, because they are the
two halves of every view. :value=".name" is a
value slot: it reads. @on.input="setName value"
is a handler slot: the first word is the message to dispatch
and the rest are arguments, resolved from the event —
value, valueAsInt, key,
isAlt and a handful more.
Step 2 The script block
An on handler answers a name the views write. You reach
for one when no mutator is the transition you mean: two fields moving
together, an argument that has to be clamped before it lands, a
branch.
Statements end at a newline or a ;, and the state they
write is rebound as they go — the second line of step
below reads what the first one wrote, so the trail records where the
counter ended.
That card also answers init. A card starts at the
schema's zero — an Int is 0, an Array is
empty — because there is no MoonBit here to pass a seed in. tutuca
has no lifecycle either: nothing in the framework calls an
initialiser, the host dispatches init
at mount, and a card that wants to start somewhere else answers it
like any other message. Every card after this one seeds itself that
way.
Note what step does not say: nowhere is
dir annotated. An on handler's argument
types are inferred from what the templates write at the call site, so
@on.click="step 1" is what makes it a number — and a
call site that disagrees with the body is a reported issue, not a
silent coercion.
Step 3 Values the state does not hold
A compute is one expression with a name, called from a
template as $name. A pred is the same
construct with its answer declared a boolean, which is what lets a
condition slot be checked rather than merely evaluated. Neither can
assign, so neither can be the reason a render is wrong.
if appears twice in this language and means two things.
In a statement body it is a branch and the else is
optional. In a value position — a compute, a
pred, an interpolation — both arms are required, because
an expression has to have a value.
The $'…' in status is a string template:
single quotes with a leading $, and
{…} holding any expression. A bare
'…' is a plain string literal — it may span lines as it
is, and it carries 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 and binds @key and
@value for the row. In this card the remove button calls
removeInSongsAt @key — a generated mutator, straight
from the view, with no handler in between — while moving a row is an
on handler, because two writes at two positions is
exactly the shape no single mutator has.
Inside a body, a collection is written through its receiver:
.songs.push v is pushInSongs, with
the infix dropped because the receiver is written out. The methods
are push, insertAt, setAt,
deleteAt/removeAt, clear for
sequences and maps, and add,
remove/delete, toggle,
set for sets and maps.
A list of records needs one more thing, because the language
has no literals to write one with: new <Type>
builds a value at its type's zero and makes it the
active target, which the statements under it fill in
through @cur. Then it is pushed like anything else. This
card's songs is an Array[String] and needs
none of it; had the schema declared
struct Song { title : String, plays : Int } and
songs : Array[Song], a row would be built like this:
receive init {
new Song
@cur.title = 'Ramble On'
@cur.plays = 0
.songs.push @cur
}
The type is spelled the way the state block spells it —
new Song, new Array[String],
new Map[String, Int] — and has to be one that block
declares. A new resets the target, so building two rows
is two news; a path into it works, 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 is that, running: an
Array[Label] seeded and grown with new, and
a nested write —
.labels[key].done = not .labels[key].done — which is the
other half of what a record buys and the one pair a view slot cannot
spell.
Step 5 What a loop asks the block
Two directives on an @each element hand work back to the
script. @when names a pred that keeps a row
or drops it. @enrich-with names an enrich
that answers a question about the row and binds the answer
for the template to read.
The reason the checkbox reads @picked rather than
calling a predicate is a scoping rule worth carrying with you: a
compute or a pred called from a
value slot is handed the state and its own arguments, and
nothing else — @value is not in scope inside one.
@when and @enrich-with resolve through the
loop instead, so those two are handed the row.
Which is also why the two directives spell their argument
differently. @when="matches" names a predicate bare; it
is a name the loop resolves. @show="$any" reads a
value, so it carries the $ that says a callable
answers it.
Step 6 Messages
An on handler is named by a view, so the views are where
its arguments come from. A receive case is named by
nobody the checker can see — it is raised by another handler, by a
parent, or by the host at mount — so the schema is the only place it
can be declared, and the only place its payload can be typed.
send is one of five effects a body may perform. They are
the vocabulary a dynamic WebAssembly component already answers with,
which is not a coincidence: an interpreted card and a compiled
component hand the host the same list, and neither knows what it will
do with them.
send 'name' args…— at this component.bubble 'name' args…— up the path the event was raised on. A card mounted on its own is the root, so a bubble from one has nowhere to go.request 'name'— resolved through the host's registry; a page that registers no handler answers nothing.sendAt &.rows[k] 'name' args…— at a position.&.rows[k]is the position;.rows[k]is what is there, and the difference is what makes a late answer land on the row that asked for it.stop— stop propagation.
Effects go out 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, because half a transition is the one outcome nobody can reason about afterwards.
Step 7 Asking for what you do not have
request is the fourth channel and the only asynchronous
one. A handler names a piece of work — request 'loadQuote'
— and the host answers: the name resolves to a
handler registered on the module, not to anything the component can
see, which is what lets the same component run against a real fetch
in production and a fixture in a test.
The answer comes back as a response arm, and it carries
two values: the result and the error, exactly one of which
is Null. There is no exception to catch and no rejected
promise — a failure is a value, arriving on the same
path as a success, at the component that asked.
That card is showing you its error path, live, with nothing mocked:
this page registers no handler for loadQuote, and an
unanswered name is not a crash. The runtime replies
Err("Request not found: loadQuote") down the ordinary
error path, so a typo in a request name and a network failure arrive
in the same place, in the same shape. Give the card a
response arm and it handles both.
Two things the block deliberately does not carry.
RequestOpts — per-call response names, and whether a
path is pinned at request time or re-resolved on arrival — stays in
MoonBit, because it is the part a small language would model badly.
And bubble, the third channel, needs an ancestor to
reach: a card mounted on its own is the root, so a bubble
from one has nowhere to go. Both are 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, and what a rule
is depends on where you attach it:
-
Precondition —
on push requires canPush { … }. Asked before the body runs, against the state as it arrived. It does not hold, the transition does not happen. -
Postcondition —
on pushAll requires canPush ensures hereEmpty { … }. Asked after the body ran, against where it landed: about what this handler was supposed to achieve — the all → button below is the one that has to end withhereat zero. One clause of each kind is the limit, and both on one header is what a transition with something to ask and something to promise looks like. -
Invariant —
invariant conserved { … }, declared once at the top level, checked after every transition the block declares, including the ones written later that never mention it. That is what makes it the component's rule rather than a handler's.
None of the three needs a rollback, and that is the framework's doing
rather than the contract's: 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, and the queued effects are
dropped with it, so nothing went out about a move that did not
happen. That is why a postcondition can be checked 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 for the shape these rules
take. a implies b is (not a) or b, it takes
exactly two operands, and a chain of them is refused — because
"a implies b implies c" is a sentence people write and
nobody reads the same way twice.
A refusal is reported, not swallowed. 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 point of attaching a rule at all. "The transition did
not happen" is already the framework's answer to everything, and it is
invisible — a state that did not move looks exactly like a
state that had nothing to move. A contract is you saying which of
those silences is a bug, and the report is where that reaches a host.
The half after the colon is the rule's own format: the
sentence it says when it does not hold, written above the
body and 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 wants both: the comment says what the rule is,
statically, and the format says what went wrong this time. It
is an ordinary $'…' template, so there is no second
interpolation syntax to learn, and 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_refusal switches on a channel that carries the
code (PRECONDITION, INVARIANT,
NO_HANDLER, …), what was asked for, which rule said no,
that sentence — and the state that was rejected, which is the thing
you actually want to look at. It is off until somebody asks, a
declining handler is not a refusal (the generated setter
behind it is the design), and one dispatch produces at most one
record. In a test, @harness.no_refusals(…) turns the
channel into a failure, which is what makes a test about a guarded
button mean something: 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. Two limits worth knowing.
A contract takes a name and the rule it names takes no
arguments — a rule that needs one is about this dispatch rather than
about the component, and if is still where that goes.
And an invariant covers the transitions the block declares:
the generated setters a card answers by default are not one of them.
Two ways to make a card look like something
Every card on this page is styled by class name.
None of them carries a line of CSS: the views name
margaui's
component classes — card, btn,
input, badge, join,
stats — with Tailwind utilities beside them, and the
page compiles them in your browser. The mounted card publishes the
class names its views used, and a ~0.5 MB wasm build of the same
compiler the CLI's gen-margaui-css uses turns them into
CSS.
The other way needs nothing at all. A <template>
may hold a <style> block, which the framework
scopes to that view — so its rules cannot reach another component,
and 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 to reach for is the ordinary question: a class list buys you a
design system and costs a compiler, a <style>
block costs nothing and buys you what you write. The compiler is
opt-in for exactly that reason —
<mb-card src="…" margaui>, and a page whose cards
style themselves never fetches it.
Two things worth knowing if you embed one. The CSS is
scoped to the preview: margaui's stylesheet carries
Tailwind's preflight and a :root theme, so injected as
written it would not style the card, it would flatten the page around
it. And class names have to be literal in the views
— the collector reads what the templates say, so a name assembled at
run time is a name that never reaches the compiler. That is why
@if.class switches between whole class lists rather than
appending a word to one.
The whole language, on one screen
It is small on purpose, and the two ideas below carry the grammar:
application is juxtaposition
(clamp n 1 10, no commas, no parentheses around the
call), and parentheses are required wherever precedence
would otherwise be implicit. There is no precedence table to
remember and none to get wrong.
Declarations
| Keyword | Body | Answers |
|---|---|---|
on name(a, b) | statements | an @on name a view writes |
receive name(a) | statements | a case of the schema's receive |
bubble name(a) | statements | a case of the schema's bubble |
response name(a) | statements | a case of the schema's response |
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 |
enrichScope 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 four
transition kinds take contract clauses between the name and the body
— on push requires canPush ensures moved { … } — each
naming a pred, at most one of each.
Statements
.count = 0 assign
.count += d .count -= d add, subtract
new Label build one, at @cur
@cur.text = 'hi' …and fill it in
@tag = $'{@value}' bind (in an enrich)
.items.push v a collection method,
.items.deleteAt i receiver first
.byId.set k v
new Label build a value at its zero,
@cur.text = 'hi' fill it in through `@cur`,
.labels.push @cur then put it somewhere
send 'flash' .msg an effect
bubble 'picked' @key
sendAt &.rows[k] 'done' 1
request 'load' stop
if cond { … } else { … } a branch, else optional
A place can be nested — .a.b and .a[k].b
are exactly what a view slot cannot spell, and the reason the block
language 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, and the message names the parentheses to add.
So a and b and c is fine, a + b * c is
refused, and a < b < c is refused too. Prefix
not and - negate; a negative literal in an
argument list wants its own parentheses
(send 'nudge' (-1)), because - 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. That 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 reading vocabulary
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 line is one sentence long: a card cannot name a MoonBit value. Everything below follows from it.
- No imports, no closures, no components built at run time. A handler that walks a dispatch path or constructs a child is a handler the compiler was for.
-
No record literal — but a record. There is no
syntax for one, because the language has no way to name a value it
is not building.
new Labelputs the type's zero at@curand the statements under it fill it in, so.labels.push @curis how a list of records grows. -
One component per file. A file declaring several
is refused by name: a card is one component, and several want
tutuca gen-viewsand a module. -
No
<template>, no card. State and script describe a component; a view is what makes one visible. -
The views are not checked. The report above each
preview is the script block judged against the schema.
gen-viewschecks the templates too — every.fielda slot reads, including inside a loop — and a card cannot, because its views are parsed at mount rather than compiled. Misspell one in a template here and it readsNulland renders nothing, quietly.
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-views 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 — and 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.
The other direction is worth knowing too. Because a card needs no
toolchain, checking one needs no browser: the same runtime this page
loads exposes __tutucard.check(source, name), which
answers the report you see above the previews — the component's name
and every issue, with lines and character ranges — and mounts
nothing. 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 loader with more panels: the state tree, the dispatch log, and a structured editor that shows one block of the file at a time.
- The landing page — the same framework with the compiler in, compiling MoonBit in your browser.
- Storybook — every ported example, compiled ahead of time, with a Lint panel per story.
- Source — the loader is
tutucard/, the language istscript/.