How to declare a web component with the Noizu.Tessera.Component DSL so that
mix compile generates the wire contract, the colocated JS skeleton wiring, and
(in Hologram apps) the server render + bridge glue. The normative wire contract
is docs/CONTRACT.md; this guide is the developer workflow.
Minimal component
defmodule MyApp.Components.PersonaCard do
use Noizu.Tessera.Component,
tag: "persona-card",
js: "persona_card.mjs"
attr :display_name, :string, required: true
attr :disabled, :boolean, default: false
prop :tag_list, :list, json: true, default: []
prop :filters, :map, json: true, default: %{}
event "persona-select", detail: %{persona_id: :string, click_count: :integer}
event "persona-hover", detail: %{hovered: :boolean}
slot "header"
cy "persona-card-root"
endtag:— kebab-case custom-element name matching^[a-z][a-z0-9]*(-[a-z0-9]+)+$(enforced at compile time; must contain at least one hyphen per the platform custom-element requirement).js:— the colocated.mjsfile next to this module's source file. It is treated as an@external_resource; the generated JS contract module is written next to it at compile time (only when its content changed).cy:— optional stabledata-cytest hook rendered on the host element.
The DSL
| Macro | Purpose | Wire shape |
|---|---|---|
attr :name, :type | DOM attribute (snake_case atom) | rendered kebab-cased (display-name=), value camelCase prop displayName |
prop :name, :type, json: true | JSON payload shipped through tsr-props | DOM property, camelCased key (tagList) |
event "name-detail" | CustomEvent the element emits | binding prop on_persona_select, default action persona_select, data-tsr-on="persona-select:action@target" |
slot "name" | declared slot (documented in manifest) | passthrough <slot /> |
css "--name", default: "…" | CSS custom property the element reads (CEM + gallery metadata) | var(--name, …) in your styles; not on the wire contract |
part "name" | styling hook exposed as ::part(name) (CEM + gallery metadata) | part="name" in your template; not on the wire contract |
cy "name" | data-cy value | rendered verbatim |
styles ~CSS"…" / styles […] | author CSS for the generated Lit element | static styles = [baseStyles, …author entries] |
All DSL options must be compile-time literals (variables and module attributes
raise a CompileError pointing at the call site).
Naming — the one rule that matters
Contract keys must survive the snake↔camel round-trip byte-for-byte: declare
snake_case atoms whose camelCase form (split on _, capitalize every segment
after the first) converts back to the identical snake_case name. persona_id
round-trips; col_2_width does not (col2Width collapses back to col2_width)
and is rejected at compile time with a rename suggestion. Non-snake names
(Display_Name, foo__bar, _hidden) are rejected outright. Do not reach for
Macro.underscore/camelize around contract keys — tessera's byte-exact rules
in CONTRACT.md §1 differ from them (userID → user_i_d, col2_width stays
col2_width).
Types
:string · :integer · :float · :boolean · :list · :map · :any.
Event detail: maps use the same type set as values.
Defaults
Declared default: values are JSON literals and appear in the generated
contract + manifest. At render time a prop/attr the caller did not pass falls
back to its declared default — so a template-referenced name must either be
passed by every caller or declare a default:, otherwise the template's
@var lookup raises KeyError at render (Hologram only injects defaults that
are declared). This bites json props referenced via tsr-props in particular:
give every json prop a default: ([], %{}) unless you guarantee callers
always pass it.
Styles
styles declares author CSS for the generated Lit element (lit-parity §2.5;
theming contract in EXTENSION-CONTRACT.md §7). Entries are compile-time
literals: a ~CSS sigil block, a plain string, or a {:shared, "path.mjs"}
tuple naming a Lit shared-styles module (export const styles = css…). A single entry or a static list composes; repeated declarations append in order: ```elixir styles [ {:shared, "./shared_styles.mjs"}, ~CSS""" :host { padding: var(--tsr-my-card-padding, 8px); } :host([tone="dark"]) { background: #111; } ::slotted(p) { margin: 0; } """, ".row { display: flex; }" ] ``` The generated element composes `static styles = [baseStyles, …author entries]` — `baseStyles` carries tessera's fixed `:host` defaults (`display: block`, system font stack) and always comes first; author entries follow in declaration order, so later entries win the cascade. `:host`, `:host([attr])`, `:host(.class)` and `::slotted()` pass through verbatim; Lit validates the CSS at runtime (tessera does not parse it). Backticks, `${` and backslashes in author CSS are escaped for the `css literal.
Theming contract (EXTENSION-CONTRACT.md §7). Every custom property the
element reads must be --tsr-<tag>-<prop> (tag verbatim, prop lower-kebab)
and MUST carry a var() fallback — a tag-scoped read without one raises a
CompileError naming the property. Reads are declared in the CEM
(cssProperties, with the fallback default); un-prefixed globals and
other tags' properties pass through but are not this element's declared
surface. The host exposes a root part; part="…" attributes in the template
are declared in the CEM (cssParts) and reachable via ::part() from the outer
tree. Properties read only inside a {:shared, …} module are JS-side and not
scanned — declare them in the component's own styles block too.
styles without a template is legal: the entries flow to the manifest and
CEM (spec-only / hand-written-JS components declare their theming surface the
same way); only template-bearing components get a generated element.
What mix compile generates
__tessera_spec__/0and__tessera_manifest__/0on the component module (compile-time validated spec + JSON-encodable manifest map).- The JS contract module
<snake_tag>.contract.generated.mjsnext to the.mjs— byte-deterministic output of CONTRACT.md §2. It is gitignored; regenerate by compiling. It is also an@external_resourceof the module, so editing it by hand is futile (and next compile overwrites edits only when the spec actually changed — content-compared, no rebuild loops). - In Hologram apps (
hologramdep present): the server render template (kebab attrs,tsr-propsJSON for json props,data-tsr-onfor bound events,data-cy), generatedinit/2+init/3that trigger the client registration call, and the absolute-pathjs_importbinding to the.mjs(registerexport). Hologram treatsinit/2(client mount) andinit/3(server) as separate callbacks — the compiler never derives one from the other, which is why tessera generates both.
Override __tessera_init__/2 (client) and/or __tessera_init__/3 (server)
— never init/2 / init/3 — to seed state. The generated inits run
call(:register, []) before delegating to the __tessera_init__ hooks, so
overriding the hooks keeps registration alive; a user-defined init written
before the generated one would shadow it and kill registration.
The top-level register() call at the end of your .mjs is load-bearing.
Stateless SSR (no cid passed) never runs init/3; the element gets defined
only when the page bundle evaluates the .mjs and reaches that statement.
Removing it means components render server-side but never upgrade on the
client. mix compile emits a warning when a component's .mjs is missing the
call. See docs/CONTRACT.md §3/§5 for the render + bridge mechanics.
Test goldens (contributors)
The ExUnit suite pins the generators' byte output against checked-in goldens:
the PersonaCard fixture's contract module (test/fixtures/persona_card.contract.golden.mjs
— the wire interface, CONTRACT.md §2) and CEM (persona_card.cem.json), plus
the generated Lit element for the hand-built spike-widget spec
(spike_widget.element.golden.mjs, shared with mix tessera.golden via
test/support/spike_widget_spec.ex). They are never bootstrapped by the
tests: a missing or stale golden FAILS the suite. After an INTENTIONAL
generator change, regenerate all three explicitly and review the diff against
CONTRACT.md §2 before committing:
MIX_ENV=test mix tessera.golden # rewrites all three goldens
MIX_ENV=test mix tessera.golden --check # verify-only
Known compiler behaviours (not bugs)
- On Elixir 1.20+, every component that overrides
init/3getswarning: the following clause is redundant: def init(props, component, server)from the type checker. This is inherent to Hologram's documented override-init-after-usepattern (use Hologram.Componentitself defines a catch-allinit/3), reproduces with a plain Hologram component, and is expected in tessera apps. It does not affect the library's own build. - Stateless rendering (no
cidpassed) never runsinit/3server-side — stateful initialization only happens forcid-bearing instances (and the client mount path callsinit/2, which is why both are generated).
Scaffolding
mix tessera.gen.component persona-card --module MyApp.Components.PersonaCard
writes lib/my_app/components/persona_card.ex (spec skeleton),
persona_card.mjs (element template — replace the body, keep the register
export), and test/components/persona_card_test.mjs (define-once smoke test).
The scaffolded .mjs imports only exports that exist in @noizu/tessera-core
(TesseraElement, defineOnce); use emit and behaviors from the same
package inside your element for events and shared behavior.
Packages
use Noizu.Tessera.Package, components: [MyApp.Components.PersonaCard]generates components/0 (component modules), manifest/0 (aggregated
manifest maps), cem/0 (the package's Custom Elements Manifest) and the
__tessera_package__/0 discovery marker on the host module — feed
mix tessera.manifest with it to emit tessera-manifest.json +
custom-elements.json for tooling, or mix tessera.cem to write just the
package's custom-elements.json.
Gallery
Every package includes the built-in interactive component gallery by default — no opt-in flag. From the package's Mix project:
mix tessera.gen.gallery # scaffolds gallery/ from the package CEM
npm install # package root: lit + @noizu/tessera-core
# (esbuild resolves the components' bare
# imports from the component files' dir)
cd gallery && npm install && npm run build && npm run serve
serves a framework-agnostic gallery on http://localhost:5588 (PORT
overridable). It reads gallery/custom-elements.json and, per component,
renders a live demo plus typed attribute/property editors, slot-content
editors, CSS custom-property editors (live theming) and an event log
(event name + camelCase detail). The runtime lives in tessera
priv/gallery/gallery.mjs; the generated gallery/src/main.mjs imports the
package's component .mjs modules (their load-bearing register() defines the
elements) and that runtime, and esbuild bundles them. Re-run
mix tessera.gen.gallery after changing the package's components.
examples/persona_kit/gallery is a checked-in, e2e-tested instance
(demo/e2e/src/e2e/gallery.cy.ts).