← Back to home

16. UI: the walker primitive library

Page bodies are written in a closed primitive library — 56 top-level primitives (Stack, Heading, Field, Table, CreateForm, QueryView, Chart, For, …) plus the two sub-elements Tab and Column, and the match expression. There is no escape hatch to raw markup; every primitive is dispatched by the body walker into the page's active design pack, so one .ddd body renders as Mantine, shadcn, Vuetify, Angular Material, daisyUI (Feliz), Material 3 (Flutter) or HEEx depending on the hosting frontend. Reach for this chapter to see what each primitive takes, what it emits, and where a target honestly declines.

Grammar: primitives are BuilderCalls whose names come from the registry src/generator/_walker/registry.ts (WALKER_PRIMITIVES), mirrored for the validator by src/util/walker-primitive-names.ts (WALKER_LAYOUT_PRIMITIVES, WALKER_SUB_PRIMITIVES, WALKER_PRIMITIVE_SLOTS) and src/util/walker-primitive-args.ts (WALKER_PRIMITIVE_NAMED_ARGS) / src/util/walker-primitive-arg-values.ts (WALKER_PRIMITIVE_ARG_VALUES) · Validators: loom.unknown-page-element · loom.unresolved-page-ref · loom.page-primitive-unknown-arg · loom.page-primitive-unknown-arg-value · loom.page-primitive-extra-children · loom.sub-primitive-misplaced · loom.slot-outside-component · loom.component-prop-type · loom.a11y-missing-alt · loom.a11y-icon-only-no-name · loom.file-upload-not-file-field · loom.frontend-collection-op-unsupported · loom.user-visible-concat · loom.chart-kind-invalid · loom.chart-of-not-grouped · loom.chart-accessor-not-field · loom.datagrid-selection-not-state · loom.datagrid-selection-not-array · loom.table-filter-server-paged · loom.op-form-needs-route-id · loom.destroy-form-of-unresolved · loom.create-unknown-field · loom.create-server-field · loom.create-field-type · per-target: loom.target-unsupported#datagrid · loom.target-unsupported#chart · loom.table-filter-unsupported · loom.modal-controlled-op-form-unsupported · loom.flutter-primitive-unsupported · Docs: ../page-metamodel.md §9, ../design-packs.md, ../actions.md

Every output block below is real generated output from one fixture — a ui Web with the pages shown, bound to seven deployables: react (Mantine v9, the default pack), vue (Vuetify), svelte (shadcnSvelte), angular (Angular Material), feliz, flutter, and a self-hosting elixir deployable (Phoenix LiveView, coreComponents). The frontend tab group carries the four static-bundle frontends; Feliz, Flutter and HEEx output follows as plain blocks where it diverges. A second react deployable pinned design: shadcn drives the pack tabs.

The dispatch model

A page body: is one primitive call whose children are nested primitive calls. The shared walker (walkBody, src/generator/_walker/walker-core.ts) recurses the body ExprIR; for each call it looks the name up in WALKER_PRIMITIVES and invokes the renderer through the active WalkerTarget. Six targets ride that one core — react, vue, svelte, angular (src/generator/<fw>/walker/*-target.ts), feliz (feliz-target.ts, emits F#) and flutter (flutter-target.ts, emits Dart). Phoenix LiveView does not: src/generator/elixir/heex-walker-core.ts is a parallel engine dispatching off the same registry's heex renderers, because LiveView's topology (hoisted handle_event clauses, for comprehensions, cond blocks) diverges too far to share.

Every registry entry also declares its a11y contract (role, needsName, needsAlt, landmark, …) — that is what emits the role="toolbar", aria-label, role="img" and aria-hidden attributes you will see below, and what the loom.a11y-* gates read.

The inventory

Positional slots are what a primitive renders positionally (WALKER_PRIMITIVE_SLOTS); a container renders every positional as a child. Named args are the only ones any emitter reads (WALKER_PRIMITIVE_NAMED_ARGS); testid: and style: { … } (an object literal of CSS declarations) are accepted by every primitive.

Group Primitive Positional slots Named args Notes
Layout Stack, Group, Empty, Tabs, Breadcrumbs, KeyValueRow container (Empty: 1 — message; KeyValueRow: 2 — label, value) — Tabs owns Tab { <label>, …children }
Layout Grid container cols
Layout Container container size
Layout Card container variant, shadow
Layout Paper container padding
Layout Toolbar container label role="toolbar" + accessible name
Layout Section container id <section id> — anchor target, region landmark
Layout Sticky container top position: sticky wrapper
Layout Divider 0 label role="separator"
Inputs Field, NumberField, PasswordField, MultilineField, Toggle 1 — label bind, error controlled against a state field
Inputs SelectField 1 — label bind, options, error options: is a string-array expression
Inputs FileUpload 1 — label bind, error bind: must be a File state field
Display Heading 1 — text level, size, weight, gradient
Display Text, Bold, Italic, InlineCode 1 — text —
Display Badge 1 — label —
Display Anchor 1 — label to an in-app route, wired through the router link
Display Image 1 — src src, alt, decorative alt: or decorative: true required
Display Avatar 0 src, alt, decorative
Display Icon 0 name, svg, size, label, decorative builtin name (src/generator/_walker/icons.ts) or svg: literal; decorative unless label:
Display Alert 1 — message title, color role="alert"
Display Skeleton 0 count, height
Display Loader 0 size role="status"
Display Stat 2 — label, value —
Display CodeBlock 1 — source source, language, title vendored highlight.js (lib/common, 36 languages) bundled into the app — no CDN — as a lazily-loaded chunk fetched only once a code block appears; an unknown language: renders as plain text; the source is never translated
Display Slot 0 — the caller's children, component bodies only
Formatters Money 1 — value value, currency, decimals verbatim digits, see below
Formatters DateDisplay 1 — value value
Formatters EnumBadge 1 — value value, color
Formatters IdLink 1 — id of, id link to the aggregate's detail route
Formatters FileLink 1 — value value <a href download> over a File value
Formatters ProvenanceInfo 1 — record of, field "?" disclosure over a provenanced field
Formatters Timeline 1 — entries of the history(id) audit trail
Data QueryView 0 of, loading, error, empty, data, single, paged reads an api/projection query
Data Table container (Column children) rows, keyExpr, onRowClick, rowTestid, pageSize, striped, highlight, sticky, serverPaged, totalPages, sortKey, sortDir, page, filter
Data DataGrid container (Column children) rows, multiSort, columnVisibility, pageSize, selection TanStack Table; not on HEEx / Flutter
Data Column (sub) 2 — header, cell accessor field, sortable, filterable only inside Table / DataGrid
Data Chart 0 of, kind, x, y grouped projection → "line" / "bar"
Data For 2 — collection, item lambda each, empty, render
Control Button 1 — label label, onClick, to, disabled, loading, variant, icon, iconSvg, iconPosition, emphasis, type
Control Action 1 — the operation ref then one-button operation invocation
Control Modal container (state-controlled) / trigger + OperationForm trigger, title, open
Forms CreateForm 1 — the aggregate of, onSubmit
Forms OperationForm 1 — record.op of, op
Forms WorkflowForm 1 — the workflow runs, onSubmit
Forms DestroyForm 1 — the aggregate of, then deletes the record at the route :id

Tab and Column have no renderer of their own — their parent consumes them inline.

Arity, argument and placement gates

Because every emitter reads args by name and by slot index, anything outside the vocabulary above used to vanish silently (while its string still landed in the i18n catalog). Each of these is now an error, proven with ddd parse:

Stat { "a", 1, Text { "x" } }        // loom.page-primitive-extra-children — Stat takes 2 (label and value)
Card { title: "x", Text { "y" } }    // loom.page-primitive-unknown-arg — Card has no `title:`
Button { "Save", variant: "filled" } // loom.page-primitive-unknown-arg-value — primary | secondary | ghost
Text { "x", style: "red" }           // loom.page-primitive-unknown-arg#style-not-object — style: takes { … }
Stack { Tab { "x", Text { "y" } } }  // loom.sub-primitive-misplaced — Tab only inside Tabs
Stack { Heding { "x" } }             // loom.unknown-page-element (+ loom.unknown-builder-type)
Text { nosuchthing }                 // loom.unresolved-page-ref — no param / state / derived / lambda / store field
Stack { Slot { } }                   // loom.slot-outside-component — in a page body
Image { "/a.png" }                   // loom.a11y-missing-alt — add alt: or decorative: true
Button { icon: "trash" }             // loom.a11y-icon-only-no-name (warning) — add text or label:
Heading { "Order " + n }             // loom.user-visible-concat — use `Order {n}` (translatable ICU)
Stat { "n", rows.count }             // loom.frontend-collection-op-unsupported — compute it server-side
Box { title: 3 }                     // loom.component-prop-type — the component's `title: string` prop got an int

Closed-vocabulary argument values

A handful of named arguments take a fixed set of strings rather than free text. They are listed in src/util/walker-primitive-arg-values.ts and enforced by loom.page-primitive-unknown-arg-value:

Argument Accepted What an unrecognised value used to render
Button { variant: } primary · secondary · ghost ghost — a borderless text button
Button { iconPosition: } left · right right
Card { variant: } raised · flat · outline flat

The gate matters more than the vocabulary. Every pack template is an {{#if (eq variant "primary")}}…{{else}}ghost{{/if}} chain, so an unrecognised value was not dropped — it fell to the last arm and rendered as a deliberate-looking ghost button, on every frontend, with no diagnostic. This page itself carried Button { "Save", variant: "filled" } with <Button variant="subtle"> printed underneath as the expected output.

The vocabularies are pinned against both rendering families by test/language/type-system/walker-primitive-arg-values.test.ts: each row must equal the HEEx packs' attr :<arg>, :string, values: [...] declaration (a compile-time constraint on that target) and must cover every value the JSX/SFC packs branch on. Arguments with no single agreed vocabulary — Card { shadow: }, size: — are deliberately left open; the table says why.

Only a string LITERAL is checked. A value computed at runtime (variant: rank) is not refused, since this layer cannot evaluate it.

Form { … }, scaffoldList { … }, Dashboard, Review, Select, Fieldset are not primitives (loom.unknown-page-element) — the form family is the four named leaves below, and list/detail pages come from scaffold (15).

Layout & surfaces

page LayoutDemo {
  route: "/layout"
  state { editing: bool = false  draftName: string = "" }
  body: Container {
    Grid {
      Group { Avatar { alt: "Product" }, Heading { "Product detail", level: 2 } },
      Card { Stack {
        Field { "Name", bind: draftName },
        Toggle { "Editing", bind: editing },
        Stat { "Price", 1999 },
        Divider {},
        Money { 1999, currency: "USD" },
        KeyValueRow { "Owner", Text { "platform-team" } }
      } }
    },
    testid: "layout-demo"
  }
}

Every user-visible literal is wrapped in the generated t(key, default) shim (the i18n catalog, .loom/messages.en.json); the keys are content hashes.

// src/pages/layout_demo.tsx — Mantine
const [editing, setEditing] = useState<boolean>(false);
const [draftName, setDraftName] = useState<string>("");
return (
  <Container data-testid="layout-demo">
    <Grid>
      <Grid.Col span="auto">
        <Group>
          <Avatar alt="Product" />
          <Title order={2}>{t("page.LayoutDemo.heading.8ba34l", "Product detail")}</Title>
        </Group>
      </Grid.Col>
      <Grid.Col span="auto">
        <Card withBorder padding="md">
          <Stack>
            <TextInput label={t("page.LayoutDemo.inputLabel.4el6o6", "Name")} value={draftName} onChange={(e) => setDraftName(e.currentTarget.value)} />
            <Switch label={t("page.LayoutDemo.inputLabel.vkb7an", "Editing")} checked={editing} onChange={(e) => setEditing(e.currentTarget.checked)} />
            <Stack gap={2}>
              <Text size="sm" c="dimmed">{t("page.LayoutDemo.statLabel.pdyg96", "Price")}</Text>
              <Text fw={700} size="xl">{1999}</Text>
            </Stack>
            <Divider />
            <MoneyValue value={ 1999 } currency="USD" />
            <KeyValueRow label={t("page.LayoutDemo.keyValue.71lv90", "Owner")}><Text>{t("page.LayoutDemo.text.uprclg", "platform-team")}</Text></KeyValueRow>
          </Stack>
        </Card>
      </Grid.Col>
    </Grid>
  </Container>
);
<!-- src/pages/layout_demo.vue — Vuetify; state is `const draftName = ref("")` -->
<v-container data-testid="layout-demo">
  <v-row>
    <v-col>
      <div class="d-flex align-center flex-wrap ga-3"><v-avatar alt="Product"></v-avatar><h2>{{ t("page.LayoutDemo.heading.8ba34l", "Product detail") }}</h2></div>
    </v-col>
    <v-col>
      <v-card variant="outlined" class="pa-4">
        <div class="d-flex flex-column ga-3">
          <v-text-field :label='t("page.LayoutDemo.inputLabel.4el6o6", "Name")' :model-value="draftName" @update:model-value="setDraftName" />
          <v-switch :label='t("page.LayoutDemo.inputLabel.vkb7an", "Editing")' :model-value="editing" @update:model-value="(v) => setEditing(!!v)" />
          <!-- Stat / Divider / Money / KeyValueRow … -->
        </div>
      </v-card>
    </v-col>
  </v-row>
</v-container>
<!-- src/routes/(app)/layout/+page.svelte — shadcnSvelte; state is `let draftName = $state<string>("")` -->
<div class="container mx-auto px-4" data-testid="layout-demo">
  <div class="grid gap-4 grid-cols-3">
    <div class="flex flex-row items-center gap-4"><!-- Avatar --><h2 class="text-2xl font-semibold tracking-tight">{t("page.LayoutDemo.heading.8ba34l", "Product detail")}</h2></div>
    <div class="rounded-xl border bg-card p-6">
      <label class="flex flex-col gap-2"><span class="loom-label">{t("page.LayoutDemo.inputLabel.4el6o6", "Name")}</span><input class="loom-input" value={draftName} oninput={(e) => { draftName = e.currentTarget.value; }} /></label>
      <!-- Toggle / Stat / Divider / Money / KeyValueRow … -->
    </div>
  </div>
</div>
// src/app/pages/layout-demo.component.ts — Angular Material; state is `readonly draftName = signal("")`
<div class="loom-stack" data-testid="layout-demo">
  <div class="loom-grid">
    <div><div class="loom-group"><!-- Avatar --><h2>{{ t("page.LayoutDemo.heading.8ba34l", "Product detail") }}</h2></div></div>
    <div>
      <mat-card>
        <mat-form-field><mat-label>{{ t("page.LayoutDemo.inputLabel.4el6o6", "Name") }}</mat-label><input matInput [value]="draftName()" (input)="draftName.set($any($event.target).value)" /></mat-form-field>
        <mat-slide-toggle [checked]="editing()" (change)="editing.set($event.checked)">{{ t("page.LayoutDemo.inputLabel.vkb7an", "Editing") }}</mat-slide-toggle>
        <!-- Stat / Divider / Money / KeyValueRow … -->
      </mat-card>
    </div>
  </div>
</div>

The remaining surfaces — Section, Paper, Sticky, Toolbar, Breadcrumbs, Tabs/Tab, Divider { label: } — from one Display page:

body: Stack {
  Section { Heading { "Typography", level: 2 }, Text { "Plain text" }, id: "typography" },
  Paper { Alert { "Saved.", title: "Done", color: "green" }, padding: "md" },
  Sticky { Toolbar { Button { "Save", variant: "primary" }, label: "Actions" }, top: 0 },
  Breadcrumbs { Anchor { "Home", to: "/" }, Text { "Display" } },
  Tabs {
    Tab { "Overview", Text { "Overview body" } },
    Tab { "Details", Text { "Details body" } }
  },
  Divider { label: "Formatters" }
}
<section id="typography">
  <Title order={2}>{t("page.Display.heading.hpgbak", "Typography")}</Title>
  <Text>{t("page.Display.text.f1rqam", "Plain text")}</Text>
</section>
<Paper p="md">
  <Alert color="green" variant="light" title={t("page.Display.alertTitle.3cn9g1", "Done")}>{t("page.Display.alert.cageta", "Saved.")}</Alert>
</Paper>
<Box pos="sticky" top="0" style={{ zIndex: 100 }}>
  <Group justify="space-between" role="toolbar" aria-label={t("page.Display.toolbarAria.rx51qc", "Actions")}>
    <Button variant="filled">{t("page.Display.button.lewgh4", "Save")}</Button>
  </Group>
</Box>
<Breadcrumbs>
  <Anchor component={RouterLink} to="/">{t("page.Display.anchor.n0mxf2", "Home")}</Anchor>
  <Text>{t("page.Display.text.oz7oad", "Display")}</Text>
</Breadcrumbs>
<Tabs defaultValue="overview">
  <Tabs.List>
    <Tabs.Tab value="overview">{t("page.Display.tabLabel.thnxru", "Overview")}</Tabs.Tab>
    <Tabs.Tab value="details">{t("page.Display.tabLabel.43f6md", "Details")}</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="overview"><Text>{t("page.Display.text.iuvnky", "Overview body")}</Text></Tabs.Panel>
  <Tabs.Panel value="details"><Text>{t("page.Display.text.sbdi7r", "Details body")}</Text></Tabs.Panel>
</Tabs>
<Divider label={t("page.Display.dividerLabel.zfocqq", "Formatters")} labelPosition="center" />
<section id="typography">
  <h2>{{ t("page.Display.heading.hpgbak", "Typography") }}</h2>
  <div>{{ t("page.Display.text.f1rqam", "Plain text") }}</div>
</section>
<v-card variant="outlined" class="pa-4">
  <v-alert color="green" variant="tonal" :title='t("page.Display.alertTitle.3cn9g1", "Done")'>{{ t("page.Display.alert.cageta", "Saved.") }}</v-alert>
</v-card>
<div style="position: sticky; top: 0; z-index: 100">
  <div class="d-flex align-center justify-space-between ga-3" role="toolbar" :aria-label='t("page.Display.toolbarAria.rx51qc", "Actions")'>
    <v-btn color="primary" variant="flat">{{ t("page.Display.button.lewgh4", "Save") }}</v-btn>
  </div>
</div>
<nav :aria-label='t("pack.vuetify.breadcrumbsLandmark.dfc3fw", "Breadcrumb")' class="loom-breadcrumbs d-flex align-center ga-2 text-body-2 mb-2">
  <router-link to="/">{{ t("page.Display.anchor.n0mxf2", "Home") }}</router-link>
  <div>{{ t("page.Display.text.oz7oad", "Display") }}</div>
</nav>
<div>
  <v-tabs v-model="__loomTab">
    <v-tab value="overview">{{ t("page.Display.tabLabel.thnxru", "Overview") }}</v-tab>
    <v-tab value="details">{{ t("page.Display.tabLabel.43f6md", "Details") }}</v-tab>
  </v-tabs>
  <v-tabs-window v-model="__loomTab">
    <v-tabs-window-item value="overview"><div>{{ t("page.Display.text.iuvnky", "Overview body") }}</div></v-tabs-window-item>
    <v-tabs-window-item value="details"><div>{{ t("page.Display.text.sbdi7r", "Details body") }}</div></v-tabs-window-item>
  </v-tabs-window>
</div>
<v-divider>{{ t("page.Display.dividerLabel.zfocqq", "Formatters") }}</v-divider>
<section id="typography">
  <h2 class="text-2xl font-semibold tracking-tight">{t("page.Display.heading.hpgbak", "Typography")}</h2>
  <p class="text-sm leading-relaxed">{t("page.Display.text.f1rqam", "Plain text")}</p>
</section>
<div class="rounded-lg border bg-card p-4 shadow-sm">
  <div role="alert" class="relative w-full rounded-lg border px-4 py-3 text-sm border-border bg-background"><p class="mb-1 font-medium leading-none tracking-tight">{t("page.Display.alertTitle.3cn9g1", "Done")}</p><div class="text-sm [&_p]:leading-relaxed">{t("page.Display.alert.cageta", "Saved.")}</div></div>
</div>
<div style="position: sticky; top: 0; z-index: 100">
  <div class="flex flex-row items-center justify-between gap-4" role="toolbar" aria-label={t("page.Display.toolbarAria.rx51qc", "Actions")}>
    <button type="button" class="loom-btn loom-btn-primary">{t("page.Display.button.lewgh4", "Save")}</button>
  </div>
</div>
<nav class="flex items-center gap-2 text-sm text-muted-foreground">
  <a href="/" class="text-primary hover:underline">{t("page.Display.anchor.n0mxf2", "Home")}</a>
  <p class="text-sm leading-relaxed">{t("page.Display.text.oz7oad", "Display")}</p>
</nav>
<Tabs tabs={[{ value: "overview", label: t("page.Display.tabLabel.thnxru", "Overview") }, { value: "details", label: t("page.Display.tabLabel.43f6md", "Details") }]} defaultValue="overview">
  {#snippet body(active)}
  {#if active === "overview" }<p class="text-sm leading-relaxed">{t("page.Display.text.iuvnky", "Overview body")}</p>{/if}
  {#if active === "details" }<p class="text-sm leading-relaxed">{t("page.Display.text.sbdi7r", "Details body")}</p>{/if}
  {/snippet}
</Tabs>
<div class="relative flex items-center py-4"><div class="flex-grow border-t border-border"></div><span class="mx-4 text-sm text-muted-foreground">{t("page.Display.dividerLabel.zfocqq", "Formatters")}</span><div class="flex-grow border-t border-border"></div></div>
<section id="typography">
  <h2>{{ t("page.Display.heading.hpgbak", "Typography") }}</h2>
  <div>{{ t("page.Display.text.f1rqam", "Plain text") }}</div>
</section>
<div class="loom-paper">
  <div class="loom-alert loom-alert-green" role="alert"><div class="loom-alert-title">{{ t("page.Display.alertTitle.3cn9g1", "Done") }}</div><div class="loom-alert-message">{{ t("page.Display.alert.cageta", "Saved.") }}</div></div>
</div>
<div style="position: sticky; top: 0; z-index: 100">
  <div class="loom-toolbar" role="toolbar" [attr.aria-label]='t("page.Display.toolbarAria.rx51qc", "Actions")'>
    <button mat-raised-button>{{ t("page.Display.button.lewgh4", "Save") }}</button>
  </div>
</div>
<nav class="loom-breadcrumbs">
  <a class="loom-anchor" [routerLink]='"/"'>{{ t("page.Display.anchor.n0mxf2", "Home") }}</a>
  <div>{{ t("page.Display.text.oz7oad", "Display") }}</div>
</nav>
<mat-tab-group>
  <mat-tab [label]='t("page.Display.tabLabel.thnxru", "Overview")'><div>{{ t("page.Display.text.iuvnky", "Overview body") }}</div></mat-tab>
  <mat-tab [label]='t("page.Display.tabLabel.43f6md", "Details")'><div>{{ t("page.Display.text.sbdi7r", "Details body") }}</div></mat-tab>
</mat-tab-group>
<div class="loom-divider-labelled"><mat-divider class="loom-divider-rule"></mat-divider><span class="loom-divider-label">{{ t("page.Display.dividerLabel.zfocqq", "Formatters") }}</span><mat-divider class="loom-divider-rule"></mat-divider></div>

Phoenix renders the same page through the app's function components (<.card>, <.button>, <.badge>, <.empty>) and a JS-command tab bar:

<%!-- lib/lv_web/live/display_live.ex --%>
<section id="typography">
  <h2 class="text-lg font-semibold leading-8 text-zinc-800"><%= pgettext("page.Display.heading.hpgbak", "Typography") %></h2>
  <p><%= pgettext("page.Display.text.f1rqam", "Plain text") %></p>
</section>
<.card>
  <div class="alert alert-success" role="alert"><p class="font-medium"><%= pgettext("page.Display.alertTitle.3cn9g1", "Done") %></p><%= pgettext("page.Display.alert.cageta", "Saved.") %></div>
</.card>
<div style="position: sticky; top: 0; z-index: 100">
  <div class="flex flex-row items-center justify-between gap-4" aria-label={pgettext("page.Display.toolbarAria.rx51qc", "Actions")} role="toolbar">
    <.button variant="primary"><%= pgettext("page.Display.button.lewgh4", "Save") %></.button>
  </div>
</div>
<div class="tabs">
  <div role="tablist" class="tab-bar">
    <button type="button" role="tab" id="tabs-1-tab-overview" class="tab tab-active" phx-click={JS.hide(to: "[data-tabs='tabs-1']") |> JS.show(to: "#tabs-1-panel-overview") |> …}><%= pgettext("page.Display.tabLabel.thnxru", "Overview") %></button>
    …
  </div>
  <div role="tabpanel" id="tabs-1-panel-overview" data-tabs="tabs-1" class="tab-panel">…</div>
</div>

Flutter has no HTML — the same tree is a widget tree (Column / Row / Card / DefaultTabController + TabBar), and Feliz an Html.div [ prop.className "…"; prop.children [ … ] ] list against daisyUI classes.

Text & display

Stack {
  Heading { "Typography", level: 2 },
  Text { "Plain text" }, Bold { "Bold text" }, Italic { "Italic text" }, InlineCode { "ddd generate system" },
  Badge { "Beta" },
  Anchor { "Loom docs", to: "/docs" },
  Empty { "Nothing here yet." },
  Skeleton { count: 3 },
  Loader { size: "sm" },
  Icon { name: "check", label: "Verified" },
  Image { "/logo.png", alt: "Acme logo" },
  CodeBlock { "let x = 1", language: "ts", title: "Sample" }
}
<Title order={2}>{t("page.Display.heading.hpgbak", "Typography")}</Title>
<Text>{t("page.Display.text.f1rqam", "Plain text")}</Text>
<strong>{t("page.Display.bold.cl1gdl", "Bold text")}</strong>
<em>{t("page.Display.italic.f5ts34", "Italic text")}</em>
<code>{t("page.Display.code.wndwjf", "ddd generate system")}</code>
<Badge>{t("page.Display.badge.67jzzb", "Beta")}</Badge>
<Anchor component={RouterLink} to="/docs">{t("page.Display.anchor.dzcdln", "Loom docs")}</Anchor>
<Center mih={200}><Text c="dimmed">{t("page.Display.empty.jije2k", "Nothing here yet.")}</Text></Center>
<Stack gap="xs" aria-hidden="true">
  { Array.from({ length: 3 }).map((_, i) => (<Skeleton key={i} height={ 28 } radius="sm" />)) }
</Stack>
<Loader size="sm" />
<span className="loom-icon" role="img" aria-label={t("page.Display.iconLabel.jnn2zp", "Verified")} dangerouslySetInnerHTML={{ __html: "<svg viewBox=\"0 0 24 24\" …><path d=\"M5 12l4 4L19 7\"/></svg>" }} />
<Image src="/logo.png" alt="Acme logo" />
<div className="loom-code-block">
  <div className="loom-code-block-title">{t("page.Display.codeBlockTitle.pb7tfr", "Sample")}</div>
  <pre><code className="language-ts">let x = 1</code></pre>
</div>
<h2>{{ t("page.Display.heading.hpgbak", "Typography") }}</h2>
<div>{{ t("page.Display.text.f1rqam", "Plain text") }}</div>
<strong>{{ t("page.Display.bold.cl1gdl", "Bold text") }}</strong>
<em>{{ t("page.Display.italic.f5ts34", "Italic text") }}</em>
<code>{{ t("page.Display.code.wndwjf", "ddd generate system") }}</code>
<v-chip size="small">{{ t("page.Display.badge.67jzzb", "Beta") }}</v-chip>
<router-link to="/docs">{{ t("page.Display.anchor.dzcdln", "Loom docs") }}</router-link>
<div class="d-flex align-center justify-center text-medium-emphasis" style="min-height: 200px">{{ t("page.Display.empty.jije2k", "Nothing here yet.") }}</div>
<div class="d-flex flex-column ga-2" aria-hidden="true"><v-skeleton-loader v-for="__i in 3" :key="__i" type="text" height="28" /></div>
<v-progress-circular indeterminate size="24" />
<span class="loom-icon" role="img" :aria-label='t("page.Display.iconLabel.jnn2zp", "Verified")' v-html='"<svg …>"'></span>
<v-img src="/logo.png" alt="Acme logo" />
<h2 class="text-2xl font-semibold tracking-tight">{t("page.Display.heading.hpgbak", "Typography")}</h2>
<p class="text-sm leading-relaxed">{t("page.Display.text.f1rqam", "Plain text")}</p>
<strong>{t("page.Display.bold.cl1gdl", "Bold text")}</strong>
<em>{t("page.Display.italic.f5ts34", "Italic text")}</em>
<code>{t("page.Display.code.wndwjf", "ddd generate system")}</code>
<span class="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold">{t("page.Display.badge.67jzzb", "Beta")}</span>
<a href="/docs" class="text-primary hover:underline">{t("page.Display.anchor.dzcdln", "Loom docs")}</a>
<div class="flex min-h-[200px] items-center justify-center"><p class="text-sm text-muted-foreground">{t("page.Display.empty.jije2k", "Nothing here yet.")}</p></div>
<div class="flex flex-col gap-2" aria-hidden="true">{#each Array.from({ length: 3 }) as _unused, i (i)}<div class="h-7 animate-pulse rounded-sm bg-muted"></div>{/each}</div>
<span class="loom-spinner inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" role="status" aria-label={t("chrome.loading", "Loading")}></span>
<span class="loom-icon inline-flex items-center" role="img" aria-label={t("page.Display.iconLabel.jnn2zp", "Verified")}>{@html "<svg …>" }</span>
<img class="rounded-md" src="/logo.png" alt="Acme logo" />
<h2>{{ t("page.Display.heading.hpgbak", "Typography") }}</h2>
<div>{{ t("page.Display.text.f1rqam", "Plain text") }}</div>
<strong>{{ t("page.Display.bold.cl1gdl", "Bold text") }}</strong>
<em>{{ t("page.Display.italic.f5ts34", "Italic text") }}</em>
<code class="loom-inline-code">{{ t("page.Display.code.wndwjf", "ddd generate system") }}</code>
<span class="loom-badge">{{ t("page.Display.badge.67jzzb", "Beta") }}</span>
<a class="loom-anchor" [routerLink]='"/docs"'>{{ t("page.Display.anchor.dzcdln", "Loom docs") }}</a>
<div class="loom-empty">{{ t("page.Display.empty.jije2k", "Nothing here yet.") }}</div>
<div class="loom-skeleton-group" aria-hidden="true"><div class="loom-skeleton" style="height: 28px"></div>…</div>
<mat-progress-spinner mode="indeterminate" diameter="24"></mat-progress-spinner>
<span class="loom-icon" role="img" [attr.aria-label]='t("page.Display.iconLabel.jnn2zp", "Verified")'><svg …><path d="M5 12l4 4L19 7"/></svg></span>
<img class="loom-image" src="/logo.png" alt="Acme logo" />
// Flutter — lib/pages/display_page.dart (Material 3 widgets, `t()` from lib/i18n.dart)
Semantics(header: true, child: DefaultTextStyle.merge(style: Theme.of(context).textTheme.titleLarge, child: Text(t('page.Display.heading.hpgbak', 'Typography')))),
Text(t('page.Display.text.f1rqam', 'Plain text')),
DefaultTextStyle.merge(style: const TextStyle(fontWeight: FontWeight.bold), child: Text(t('page.Display.bold.cl1gdl', 'Bold text'))),
Chip(label: Text(t('page.Display.badge.67jzzb', 'Beta')), visualDensity: VisualDensity.compact),
TextButton(onPressed: () => Navigator.of(context).pushNamed('/docs'), child: Text(t('page.Display.anchor.dzcdln', 'Loom docs'))),
Icon(Icons.circle, size: 20.0, semanticLabel: t('page.Display.iconLabel.jnn2zp', 'Verified')),
Image.network("/logo.png", semanticLabel: "Acme logo"),

Anchor { to: } is a router link on every frontend (RouterLink / router-link / [routerLink] / pushNamed) — give it an in-app path; an external URL belongs in a menu link (link "Docs" -> "https://…", 15). Icon is decorative (aria-hidden) unless label: names it, which turns it into a role="img" and makes the label a translated slot. On Phoenix Image { "/logo.png", alt: … } and Icon { name: … } render <img alt="…" /> with no src and an empty <span class="loom-icon"> — the HEEx renderers read only a named src: and only an svg: literal (heex-primitives.ts renderImage / renderIcon); spell Image { src: "…", alt: "…" } and pass svg: there.

A text slot renders a scalar, not a value object

Text / Bold / Heading / Badge / Button (and the value half of Stat / KeyValueRow) render their slot as a text child. A value object crosses the wire as a JSON object, which no frontend renders there, so a value-object field read into such a slot is loom.valueobject-in-text-slot — render one of its fields instead (money has its own gate, loom.money-in-text-slot, whose fix is the Money formatter below):

For { each: rows, s => Text { s.address } }        // error: loom.valueobject-in-text-slot
For { each: rows, s => Text { s.address.city } }   // fine
// what the refused form would have emitted — TS2322: Type 'Address' is not assignable to type 'ReactNode'
<Text>{s.address}</Text>
Money { "12.3456" }
Money { "12.3456", currency: "EUR", decimals: 2 }
DateDisplay { "2026-01-02T03:04:05Z" }
EnumBadge { "Public" }

Money renders the wire's own digits, verbatim and locale-neutral — no Number() hop, no grouping, no invented currency symbol, no 2-dp truncation (a NUMERIC(19,4) value shows its 4th decimal). decimals: n re-scales the digit string (half away from zero, never through a float); currency: "EUR" prefixes exactly the code you passed. One implementation, moneyText in src/generator/_frontend/money-format.ts, is spliced into every JS pack's lib/format helper.

<MoneyValue value={ "12.3456" } />                              // → 12.3456
<MoneyValue value={ "12.3456" } currency="EUR" decimals={ 2 } /> // → EUR 12.35
<DateTimeValue iso={ "2026-01-02T03:04:05Z" } />
<Badge tt="none">{ "Public" }</Badge>
// lib/format.tsx: MoneyValue → moneyText(value, currency, decimals)
<span>{{ formatMoney("12.3456") }}</span>
<span>{{ formatMoney("12.3456", "EUR", 2) }}</span>
<span>{{ formatDateTime("2026-01-02T03:04:05Z") }}</span>
<v-chip size="small">{{ "Public" }}</v-chip>
<span class="tabular-nums">{formatMoney("12.3456")}</span>
<span class="tabular-nums">{formatMoney("12.3456", "EUR", 2)}</span>
<span title={ "2026-01-02T03:04:05Z" }>{formatDateTime("2026-01-02T03:04:05Z")}</span>
<span class="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold">{ "Public" }</span>
<span>{{ formatMoney("12.3456") }}</span>
<span>{{ formatMoney("12.3456", "EUR", 2) }}</span>
<span>{{ formatDateTime("2026-01-02T03:04:05Z") }}</span>
<span class="loom-badge">{{ "Public" }}</span>
// Feliz — the raw string, prefixed only by a declared currency; `decimals:` is ignored
Html.span [ prop.className "tabular-nums"; prop.text (string ("12.3456")) ]
Html.span [ prop.className "tabular-nums"; prop.text ("EUR" + " " + string ("12.3456")) ]
<%!-- Phoenix — verbatim + currency prefix; `decimals:` is ignored --%>
<span class="money"><%= to_string("12.3456") %></span>
<span class="money">EUR <%= to_string("12.3456") %></span>
<time datetime={to_string(@row.created_at)}><%= Calendar.strftime(@row.created_at, "%Y-%m-%d") %></time>
<span class="badge badge-enum">Public</span>

Flutter still formats through intl (NumberFormat.decimalPattern() / NumberFormat.currency(symbol: 'EUR ')) rather than the verbatim contract — tracked as M-T1.21.

IdLink { o.id, of: Product } renders the short id as a link to the aggregate's detail route (<RouterLink to={/products/${row.id}}><IdValue id={row.id} /></RouterLink> on React, shortId(row.id) in a router-link on Vue, [routerLink]='"/products/" + row.id' on Angular).

When the linked aggregate ships a display (a derived display or a plain display field) and the id is a reference rather than the row's own id, the link text is that record's display instead of the short id. The label is read by a small per-cell child — LoomRefLabel (src/lib/ref-label.tsx on React, LoomRefLabel.vue / .svelte, the loom-ref-label standalone component on Angular, a LoomRefLabel module in App.fs on Feliz, lib/ref_label.dart on Flutter) — so the hook runs inside the child, never once per row in the page, and the framework's query cache issues one GET /<plural>/{id} per distinct id. The short id stays as the fallback while loading, on error, and when display is empty; the link target is unchanged.

aggregate Project with crudish { name: string  derived display: string = name }
aggregate Task with crudish { title: string  project: Project id }
ui WebApp with scaffold(subdomains: [Core]) { }
// web_app/src/pages/tasks/list.tsx (React, mantine)
<RouterLink to={`/projects/${ row.project }`}><LoomRefLabel path="/projects/" id={ row.project }><IdValue id={ row.project } /></LoomRefLabel></RouterLink>

FileLink { a.attachment } is a native download anchor over a File value ({ url, key, contentType, size }), null-guarded for File?:

page Doc(id: Attachment id) {
  route: "/docs/:id"
  body: QueryView { of: Docs.Attachment.byId(id), single: true,
    data: a => Stack { Heading { a.title, level: 2 }, FileLink { a.attachment } } }
}
// react
{attachmentById.data.attachment ? (
  <a href={attachmentById.data.attachment.url} download>{attachmentById.data.attachment.key}</a>
) : <span>—</span>}
<%!-- phoenix --%>
<%= if @a.attachment do %><a href={@a.attachment["url"]} download><%= @a.attachment["key"] %></a><% else %><span>—</span><% end %>

ProvenanceInfo { of: p, field: "stock" } is the "?" disclosure over a provenanced field's lineage (provenance.md); it reads the { value, lineage } wire carrier and renders a native <details> on the JS frontends and HEEx, an ExpansionTile on Flutter, Html.details on Feliz:

Group { Text { p.stock }, ProvenanceInfo { of: p, field: "stock" } }
// react — pages/product_detail.tsx
<Text>{productById.data.stock.value}</Text>
{productById.data.stock.lineage != null ? (
  <details className="loom-provenance">
    <summary aria-label="How this value was computed">?</summary>
    <dl className="loom-provenance-tree">
      <div><dt>Rule</dt><dd><code>{productById.data.stock.lineage.snapshotId}</code></dd></div>
      <div><dt>Value</dt><dd>{String(productById.data.stock.lineage.computedValue)}</dd></div>
      {productById.data.stock.lineage.inputs.map((inp) => (
        <div key={inp.path}><dt>{inp.path}</dt><dd>{String(inp.value)}</dd></div>
      ))}
    </dl>
  </details>
) : null}
<%!-- phoenix — reads the co-located jsonb column off the Ecto struct --%>
<%= @p.stock %>
<%= if @p.stock_provenance do %>
  <details class="loom-provenance">
    <dl class="loom-provenance-tree">
      <div><dt>Rule</dt><dd><code><%= @p.stock_provenance["snapshotId"] %></code></dd></div>
      …

Timeline { of: entries } renders an audited aggregate's history — the AuditEntry[] a backend serves at GET /<agg>/{id}/history — as an ordered list of action / time / actor / field changes. The read is the derived history(id) (spelled bare, Product.history(id)), which the scaffolded Detail page also emits as its History section:

page ProductHistory {
  route: "/products/:id/history"
  body: QueryView {
    of: Product.history(id),
    data: entries => Timeline { of: entries, testid: "product-history" }
  }
}
const productHistory = useHistoryProduct(id);
// …
<ol className="loom-timeline" data-testid="product-history">
  {(productHistory.data ?? []).map((__e) => (
    <li key={__e.auditId} className="loom-timeline-entry">
      <span className="loom-timeline-action">{__e.action}</span>
      <time dateTime={__e.at}>{new Date(__e.at).toLocaleString()}</time>
      {__e.actor != null ? <span className="loom-timeline-actor">{String(__e.actor)}</span> : null}
      {__e.changes.length > 0 ? (
        <dl className="loom-timeline-changes">
          {__e.changes.map((__c) => (
            <div key={__c.field}><dt>{__c.field}</dt><dd>{String(__c.before ?? "—")} → {String(__c.after ?? "—")}</dd></div>
          ))}
        </dl>
      ) : null}
    </li>
  ))}
</ol>
<ol class="loom-timeline" data-testid="product-history">
  <li v-for="__e in (productHistory.data ?? [])" :key="__e.auditId" class="loom-timeline-entry">
    <span class="loom-timeline-action">{{ __e.action }}</span>
    <time :datetime="__e.at">{{ new Date(__e.at).toLocaleString() }}</time>
    <span v-if="__e.actor != null" class="loom-timeline-actor">{{ String(__e.actor) }}</span>
    <dl v-if="__e.changes.length > 0" class="loom-timeline-changes">
      <div v-for="__c in __e.changes" :key="__c.field"><dt>{{ __c.field }}</dt><dd>{{ String(__c.before ?? "—") }} → {{ String(__c.after ?? "—") }}</dd></div>
    </dl>
  </li>
</ol>
<ol class="loom-timeline" data-testid="product-history">
  {#each (productHistory.data ?? []) as __e (__e.auditId)}
    <li class="loom-timeline-entry">
      <span class="loom-timeline-action">{__e.action}</span>
      <time datetime={__e.at}>{new Date(__e.at).toLocaleString()}</time>
      {#if __e.actor != null}<span class="loom-timeline-actor">{String(__e.actor)}</span>{/if}
      {#if __e.changes.length > 0}<dl class="loom-timeline-changes">…</dl>{/if}
    </li>
  {/each}
</ol>
<ol class="loom-timeline" data-testid="product-history">
  @for (__e of (productHistory.data() ?? []); track __e.auditId) {
    <li class="loom-timeline-entry">
      <span class="loom-timeline-action">{{ __e.action }}</span>
      <time [attr.datetime]="__e.at">{{ __e.at }}</time>
      @if (__e.actor != null) { <span class="loom-timeline-actor">{{ $any(__e.actor) }}</span> }
      @if (__e.changes.length > 0) { <dl class="loom-timeline-changes">…</dl> }
    </li>
  }
</ol>
<%!-- phoenix — the page loads the trail itself in handle_params/3 --%>
defp load_product_history(_socket, id) do
  case Lv.Shop.get_product(id) do
    {:ok, _record} -> Lv.Audit.History.for_target(Lv.Repo, "Product", id) |> Enum.map(&product_audit_entry/1)
    _ -> :error
  end
end
…
<ol class="loom-timeline" data-testid="product-history">
  <%= for e <- @product_history || [] do %>
    <li class="loom-timeline-entry">
      <span class="loom-timeline-action"><%= e["action"] %></span>
      <time datetime={to_string(e["at"])}><%= e["at"] %></time>

Feliz (Html.orderedList … (productHistory |> List.map (fun (__e: AuditEntry) -> …))) and Flutter (...productHistory.map((e) => Padding(… Text(e.action) …))) render the same list from their decoded AuditEntry records.

Inputs — Field, NumberField, PasswordField, MultilineField, SelectField, Toggle, FileUpload

Each is a controlled input bound to a state field through bind:; error: is any expression rendered in the pack's inline error slot (an empty string means no error), and because it is walked in page scope it can read derived values — the idiom for client-only validation (page-metamodel.md §8.2). FileUpload must bind a File state field (loom.file-upload-not-file-field); it uploads to the api's /files endpoint and writes the returned FileRef back.

page Inputs {
  route: "/inputs"
  state { name: string = ""  qty: int = 0  pw: string = ""  bio: string = ""  vis: string = ""  notify: bool = false  doc: File }
  derived pwOk: bool = pw.length >= 8
  body: Stack {
    Field { "Name", bind: name },
    NumberField { "Quantity", bind: qty },
    PasswordField { "Password", bind: pw, error: pwOk ? "" : "At least 8 characters" },
    MultilineField { "Bio", bind: bio },
    SelectField { "Visibility", bind: vis, options: ["Private", "Internal", "Public"] },
    Toggle { "Notifications", bind: notify },
    FileUpload { "Attachment", bind: doc }
  }
}
const [name, setName] = useState<string>("");
const [qty, setQty] = useState<number>(0);
const [pw, setPw] = useState<string>("");
// …
const pwOk = useMemo(() => (pw.length >= 8), [pw]);
return (
  <Stack>
    <TextInput label={t("page.Inputs.inputLabel.4el6o6", "Name")} value={name} onChange={(e) => setName(e.currentTarget.value)} />
    <NumberInput label={t("page.Inputs.inputLabel.c75rso", "Quantity")} value={qty} onChange={(v) => setQty(typeof v === "number" ? v : 0)} />
    <PasswordInput label={t("page.Inputs.inputLabel.cf437c", "Password")} value={pw} onChange={(e) => setPw(e.currentTarget.value)} error={ (pwOk ? "" : "At least 8 characters") } />
    <Textarea label={t("page.Inputs.inputLabel.hi07w1", "Bio")} value={bio} onChange={(e) => setBio(e.currentTarget.value)} />
    <Select label={t("page.Inputs.inputLabel.la143p", "Visibility")} data={ ["Private", "Internal", "Public"] } value={vis} onChange={(v) => setVis(v ?? "")} />
    <Switch label={t("page.Inputs.inputLabel.fki4un", "Notifications")} checked={notify} onChange={(e) => setNotify(e.currentTarget.checked)} />
    <FileInput label={t("page.Inputs.inputLabel.417wqw", "Attachment")} clearable placeholder={ doc?.key ?? t("pack.mantine.chooseFile.ymjjdi", "Choose file") } onChange={async (f) => { if (!f) { setDoc(null); return; } const fd = new FormData(); fd.append("file", f); setDoc(await api.upload("/files", fd)); }} />
  </Stack>
);
readonly doc = signal<FileRef | null>(null);
// …
<label class="loom-field"><span class="loom-label">{{ t("page.Inputs.inputLabel.417wqw", "Attachment") }}</span><input type="file" (change)="onFileUploadTo($event, doc)" /></label>
// onFileUploadTo: sig.set(await api.uploadFile(file));
<%!-- phoenix — each input writes its state field back through a hoisted handle_event;
      FileUpload becomes allow_upload/3 + a progress callback --%>
<.input type="text" name="name" value={@name} label={pgettext("page.Inputs.inputLabel.4el6o6", "Name")} phx-change="update_name" />
<.input type="number" name="qty" value={@qty} label={pgettext("page.Inputs.inputLabel.c75rso", "Quantity")} phx-change="update_qty" />
<.input type="password" name="pw" value={@pw} label={pgettext("page.Inputs.inputLabel.cf437c", "Password")} phx-change="update_pw" />
<.input type="textarea" name="bio" value={@bio} label={pgettext("page.Inputs.inputLabel.hi07w1", "Bio")} phx-change="update_bio" />
// flutter — FileUpload picks with file_picker and POSTs a multipart request to /files
OutlinedButton.icon(icon: const Icon(Icons.upload_file), label: Text(t('page.Inputs.inputLabel.417wqw', 'Attachment')),
  onPressed: () async { final picked = await FilePicker.platform.pickFiles(withData: true); … setDoc(FileRef.fromJson(jsonDecode(resp.body) as Map<String, dynamic>)); }),

The inputs carry the labelled: "associate" a11y contract — every pack renders a real <label> association (Mantine's label=, a <label> wrapper on shadcn/Svelte, <mat-label> on Angular Material).

Design-pack divergence (pack)

The same React page under two packs. Mantine ships named components from @mantine/core; shadcn ships <Input> + <Label> from @/components/ui/* with Tailwind utility classes. The walker tree is identical — only the leaf rendering differs.

<Card withBorder padding="md">
  <Stack>
    <TextInput label={t("page.LayoutDemo.inputLabel.4el6o6", "Name")} value={draftName} onChange={(e) => setDraftName(e.currentTarget.value)} />
    <Switch label={t("page.LayoutDemo.inputLabel.vkb7an", "Editing")} checked={editing} onChange={(e) => setEditing(e.currentTarget.checked)} />
    <Divider />
  </Stack>
</Card>
// import { Card, Divider, Stack, Switch, TextInput } from "@mantine/core";
<Card>
  <CardContent><div className="flex flex-col gap-4">
    <div className="flex flex-col gap-2"><Label>{t("page.LayoutDemo.inputLabel.4el6o6", "Name")}</Label><Input value={draftName} onChange={(e) => setDraftName(e.currentTarget.value)} /></div>
    <div className="flex items-center gap-2"><Switch checked={editing} onCheckedChange={(v) => setEditing(v)} /><Label>{t("page.LayoutDemo.inputLabel.vkb7an", "Editing")}</Label></div>
    <hr className="border-border" />
  </div></CardContent>
</Card>
// import { Card, CardContent } from "@/components/ui/card";
// import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label";

Pack inventory: ls designs/ — React mantine (v7/v9), shadcn (v3/v4), mui (v5/v7), chakra (v2/v3); Vue vuetify, shadcnVue; Svelte shadcnSvelte, flowbite; Angular angularMaterial, primeng, spartanNg; HEEx coreComponents, daisyui. Feliz (daisyUI) and Flutter (Material 3) are procedural packs with no .hbs layer (design-packs.md).

Chart — grouped-projection series (every frontend)

A Chart plots a grouped query-time projection (group by) as a line or bar series. kind: is "line" or "bar" (kind-discriminated — there is no LineChart; loom.chart-kind-invalid otherwise), of: names the projection through an api handle, x:/y: are plain accessor lambdas over the declared row (loom.chart-accessor-not-field); a singleton projection has one row and nothing to chart (loom.chart-of-not-grouped).

projection SalesByVisibility {
  visibility: Visibility
  productCount: int
  revenue: money
  from Product as p
  group by p.visibility
  select visibility = p.visibility, productCount = count(), revenue = sum(p.price)
}
// …
page Dash { route: "/dash"
  body: Chart { kind: "bar", of: Shop.SalesByVisibility, x: r => r.visibility, y: r => r.revenue } }
// react (mantine) — the projection hook is hoisted like any read; the series is
// coerced with Number(): a money field parses into a Decimal, which no chart library plots
const salesByVisibility = useSalesByVisibility();
// …
<div role="img" aria-label="Bar chart of SalesByVisibility: revenue by visibility">
  <BarChart data={ (salesByVisibility.data ?? []).map((r) => ({ visibility: r.visibility, revenue: Number(r.revenue) })) } dataKey="visibility" series={[{ name: "revenue" }]} h={300} withLegend />
</div>
<%!-- phoenix — rows are already a server assign; LoomChart draws inline SVG, no JS --%>
defp load_sales_by_visibility(socket) do
  Lv.Shop.QueryProjections.SalesByVisibility.run(current_user)
  |> Enum.map(fn row -> %{visibility: row.visibility, product_count: row.productCount, revenue: row.revenue} end)
end
…
<LvWeb.Components.LoomChart.chart kind="bar" rows={@sales_by_visibility} x={:visibility} y={:revenue} label="Bar chart of SalesByVisibility: revenue by visibility" />
// feliz — View.chart draws inline SVG from the decoded list
(View.chart true "Bar chart of SalesByVisibility: revenue by visibility" model.SalesByVisibility (fun r -> string r.visibility) (fun r -> float r.revenue))
// flutter — LoomChart is a CustomPainter in lib/chart.dart
LoomChart(isBar: true, label: "Bar chart of SalesByVisibility: revenue by visibility",
  points: (salesByVisibilityRead.asData?.value ?? const []).map((r) => LoomChartPoint(r.visibility.toString(), (r.revenue as num).toDouble())).toList()),

All targets carry the same a11y contract — role="img" (Semantics(image: true) on Flutter) with the derived accessible name. The eight React packs each ship a primitive-chart template binding their own library (@mantine/charts, @mui/x-charts, recharts for shadcn/chakra), pulled into package.json only when a page charts; Vue, Svelte and Angular render a generated dependency-free LoomChart component from one primitive-chart.hbs in the framework's shared template layer (vue/, sveltekit/, angular/). loom.target-unsupported#chart names no shipping frontend today — it is the seam a new frontend gates on.

The form family — CreateForm, OperationForm, WorkflowForm, DestroyForm

CreateForm { of: <Aggregate> } introspects the aggregate's create input and emits one input per field, dispatched by type: string → text, enum → select, bool → switch, int → integer input, money → a decimal text input holding a Decimal, datetime → datetime-local, File → file input, X id → a picker over the target's derived display. The shell wires the submit to the create mutation with zod validation, a success toast, a redirect to the new record, and RFC-7807 field-error mapping. Extra arguments are gated: fields: is not a knob (loom.page-primitive-unknown-arg), and a hand-written create request is checked field by field (loom.create-unknown-field, loom.create-server-field, loom.create-field-type).

page ProductNew {
  route: "/products/new"
  body: Stack {
    Heading { "Create product", level: 2 },
    Card { CreateForm { of: Product, testid: "products-new" } }
  }
}

For aggregate Product { name: string visibility: Visibility active: bool price: money createdAt: datetime stock: int }:

const create = useCreateProduct();
const { register, handleSubmit, setError, control, formState: { errors } } = useForm<CreateProductFormState, unknown, CreateProductRequest>({
  resolver: zodResolver(CreateProductRequest),
  defaultValues: { name: "", visibility: "Private", active: false, price: new Decimal("0"), createdAt: "", stock: 0 },
});
// …
<form onSubmit={handleSubmit(async (vals) => {
  try {
    const out = await create.mutateAsync(vals);
    notifications.show({ color: "green", message: "Product created" });
    navigate(`/products/${out.id}`);
  } catch (e) {
    const outcome = applyServerErrors({ error: e, setError, fieldMap: {} as const });
    // … global / unhandled → red notification
  }
})} data-testid="products-new">
  <Stack gap="md">
    <TextInput label="Name" {...register("name")} data-testid="products-new-input-name" error={errors.name?.message} />
    <Controller control={control} name="visibility" render={({ field, fieldState }) => (
      <Select label="Visibility" data={ ["Private","Internal","Public"] } allowDeselect={false} value={field.value as string} onChange={(v) => field.onChange(v)} error={fieldState.error?.message} />
    )} />
    <Controller control={control} name="active" render={({ field, fieldState }) => (
      <Switch label="Active" checked={!!field.value} onChange={(e) => field.onChange(e.currentTarget.checked)} error={fieldState.error?.message} />
    )} />
    <Controller control={control} name="price" render={({ field, fieldState }) => (
      <TextInput label="Price" value={field.value instanceof Decimal ? field.value.toString() : String(field.value ?? "0")} onChange={(e) => { try { field.onChange(new Decimal(e.currentTarget.value || "0")); } catch { field.onChange(new Decimal("0")); } }} inputMode="decimal" error={fieldState.error?.message} />
    )} />
    <TextInput label="Created At" {...register("createdAt")} type="datetime-local" error={errors.createdAt?.message} />
    <Controller control={control} name="stock" render={({ field, fieldState }) => (
      <NumberInput label="Stock" allowDecimal={false} value={field.value as number | "" | undefined} onChange={(v) => field.onChange(typeof v === "number" ? v : Number(v) || 0)} error={fieldState.error?.message} />
    )} />
    <Group justify="flex-end" mt="sm">
      <Button type="submit" loading={ create.isPending } data-testid="products-new-submit">Create</Button>
    </Group>
  </Stack>
</form>
<!-- a useLoomForm composable drives the same field set -->
const form = useLoomForm(CreateProductRequest, { name: "", visibility: "Private", active: false, price: new Decimal("0"), createdAt: "", stock: 0 });
// …
<v-form @submit.prevent='form.handleSubmit(async (vals) => { const out = await create.mutateAsync(vals); pushToast("Product created"); navigate(`/products/${out.id}`); })($event)' data-testid="products-new">
  <v-text-field label="Name" v-model="form.values.name" :error-messages='form.errors["name"]' />
  <v-select label="Visibility" :items='["Private","Internal","Public"]' v-model="form.values.visibility" :error-messages='form.errors["visibility"]' />
  <v-switch label="Active" v-model="form.values.active" :error-messages='form.errors["active"]' />
  <v-text-field label="Price" inputmode="decimal" :model-value="String(form.values.price ?? '0')" @update:model-value="(v) => form.values.price = String(v || '0')" :error-messages='form.errors["price"]' />
  <v-text-field label="Created At" v-model="form.values.createdAt" type="datetime-local" :error-messages='form.errors["createdAt"]' />
  <v-text-field label="Stock" type="number" step="1" :model-value="form.values.stock" @update:model-value="(v) => form.values.stock = Math.trunc(Number(v)) || 0" :error-messages='form.errors["stock"]' />
  <v-btn type="submit" color="primary" variant="flat" :loading="create.isPending">Create</v-btn>
</v-form>
<%!-- phoenix — a changeset-backed <.simple_form> --%>
socket |> assign(:form, Lv.Shop.change_product(%Lv.Shop.Product{}) |> to_form())
…
<.simple_form for={@form} phx-submit="save_product" data-testid="products-new">

OperationForm { p.archive } (the instance spelling — p is a loaded record) renders the operation's parameter set as a form; it is usually hosted in a Modal behind a trigger button, which is exactly what the scaffold's Detail page emits per public operation:

data: p => Modal { trigger: Button { "Archive…" }, title: "Archive product", OperationForm { p.archive } }
// react — the form is hoisted to module scope and opened through @mantine/modals
function openArchiveModal(mut: ReturnType<typeof useArchiveProduct>): void {
  modals.open({ title: t("page.ProductDetail.modalTitle.umy5vy", "Archive product"),
    children: <ArchiveForm mut={mut} onClose={() => modals.closeAll()} /> });
}
// …
<Button variant="filled" onClick={() => openArchiveModal(archive)}>{t("page.ProductDetail.button.v0v5o9", "Archive…")}</Button>
<.button phx-click={show_modal("product-op-archive-modal")}><%= pgettext("page.ProductDetail.button.v0v5o9", "Archive…") %></.button>
<.modal id="product-op-archive-modal">
  <:title><%= pgettext("page.ProductDetail.modalTitle.umy5vy", "Archive product") %></:title>
  <.simple_form for={@archive_form} phx-change="validate_archive" phx-submit="submit_archive">
// flutter — showDialog around the generated ArchiveProductForm
ElevatedButton(onPressed: () => showDialog(context: context, builder: (dialogContext) => AlertDialog(title: Text(t('page.ProductDetail.modalTitle.umy5vy', 'Archive product')), content: SizedBox(width: double.maxFinite, child: SingleChildScrollView(child: ArchiveProductForm(id: id))))), child: Text(t('page.ProductDetail.button.v0v5o9', 'Archive…'))),

The dialog's submit button names the operation, and no other control in the dialog shares its accessible name. Where the target renders a dismiss button beside it (the React, Svelte and Angular packs), that button says Cancel, or Go back when the operation is itself named cancel. Where the pack's dialog has a corner close icon (mantine, shadcn, shadcnVue, chakra v2, and the HEEx .modal), the icon is named Close, or Close dialog when the operation is itself named close. All four labels are catalogued chrome (chrome.cancel, chrome.goBack, chrome.close, chrome.closeDialog), derived by one rule (opDialogChrome).

aggregate Appointment { … operation cancel(reason: string) when status == Requested { … } }
// react/mantine — the scaffolded Detail page's cancel dialog footer
<Button variant="default" onClick={onClose}>{t("chrome.goBack", "Go back")}</Button>
<Button type="submit" loading={mut.isPending} data-testid="appointments-op-cancel-submit">Cancel</Button>
// … and an `operation close()` dialog's corner icon
modals.open({ title: t("page.Detail.modalTitle.l0xxoj", "Close"),
  closeButtonProps: { "aria-label": t("chrome.closeDialog", "Close dialog") }, … });
<.modal id="order-op-close-modal" close_label={pgettext("chrome.closeDialog", "Close dialog")}>

The op-form spelling OperationForm { of: Product, op: archive } names no record, so it targets the page's :id route param — on a route without one it is rejected (loom.op-form-needs-route-id). A Modal has two shapes that do not combine everywhere: the trigger shape above, and the state-controlled shape Modal { …children, open: <stateBool> } (a children container, dialog title via title:). Putting an OperationForm inside the controlled shape is an error on React, Vue, Svelte and Flutter (loom.modal-controlled-op-form-unsupported); an extra positional next to the op form is loom.page-primitive-extra-children#modal-op-form. WorkflowForm { runs: <wf> } and DestroyForm { of: <Aggregate>, then: navigate(Page) } follow the create shape over a workflow start / the aggregate's canonical destroy (page-metamodel.md §9, 13-workflows). DestroyForm names the aggregate, never a loaded record — it deletes the record at the page's :id route param, confirms through the pack's dialog, and navigates to then: (default: the aggregate's list route). It needs a canonical destroy { } (with crudish gives one), and of: must name the aggregate TYPE — spelling of: p over a QueryView row binding, or passing the form a positional record, is loom.destroy-form-of-unresolved.

workflow registerProduct {
  create(name: string) { let p = Product.create({ name: name, price: 0.0 }) }
}
// …
page ProductNew { route: "/products/register" body: WorkflowForm { runs: registerProduct, testid: "register" } }
page ProductDetail(id: Product id) {
  route: "/products/:id"
  body: QueryView { of: Shop.Product.byId(id), single: true,
    data: p => Stack { Heading { p.name, level: 2 }, DestroyForm { of: Product, then: navigate(Home) } } }
}
// react — pages/product_new.tsx: the workflow's create(…) params are the field set
const run = useRegisterProductWorkflow();
<form onSubmit={handleSubmit(async (vals) => {
  await run.mutateAsync(vals);
  notifications.show({ color: "green", message: "Register Product completed" });
  navigate("/workflows");
})} data-testid="register">
  <TextInput label="Name" {...register("name")} data-testid="register-input-name" error={errors.name?.message} />
  <Button type="submit" loading={ run.isPending } data-testid="register-submit">Run</Button>

// react — pages/product_detail.tsx: DestroyForm is one confirm-then-delete button
const deleteProduct = useDeleteProduct();
<Button onClick={() => { if (window.confirm(t("chrome.deleteConfirm", "Delete this {entity}?", { entity: "product" }))) void deleteProduct.mutateAsync(id ?? "").then(() => { navigate("/"); }); }}
  loading={deleteProduct.isPending} data-testid="products-destroy">{t("chrome.deleteEntity", "Delete {entity}", { entity: "Product" })}</Button>
<%!-- phoenix — product_new_live.ex.  The HEEx workflow form ships a single
      `_placeholder` input instead of the create(…) params (heex-primitives.ts:388) --%>
<.simple_form for={@form} phx-submit="run_register_product" data-testid="register">
  <.input field={@form[:_placeholder]} label="Field" />
  <:actions><.button type="submit" data-testid="register-submit">Submit</.button></:actions>
</.simple_form>

<%!-- product_detail_live.ex --%>
def handle_event("destroy_product", %{"id" => id}, socket) do
  Lv.Shop.destroy_product!(id)
  {:noreply, socket |> put_flash(:info, "Delete succeeded") |> push_navigate(to: ~p"/products")}
end
…
<.button phx-click="destroy_product" phx-value-id={@id} data-confirm="Delete this product? This cannot be undone." class="btn-danger">Delete Product</.button>

QueryView — async data branching

QueryView { of:, loading:, error:, empty:, data: rows => … } reads a query — <api>.<Agg>.all, .byId(id), .history(id) on an audited aggregate, a declared find (including the paged find scaffoldPaged synthesizes — findAllBy<Criterion> / page<Retrieval> — which a JSX frontend can read only when an api its backend serves routes it, e.g. via scaffoldPagedApi; otherwise loom.ui-read-unresolved#unrouted), or a projection — and renders one of four arms by state. That list is CLOSED: an of: naming anything else is refused with loom.ui-read-unresolved, which names what the aggregate does expose. (It is an error on every target because the failure was not: the JSX clients imported a hook that was never emitted, while Phoenix LiveView substituted the unfiltered list_<agg>s() and rendered every row silently.) single: true marks a one-record read (derived anyway from byId / a T? find); paged: true binds the envelope instead of the rows (§9.2 of page-metamodel.md). Because .all is paged by default, the simplest table over it is rewritten at the macro layer into the scaffold's server-paged shape — pageNum / sortKey / sortDir state, the args threaded into the hook, sortable headers, a pager — so unfold ejects real source:

page Products {
  route: "/products"
  body: QueryView {
    of: Shop.Product.all,
    loading: Skeleton { count: 5 },
    error: Alert { "Couldn't load products" },
    empty: Empty { "No products yet." },
    data: rows => Paper { Table {
      rows: rows, striped: true,
      Column { "ID", o => IdLink { o.id, of: Product } },
      Column { "Name", o => Text { o.name } },
      Column { "Visibility", o => EnumBadge { o.visibility } },
      Column { "Price", o => Money { o.price, currency: "USD" } },
      Column { "Created", o => DateDisplay { o.createdAt } }
    } }
  }
}
const [pageNum, setPageNum] = useState<number>(1);
const [sortKey, setSortKey] = useState<string>("");
const [sortDir, setSortDir] = useState<string>("asc");
const productAll = useAllProducts({ page: pageNum, pageSize: 20, sort: sortKey, dir: sortDir });
return (
  <>
    { productAll.isLoading && (<Stack gap="xs" aria-hidden="true">{ /* 5 × <Skeleton/> */ }</Stack>) }
    { productAll.isError && (<Alert color="red" variant="light">{t("page.Products.alert.qx0ntz", "Couldn't load products")}</Alert>) }
    { productAll.data && productAll.data.items.length === 0 && (
      <Center mih={200}><Text c="dimmed">{t("page.Products.empty.nnvlb2", "No products yet.")}</Text></Center>
    ) }
    { productAll.data && productAll.data.items.length > 0 && (
      <Paper p="md">
        <><Table striped>
          <Table.Thead><Table.Tr>
            <Table.Th><button type="button" onClick={() => { /* toggle sortKey/sortDir for "id" */ }}>{t("page.Products.columnHeader.o4495s", "ID")}{sortKey === "id" ? (sortDir === "asc" ? " ↑" : " ↓") : ""}</button></Table.Th>
            {/* … one sortable header per column */}
          </Table.Tr></Table.Thead>
          <Table.Tbody>
            { productAll.data.items.map((row) => (
              <Table.Tr key={ row.id }>
                <Table.Td><RouterLink to={`/products/${ row.id }`}><IdValue id={ row.id } /></RouterLink></Table.Td>
                <Table.Td><Text>{row.name}</Text></Table.Td>
                <Table.Td><Badge tt="none">{ row.visibility }</Badge></Table.Td>
                <Table.Td><MoneyValue value={ row.price } currency="USD" /></Table.Td>
                <Table.Td><DateTimeValue iso={ row.createdAt } /></Table.Td>
              </Table.Tr>
            )) }
          </Table.Tbody>
        </Table>
        <div data-testid="pager"><button type="button" disabled={pageNum <= 1} onClick={() => setPageNum(pageNum - 1)}>{t("chrome.prev", "Prev")}</button><span>{t("chrome.pageOf", "Page {page} of {pages}", { page: pageNum, pages: Math.max(1, productAll.data.totalPages) })}</span><button type="button" disabled={pageNum >= Math.max(1, productAll.data.totalPages)} onClick={() => setPageNum(pageNum + 1)}>{t("chrome.next", "Next")}</button></div></>
      </Paper>
    ) }
  </>
);
const productAll = reactive(useAllProducts(() => ({ page: pageNum.value, pageSize: 20, sort: sortKey.value, dir: sortDir.value })));
// …
<div>
  <template v-if="productAll.isLoading">…skeleton…</template>
  <template v-if="productAll.isError"><v-alert color="red" variant="tonal">{{ t("page.Products.alert.qx0ntz", "Couldn't load products") }}</v-alert></template>
  <template v-if="productAll.data && productAll.data.items.length === 0">
    <div class="d-flex align-center justify-center text-medium-emphasis" style="min-height: 200px">{{ t("page.Products.empty.nnvlb2", "No products yet.") }}</div>
  </template>
  <template v-if="productAll.data && productAll.data.items.length > 0">
    <v-card variant="outlined" class="pa-4"><v-table striped="even">
      <thead><tr><th><button type="button" @click="sortKey === 'id' ? (sortDir = sortDir === 'asc' ? 'desc' : 'asc') : (sortKey = 'id', sortDir = 'asc')">{{ t("page.Products.columnHeader.o4495s", "ID") }}…</button></th><!-- … --></tr></thead>
      <tbody>
        <tr v-for="(row) in productAll.data.items" :key="row.id">
          <td><router-link :to="`/products/${ row.id }`" :title="row.id">{{ shortId(row.id) }}</router-link></td>
          <td><div>{{ row.name }}</div></td>
          <td><v-chip size="small">{{ row.visibility }}</v-chip></td>
          <td><span>{{ formatMoney(row.price, "USD") }}</span></td>
          <td><span>{{ formatDateTime(row.createdAt) }}</span></td>
        </tr>
      </tbody>
    </v-table><!-- pager --></v-card>
  </template>
</div>
const productAll = useAllProducts(() => ({ page: pageNum, pageSize: 20, sort: sortKey, dir: sortDir }));
// …
{#if productAll.isLoading}
    …skeleton…
  {:else if productAll.isError}
    <div role="alert" class="…">{t("page.Products.alert.qx0ntz", "Couldn't load products")}</div>
  {:else if (productAll.data?.items ?? []).length === 0}
    <div class="flex min-h-[200px] items-center justify-center"><p class="text-sm text-muted-foreground">{t("page.Products.empty.nnvlb2", "No products yet.")}</p></div>
  {:else if productAll.data}
    <div class="rounded-lg border bg-card p-4 shadow-sm">
      <div class="w-full overflow-auto"><table class="w-full caption-bottom text-sm">
        <tbody>
          {#each (productAll.data.items ?? []) as row (row.id)}
            <tr class="border-b transition-colors hover:bg-muted/50">
              <td class="p-2 align-middle"><a href={`/products/${ row.id }`} class="text-primary hover:underline"><code title={ row.id }>{formatId(row.id)}</code></a></td>
              <td class="p-2 align-middle"><p class="text-sm leading-relaxed">{row.name}</p></td>
              <td class="p-2 align-middle"><span class="…">{ row.visibility }</span></td>
              <td class="p-2 align-middle"><span class="tabular-nums">{formatMoney(row.price, "USD")}</span></td>
              <td class="p-2 align-middle"><span title={ row.createdAt }>{formatDateTime(row.createdAt)}</span></td>
            </tr>
          {/each}
        </tbody>
      </table></div>
      <!-- pager -->
    </div>
{/if}
readonly productAll = useAllProducts(() => ({ page: this.pageNum(), pageSize: 20, sort: this.sortKey(), dir: this.sortDir() }));
// …
@if (productAll.isLoading()) { …skeleton… }
@if (productAll.isError()) { <div class="loom-alert loom-alert-red" role="alert"><div class="loom-alert-message">{{ t("page.Products.alert.qx0ntz", "Couldn't load products") }}</div></div> }
@if (!productAll.isLoading() && !productAll.isError() && (productAll.data()?.items ?? []).length === 0) {
  <div class="loom-empty">{{ t("page.Products.empty.nnvlb2", "No products yet.") }}</div>
}
@if ((productAll.data()?.items ?? []).length > 0) {
  <div class="loom-paper">
    <table class="loom-table loom-table-striped">
      <tbody>
        @for (row of productAll.data()!.items; track row.id) {
          <tr><td><a class="loom-anchor" [routerLink]='"/products/" + row.id'>{{ shortId(row.id) }}</a></td>
              <td><div>{{ row.name }}</div></td><td><span class="loom-badge">{{ row.visibility }}</span></td>
              <td><span>{{ formatMoney(row.price, "USD") }}</span></td><td><span>{{ formatDateTime(row.createdAt) }}</span></td></tr>
        }
      </tbody>
    </table>
    <!-- pager -->
  </div>
}

React renders four short-circuit expressions; Vue sibling <template v-if>s; Svelte one {#if}/{:else if} chain; Angular @if blocks over signals. Phoenix loads the page in handle_params/3 (Lv.Shop.list_products(page_num, 20, sort_key, sort_dir)) and re-queries on "loom-sort" / "loom-page" events; Flutter wraps a Riverpod FutureProvider in AsyncValue.when(loading/error/data); Feliz decodes into a Remote<'T list> Model field. A QueryView over a projection is only admitted for an unkeyed query-time projection (loom.ui-projection-read-unsupported).

Table, Column and DataGrid

Table { rows:, …Column } is the portable grid: single-column sort (Column { …, sortable: true, field: "name" }), server or client paging (page: / pageSize: / serverPaged: / totalPages:), and one client-side substring filter: <stateField>. The filter renders on the six walkBody frontends only (loom.table-filter-unsupported on Phoenix) and only on a client-paged table — on a server-paged one it would narrow the current page rather than the result set (loom.table-filter-server-paged); note the auto-paged rewrite above makes a bare Table { rows: rows, filter: q } over .all server-paged.

DataGrid swaps the hand-rolled controls for a TanStack Table row model — multi-column sort, per-column filters, column visibility, client pagination, optional row selection — and is emitted as a hoisted child component (<CustomersGrid rows={…} />):

state { picked: string[] }
// …
QueryView { of: Sales.Customer.all, data: rows => DataGrid {
  Column { "Name", o => o.name, sortable: true, filterable: true },
  Column { "Tier", o => o.tier, sortable: true },
  rows: rows, selection: picked, multiSort: true, columnVisibility: true, pageSize: 25, testid: "customers-grid" } }

selection: must name a string[] field of the page's state (loom.datagrid-selection-not-state, loom.datagrid-selection-not-array). It ships on react, vue, svelte, angular and feliz; Phoenix (no client row model) and Flutter (no JS runtime) reject it with loom.target-unsupported#datagrid — use Table, whose sort and paging are server-driven there. The 15 JS packs each ship a primitive-data-grid.hbs for the chrome. Full design notes: page-metamodel.md §9.1.

For — list comprehension

For { each: <coll>, empty?: <markup>, <item> => <markup> } emits the item lambda once per element; it is a child primitive (nest it in a container). The optional empty: arm renders when the collection is empty. The item can be used bare (Badge { tag }) or through member access.

Stack {
  For { each: ["alpha", "beta"], empty: Empty { "No tags" }, tag => Badge { tag } }
}
<>{["alpha", "beta"].length === 0 ? (
  <Center mih={200}><Text c="dimmed">{t("page.Lists.empty.8e7r1j", "No tags")}</Text></Center>
) : (
  ["alpha", "beta"].map((tag, tagIdx) => (
  <Fragment key={tagIdx}>
    <Badge>{tag}</Badge>
  </Fragment>
))
)}</>
<template v-for='(tag, tagIdx) in ["alpha", "beta"]' :key="tagIdx">
  <v-chip size="small">{{ tag }}</v-chip>
</template>
<template v-if='!["alpha", "beta"].length'>
  <div class="d-flex align-center justify-center text-medium-emphasis" style="min-height: 200px">{{ t("page.Lists.empty.8e7r1j", "No tags") }}</div>
</template>
{#each ["alpha", "beta"] as tag, tagIdx (tagIdx)}
  <span class="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold">{tag}</span>
{:else}
  <div class="flex min-h-[200px] items-center justify-center"><p class="text-sm text-muted-foreground">{t("page.Lists.empty.8e7r1j", "No tags")}</p></div>
{/each}
@for (tag of ["alpha", "beta"]; track $index) {
  <span class="loom-badge">{{ tag }}</span>
} @empty {
  <div class="loom-empty">{{ t("page.Lists.empty.8e7r1j", "No tags") }}</div>
}
<%= if Enum.empty?(["alpha", "beta"]) do %>
  <.empty><%= pgettext("page.Lists.empty.8e7r1j", "No tags") %></.empty>
<% else %>
  <%= for tag <- ["alpha", "beta"] do %>
    <.badge><%= tag %></.badge>
  <% end %>
<% end %>
if (['alpha', 'beta'].isEmpty) Center(child: Padding(padding: const EdgeInsets.all(32), child: Text(t('page.Lists.empty.8e7r1j', 'No tags')))) else ...['alpha', 'beta'].map((tag) => Chip(label: Text('${tag}'), visualDensity: VisualDensity.compact)),

The list key is the loop index (tagIdx / $index). Inline stdlib collection ops on the collection render through each frontend's own seam (orders.where(o => …), .map(…), .sortBy(…), …; the full list is in page-metamodel.md §8.1); the backend-only ops are rejected (loom.frontend-collection-op-unsupported), and a host-language method such as .filter(…) is an unknown member (loom.unknown-member).

match in markup — the ternary/block split

match { pred => value, … else => value } is the predicate-arm conditional. In body position it is where React and everyone else part ways: React renders a markup-valued ternary, the rest emit block control flow. A bare identifier before => parses as a lambda (submitted => … is param => body), so write the comparison out (submitted == true =>).

match {
  showArchived == true => Badge { "showing archived" },
  else => Text { "active only" }
}
{((showArchived === true)) ? (<Badge>{t("page.Lists.badge.nr1x3e", "showing archived")}</Badge>) : <Text>{t("page.Lists.text.2ovjgv", "active only")}</Text>}
<template v-if="(showArchived === true)">
  <v-chip size="small">{{ t("page.Lists.badge.nr1x3e", "showing archived") }}</v-chip>
</template>
<template v-else>
  <div>{{ t("page.Lists.text.2ovjgv", "active only") }}</div>
</template>
{#if (showArchived === true)}
  <span class="…">{t("page.Lists.badge.nr1x3e", "showing archived")}</span>
{:else}
  <p class="text-sm leading-relaxed">{t("page.Lists.text.2ovjgv", "active only")}</p>
{/if}
@if ((showArchived() === true)) {
  <span class="loom-badge">{{ t("page.Lists.badge.nr1x3e", "showing archived") }}</span>
} @else {
  <div>{{ t("page.Lists.text.2ovjgv", "active only") }}</div>
}
<%= cond do %>
  <% @show_archived == true -> %>
    <.badge><%= pgettext("page.Lists.badge.nr1x3e", "showing archived") %></.badge>
  <% true -> %>
    <p><%= pgettext("page.Lists.text.2ovjgv", "active only") %></p>
<% end %>
switch (0) { _ when (state.showArchived == true) => Chip(label: Text(t('page.Lists.badge.nr1x3e', 'showing archived')), visualDensity: VisualDensity.compact), _ => Text(t('page.Lists.text.2ovjgv', 'active only')) },

Feliz renders if … then … else … over the Model. Nest a match inside a container: a page whose body: is a bare match is currently dropped from the build with no diagnostic (no page file, no route) — wrap it (body: Stack { match { … } }) until that gap closes.

Button and Action

Button { "label", onClick: <action> | to: <route>, disabled:, loading:, variant:, icon:, iconPosition: } — onClick: names a page/component/store action (never an inline effect: loom.effect-in-lambda), to: navigates. Action { record.op, then: navigate(Page) } is one button that invokes an aggregate operation through its mutation hook and runs then: after it resolves.

component ProductActions(product: Product) {
  body: Toolbar { Action { product.archive, then: navigate(Home) } }
}
// …
Button { "Increment", onClick: bump }
const archiveProduct = useArchiveProduct(product?.id);
<Group justify="space-between" role="toolbar" aria-label="Actions">
  <Button onClick={() => void archiveProduct.mutateAsync({}).then(() => { navigate("/"); })} loading={archiveProduct.isPending}>Archive</Button>
</Group>
// …
<Button onClick={bump}>{t("page.Counter.button.132vha", "Increment")}</Button>
<v-btn @click='() => void archiveProduct.mutateAsync({}).then(() => { router.push("/"); })' :loading='archiveProduct.isPending'>Archive</v-btn>
<v-btn @click='bump'>{{ t("page.Counter.button.132vha", "Increment") }}</v-btn>
<button type="button" class="loom-btn loom-btn-primary" onclick={() => void archiveProduct.mutateAsync({}).then(() => { navigate("/"); })}>Archive</button>
<button type="button" class="loom-btn loom-btn-primary" onclick={bump}>{t("page.Counter.button.132vha", "Increment")}</button>
<button mat-button (click)='onArchiveProduct()' [disabled]='archiveProduct.isPending()'>Archive</button>
// async onArchiveProduct() { await this.archiveProduct.mutateAsync({ id, input: {} }); this.router.navigateByUrl("/"); }
<button mat-button (click)='bump()'>{{ t("page.Counter.button.132vha", "Increment") }}</button>
<.button phx-click="archive_product" phx-value-id={@product.id}>Archive</.button>
<%!-- handle_event("archive_product", …) runs Lv.Shop.archive_product!/1 and push_navigate --%>

An icon-only button needs a name — visible text or label: (loom.a11y-icon-only-no-name, a warning).

Button icons

icon: names a builtin icon — the same registry Icon { name: } reads (src/util/builtin-icons.ts: arrow-right, check, chevron-down, code, cube, document, external, github, grid, list, settings, x). An unknown name renders the button with no glyph at all, so it is loom.button-icon-unknown (a warning); iconSvg: "<svg …/>" passes your own SVG through and wins over icon:.

Button { "Delete", onClick: remove, icon: "trash" }   // warning: loom.button-icon-unknown
Button { "Done", onClick: finish, icon: "check" }     // fine
// react — the resolved SVG is inlined; a miss used to emit the button with no icon
<Button onClick={finish} rightSection={<span className="loom-icon" …><svg width="1em" height="1em" …/></span>}>…</Button>

An Action over an operation with parameters is rejected (loom.action-op-has-params) — use OperationForm.

Slot

Slot { } renders the positional children a caller passed to a component; the component shell declares the matching children parameter. It means nothing in a page (loom.slot-outside-component).

component PageBox(title: string) {
  body: Card { Heading { title, level: 3 }, Slot { } }
}
page Boxed { route: "/boxed" body: PageBox { title: "Welcome", Text { "hi" } } }
// src/components/PageBox.tsx
export default function PageBox({ title, children }: PageBoxProps) {
  return (
    <Card withBorder padding="md">
      <Title order={3}>{title}</Title>
      {children}
    </Card>
  );
}
// src/pages/boxed.tsx
<PageBox title="Welcome"><Text>{t("page.Boxed.text.sx4lga", "hi")}</Text></PageBox>
<!-- src/components/PageBox.vue -->
<v-card variant="outlined" class="pa-4">
  <h3>{{ title }}</h3>
  <slot />
</v-card>
<!-- src/lib/components/PageBox.svelte -->
let { title, children }: { title: string; children?: Snippet } = $props();
…
<h3 class="text-xl font-semibold">{title}</h3>
{@render children?.()}
// src/app/components/PageBox.ts
template: `<mat-card><mat-card-content><h3>{{ title }}</h3><ng-content></ng-content></mat-card-content></mat-card>`

Feliz emits let PageBox (props: {| title: string; children: ReactElement |}) = … in the nested Components module, Flutter a PageBox extends StatelessWidget with a Widget? child, Phoenix a page_box(assigns) function component with slot :inner_block.

Per-target honest gates

Where a target cannot render a primitive the compiler says so — a loom.* error, never a blank spot. The loom.target-unsupported#<reason> rows are read off the per-target capability table (src/util/target-capabilities.ts, each platform's capabilities), which gives every target a verdict per construct — supported, or refused with a reason:

Gate Fires when
loom.target-unsupported#datagrid DataGrid on phoenixLiveView or flutter
loom.table-filter-unsupported Table { filter: } on phoenixLiveView
loom.table-filter-server-paged filter: on a serverPaged: true table (any target)
loom.modal-controlled-op-form-unsupported Modal { open: …, OperationForm { … } } on react / vue / svelte / flutter
loom.target-unsupported#chart Chart on a frontend with no chart renderer — none of the seven today
loom.target-unsupported#entity-history-read QueryView { of: <Agg>.history(id) } on a frontend whose read layer does not collect the history read — none of the seven today
loom.flutter-primitive-unsupported a primitive the Flutter pack has no renderer for — none in the current registry (the message still names FileUpload, which now renders)
loom.user-component-deferred-target a component shape the Angular / Feliz emitter filters out — slot / action params, a Feliz body with Action { … }, a byId or store read, an Angular read fed by an @Input() (15)
loom.feliz-async-effect-unsupported · loom.flutter-async-effect-unsupported match await in a component action (actions.md)
loom.ui-read-unresolved a QueryView / Chart of: naming an operation the aggregate's repository does not declare (any target)
loom.ui-projection-read-unsupported a keyed or folded projection read from a page, or a frontend without a projection client
loom.toast-message-unsupported a realtime toast(…) message outside the literal / e.field / binary-op subset

The HEEx parity list (test/generator/elixir/heex-parity.test.ts) freezes the set of TSX-rendered primitives without a heex renderer: today that is DataGrid only — every other primitive has a HEEx renderer, so a new TSX-only primitive fails CI until it gets one or is pinned with a reason.

Where to go next