# A component tier — 2026-10-24

A design review of the layer's HTML surface, written against `2d28ad2`,
asked whether m0 should render markup the way FastHTML does —
`Div(P("x"), cls="card")`, a function per tag, children positional,
attributes as keywords — and compared eight shapes on the same page and
the same 50-item list. This is the record of what the probes found, what
was decided (D67) and what was built (SPEC N54, N55), in the repository's
own order: an application that asks, a wire gate, then the lift. The
application is `apps/fragment_notes`, the gate `smoke-fragment-notes`,
unchanged.

## Where it stood

`html.mojo` had two tiers over one escaping rule. The builder (`Html`,
`Fragment`) is statement-shaped — `open`, `attr`, `text`, `close` — and
allocates once per render. The expression tier (`el`, `void`, `attr`,
`flag`, `text`, `Fragment.el`) makes an element a `String` so a renderer
reads like its markup, and allocates per element
([one-renderer-two-transports](https://m0serve.dev/notes/one-renderer-two-transports.md), D19).
Both name the escaping at each hole: `text(x)` for data, `attr(name, x)`
for a value, a bare string for markup the caller trusts (D1).

That last clause is the hole. In `el("li", "", title)` a request-derived
`title` is markup, and the injection reads like every other argument;
nothing in the type system notices that `text(...)` was left off. The
review's recommendation was a tier where a bare string is TEXT by
construction, so the escaped path is not the shorter one but the only one
a child can take without saying so.

## The probes

Every figure below is from the pinned toolchain (Mojo 1.2.0, 2016d0cd) on
a 4-vCPU Linux container, `mojo build` defaults, best of five rounds of
20,000 renders, two runs agreeing within 5%. The list is 50 `<li>` items,
each with an id, two `hx-` attributes and escaped text, 3,153 bytes; every
form below renders byte-identical output, checked before timing.

**The review's probes reproduce.** A component form over `String` with
`@implicit` constructors on a `Child` type (from `String`, `StringLiteral`,
`El`, `List[El]`) and `var **attrs` compiles and runs; so does a
compile-time HTML literal (`html["<li id=\"{}\">{}</li>"](id, title)`),
whose unquoted hole in a tag and whose wrong arity are compile errors; so
does a form bound to a struct through `std.reflection` (`field_names`,
`field_types`, `field_ref`). The review's render figures reproduce too.

**Option 5 — a tree rendered once — does not pay.** The review left it
open, since the builder is five times faster than any per-element form on
a list and a tree rendered into one buffer at the root looked like the way
to that figure. Measured, with the dictionary's cost separated out:

| form, the 50-item list | µs |
|---|---|
| `Html` builder | 7.5 |
| expression tier (`el`, `attr`, `text`) | 28 |
| components, `String` per element, `**attrs` dictionary | 38 |
| components, `String` per element, attributes pre-rendered (no dictionary) | 29 |
| components, a tree of recorded builder operations rendered once at the root | 38 |
| components, one `List[UInt8]` per element presized from its parts | 24 |

The tree is no faster than the strings it replaces: the children are
evaluated before their parent whatever the shape, so each allocates, and
what the tree saves in escaping it spends copying operation records. The
keyword dictionary is a quarter of the per-element cost. The presized byte
buffer is the form to build — slightly faster than the expression tier,
three times the builder — and the builder stays for a renderer that is hot
(D19 stands).

**The 3b overload coexists.** A `Fragment.el` taking `Child` children
beside the one taking `String` children resolves without ambiguity on
today's call shapes, the exact type winning. It was still given its own
name, `Fragment.element`: in the new tier a bare string is text, and a
call that hands one to the old overload as its `attrs` argument would
raise from `raw_attrs` with a message about the wrong thing.

**Two things the review's probe got wrong, fixed in the tier.** Its
`_tag` closed every element, so `Input(...)` rendered `<input></input>`;
the tier's void elements take keywords only, and a child handed to one is
a compile error. Its own `Todo.__ft__` typed `hx_swap_oob=True` as a raw
keyword, which is the attribute the layer forbids an application to spell
([a-fragment-that-names-itself](https://m0serve.dev/notes/a-fragment-that-names-itself.md)); the
tier refuses the swap attributes at render, below.

**The reflection probe fails the warning ratchet as written.**
`UnsafePointer(to=...)` is deprecated on 1.2.0 and warns; `Pointer(to=...)`
with the same `unsafe_bitcast` compiles clean. The dispatch compares type
names as strings (`SIMD[DType.int, 1]` for `Int`), which a pin move can
change silently, and the probe defaults a missing field to zero where the
tree's `form(req)` is `Optional` precisely so that absent cannot read as
empty. Reflection is not part of this round; it waits for an application
that asks, with a `check-*` probe guarding the API as `check-mojoc-trait`
guards the trait fix.

**The dev loop is untouched.** A one-line edit to `apps/fragment_notes`
(678 lines) rebuilds in 31 s here against the ~10 s `m0 dev`'s docstring
assumes; 200 call sites of either tier compile in 17.9 s. Authoring
ergonomics were the review's whole case, and this is the number that
dominates them. It is recorded here as the open cost, not answered.

## The decision (D67)

An auto-escaping element type, in the component tier alone. `El` is markup
by construction and a bare `String` child is text; `String` stays the
currency at every boundary — a fragment finishes to a `String`,
`reply.html` takes one, the builder is unchanged — so D1's helpers stand
where they stood and the hole they left is closed where an application
writes its elements.

The expression tier is superseded, not removed. The two spellings invert
what a bare string means (`el`: markup; `Li`: text), so one file holding
both is the quiet mis-escaping hazard this round exists to remove: no file
mixes them, and each application leaves `el` whole, under its own gate.
`fragment_notes` went first, in this round; `table_notes`, `blobs`, the
four scaffold templates and `login.mojo`'s `csrf_input` follow a round
each, and `el` goes when the last has left. N10 says so.

Not built with it, each an application's ask away: a `Component` trait
(FastHTML's `__ft__`), the form bound to a struct by reflection, the
compile-time literal. The literal is worth a line: D2's recorded reasons
are new syntax and attack surface, an `open()` in the request path and the
loss of the compile-checked context struct, and a `comptime` literal has
none of the three — so D2 as written does not forbid it, and the question
when an application asks is whether a hole syntax inside a Mojo string is
a template language in the sense that matters. It escapes every hole alike
in the probe, with no trusted-markup hole.

## What was built

In `html.mojo`, below the expression tier:

- `El`, the bytes of a rendered element; `html()` reads it as a `String`.
- `Child`, with implicit constructors from `String` and `StringLiteral`
  (escaped), `El` (markup) and `List[El]` (each in turn).
- `AttrValue`, from `String` and `StringLiteral` (escaped and quoted),
  `Int` (decimal) and `Bool` (presence: `checked=True` is ` checked`,
  `checked=False` is nothing).
- `_element` and `_void_element`, one buffer presized from the parts;
  `element` and `void_element` for a tag the module does not name; `Raw`
  for markup in a string, named as the builder's `raw` is.
- The keyword's spelling: `cls` is `class`, a leading `_` is dropped
  (`_for`), every other `_` is `-` (`hx_boost`, `aria_label`, `data_n`).
- The refusal: a keyword that spells a swap — `hx-VERB` for the six htmx
  verbs, `hx-target`, `hx-swap`, `hx-push-url`, `hx-headers`, anything
  under `data-on` — raises at render, naming `Fragment.element`. At render
  and not in a source check, because an application outside the tree
  meets the rule there and nowhere else; `hx_boost`, `hx_confirm`,
  `hx_indicator` pass, since they are not a swap.
- `Html.raw(El)` and `Fragment.raw(El)`, so an element goes into a builder
  or a fragment as it stands.
- `Fragment.element(tag, verb, url, children..., push=, header=,
  **attrs)`: the keywords, then the swap, then the push and header, then
  the children — the bytes `Fragment.el` writes for the same element, in
  both vocabularies, held by `test_html.mojo`.

`src/tags.mojo` holds the tag functions, one line each over `_element` or
`_void_element`: 98 elements and 13 void ones from the living standard,
without the obsolete elements and without `html`, whose name the builder
holds (the document element is the page shell's, or `element("html",
...)`). They are imported by name from `m0_http.tags`, not from
`m0_http`'s root, where `Form` is the parsed form.

`apps/fragment_notes`' `render_list` and `render_login` moved onto it;
`render_note` stays in the builder on purpose, the two shapes side by
side. `csrf_input` still renders a `String`, so the field goes in as
`Raw(csrf_input(csrf))` until `login.mojo`'s round. The smoke did not
change.

## Not built, and why

- **Migrating the other applications and the templates here.** Each has a
  gate of its own (`smoke-table-notes`, `smoke-blobs`, the scaffold smoke
  and `sabotage-scaffold`), and the method is one application per round
  under its gate. The scaffold's `AGENTS.md` still teaches `el`; it moves
  with the templates.
- **A `Component` trait.** Four lines, and nothing asks yet; a trait
  behind a `.mojoc` is also the shape that lost its witness table through
  Mojo 1.0 ([a-trait-and-a-directory-name](https://m0serve.dev/notes/a-trait-and-a-directory-name.md)).
- **A faster tier.** The builder's figure is not reachable by a form that
  evaluates children first; a form that writes into the parent's buffer
  needs a storable closure or a statement, and the statement form exists.

## Measured, and withdrawn — 2026-10-24

The plan was to move the scaffold's templates next and then ask whether an
application benefits, with the answer decided by a rule written before
any run: the component tier pays if an agent writing an application on it
takes at least 20% fewer tool calls or costs 20% less, or leaves data
unescaped less often; otherwise it is withdrawn while only
`fragment_notes` and the templates have moved.

The templates moved on a branch (#627, closed): `views`, `auth` and `live`
rendered byte-identical, `board` changed one attribute
(`data-bind:text` to the value form `data-bind="text"`, the same signal
in Datastar 1.0.4's bundle and in a browser), and `sabotage-scaffold`'s
five re-pointed arms were caught. Two things the tier could not spell
turned up there: an attribute with a `:`, and an attribute on a start
tag whose element the renderer does not hold.

The measurement was the agent usability harness's Mojo track, five pairs,
one session on each tier started together, Opus 5.5, the two wheels built
locally (`0.13.0+el` from `main`, `0.13.0+tags` from the branch) and
reached through `UV_FIND_LINKS`, with a probe of its own for escaping. On
the recorded brief every agent took `--template board` and wrote no
element in either tier, so it measured nothing about rendering. On a
brief no template answers — an author on every message, `anonymous` when
empty, a delete button on each — every agent wrote elements:

| | expression tier | component tier |
|---|---|---|
| contract and UI checks | 5/5 | 5/5 |
| escape failures, text or author, page or stream | 0 | 0 |
| tool calls, median (range) | 17 (14-22) | 21 (16-24) |
| cost, median | $0.65 | $0.74 |
| compile errors from the tier | — | 0 |

Every expression-tier agent wrapped the request-derived author in
`text(...)`; a copy with that call removed reads raw on the page and the
stream, so the probe sees the hole when it is there. The fifteen
applications archived from earlier runs, all on the expression tier,
escape too. The component tier's extra calls went into the framework's
source: four of five fetched the live views page, deployed at 1.16.0,
which shows `f.el` where their scaffold used `f.element`, and said so in
their reports. That confound runs against the tier, but its removal
cannot reach the bar: parity is not a gain, and there are no escape
failures to reduce.

So the tier is withdrawn (D68): `fragment_notes` returns to the
expression tier, N54 and N55 are out of scope, and D1's helpers stand.
What would reopen it is the evidence this round did not find — an
application, or an agent, that leaves request data unescaped on the
expression tier, or a measurement where the component tier is cheaper by
the margin above. Both arms also reported the same gap, which belongs to
the layer whichever tier it uses: no page shows a Datastar delete button.
