Authoring tessera components (Elixir side)

Copy Markdown View Source

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"
end
  • tag: — 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 .mjs file 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 stable data-cy test hook rendered on the host element.

The DSL

MacroPurposeWire shape
attr :name, :typeDOM attribute (snake_case atom)rendered kebab-cased (display-name=), value camelCase prop displayName
prop :name, :type, json: trueJSON payload shipped through tsr-propsDOM property, camelCased key (tagList)
event "name-detail"CustomEvent the element emitsbinding 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 valuerendered verbatim
styles ~CSS"…" / styles […]author CSS for the generated Lit elementstatic 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

  1. __tessera_spec__/0 and __tessera_manifest__/0 on the component module (compile-time validated spec + JSON-encodable manifest map).
  2. The JS contract module <snake_tag>.contract.generated.mjs next to the .mjs — byte-deterministic output of CONTRACT.md §2. It is gitignored; regenerate by compiling. It is also an @external_resource of 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).
  3. In Hologram apps (hologram dep present): the server render template (kebab attrs, tsr-props JSON for json props, data-tsr-on for bound events, data-cy), generated init/2 + init/3 that trigger the client registration call, and the absolute-path js_import binding to the .mjs (register export). Hologram treats init/2 (client mount) and init/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/3 gets warning: the following clause is redundant: def init(props, component, server) from the type checker. This is inherent to Hologram's documented override-init-after-use pattern (use Hologram.Component itself defines a catch-all init/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 cid passed) never runs init/3 server-side — stateful initialization only happens for cid-bearing instances (and the client mount path calls init/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.

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).