noizu_tessera v0.2.0 · open source (MIT) · two engines, one contract
Web components that land in your Hologram actions
Author the element once in Elixir. mix compile generates the wire
contract, the element and the Hologram bridge, so shadow-DOM events arrive in your page, layout
or cid-targeted actions with snake_cased params. Two engines run under
that one spec: Tier 1 compiles an Elixir subset into a real LitElement for widgets that travel;
Tier 2 runs the full Hologram runtime inside a universal custom element for ones that talk to
your server. There is no JS to write on the consuming side. There is JS, of course;
mix tessera.install puts it where Hologram can find it. Since 0.2.0 you
can also extend someone else's element without forking it:
use Vendor.Element on either engine, plus a JS-side
onAction hook for everyone who would rather not write Elixir.
What's in 0.2.0.
persona-card — a site-local tessera component (TesseraSite.Components.PersonaCard),
same wire contract as examples/persona_kit.
Watch one event cross the seam
Dispatch target
Click the card. Each row appears as the bridge takes that step.
on_persona_select={:chose}
Click the card. Nothing is pre-recorded; the rows render from the live event.
This page is itself a Hologram app, so the dispatch is real: the rows above render from the
params the action actually received, keys already snake_cased.
The card above is the real persona-card element, defined by a site-local
tessera component spec. Click it; the inspector shows every hop the event takes.
Elixir
the seam
DOM
Every host rewrites the same shim
A custom element does the right thing: it emits a composed CustomEvent that
bubbles out of its shadow root. A server-driven UI framework has no template syntax to bind it. So
the widget works, the framework works, and the seam between them is yours to write by hand.
Today that means a JS shim like this one, living somewhere in your assets:
hand-written, per app, per event (representative)
document.addEventListener("persona-select", (event) => {
Hologram.dispatchAction("chose", "page", {
persona_id: event.detail.personaId, // camelCase to snake_case, by hand
click_count: event.detail.clickCount,
});
});
Every key translated by hand. Every host re-derives it. And it sits next to the other
afternoon-cost surprises: shadow-DOM event retargeting, kebab versus camel versus snake,
js_import paths that resolve against the caller's file. That last one is a
fact everyone learns exactly once.
tessera replaces the shim with one generated attribute:
generated by the adapter, read by the bridge
data-tsr-on="persona-select:chose@page"
The bridge that reads it ships with the library. The alternatives today are re-implementing the
widget natively or skipping it. There is a fourth option: make the seam a versioned contract that
mix compile writes for you. That is tessera.
The spec is the source of truth
One module declares the component. Five macros describe the wire surface:
attr,
prop (with json:), event,
slot, cy. Everything else on this page is generated
from it. (Tier 1 components also declare their behavior in the same module with
state, template and
action; see the code.)
Hover any name. Its kebab attribute, camelCase key and spec atom light up together; they are one
declaration in three places.
Precomputed from the naming rules in CONTRACT.md §1; the real check runs at mix compile.
Names are checked at compile time, not discovered at runtime. A key must survive the snake_case to
camelCase round trip byte-for-byte. col_2_width collapses to
col2Width and back to col2_width, so the compiler
rejects it and suggests the rename. The generated module is gitignored and content-compared: editing
it by hand is futile, and recompiles only rewrite it when the spec actually changed. The compiler's
own goldens are checked in; regenerating them is an explicit
mix tessera.golden (--check in CI), so codegen
drift fails a build instead of surprising a user.
The same compile step emits a Custom Elements Manifest (custom-elements.json,
schema 1.0.0) for tooling. One spec, three artifacts, no drift between them.
What the compile step buys you
Spec once, compile-checked
One spec module declares the component; mix compile validates names against
the wire contract's round-trip rules and writes the contract module and manifest. Bad keys fail
with a rename suggestion, not at 2am in a browser.
Zero glue in the host
mix tessera.install, drop the tag in ~HOLO,
bind on_<event>={:action}. Events reach page, layout or
cid-targeted actions with snake_cased detail keys. Proof: the event
inspector above.
Two engines, one contract
Tier 1 compiles an Elixir subset to a real LitElement: lit-html rendering, ESM/UMD bundles,
defineOnce registration. Tier 2 hosts the Hologram runtime inside a
universal custom element: server round-trips, actions, live state. Neither fakes the other;
both expose the same extension surface. The decision guide:
Two engines.
The Lit-parity milestone, grouped by what you are trying to do. Every item below has a
conformance case or an ExUnit test behind it; the limits are listed after.
Author
Tier 1: state / template ~TSR / action in one Elixir module, compiled to a real LitElement
Styles DSL: styles, ~CSS, shared style modules, --tsr-<tag>-* theming, parts
Property options: attribute rename or disable, reflect: true, lists as arrays
Full Tier-2 Lit parity is not claimed; Tier 2 is a Hologram-runtime engine, not a Lit re-implementation
SSR and Declarative Shadow DOM are proven in a spike, not shipped
40 Lit features are accepted gaps (Lit context among them; Signals is a design choice, built-in reactivity instead), each with a documented alternative in docs/notes/gap-acceptance.md
The 0.2.0 surface, in code
Four short examples. The Elixir ones mirror the conformance fixtures in the tessera repo, so the
syntax is the syntax the compiler is tested against.
Tier 1: state, action, template and styles in one module (mirrors the conf_state and conf_styles fixtures)
Extend a vendor element: same tag, layered override, base_action down-call (mirrors takeover_ext/conf_takeover_ext_aaa)
# Keep this file out of the base's directory:
# :inherit beside the base is a compile error.
defmodule MyApp.CounterExt do
use VendorKit.ClickCounter, tag: :inherit, priority: 10
event("count-doubled", detail: %{layer: :string})
action(:bump, fn state, params ->
emit("count-doubled", %{layer: "mine"})
# next layer down, ultimately the vendor's own action
base_action(:bump, state, params)
end)
end
From JavaScript: intercept, dispatch, read state (EXTENSION-CONTRACT.md §6.2–6.4)
Convert an existing Lit element to a spec stub (docs/notes/from-lit.md)
mix tessera.from_lit path/to/my-el.ts
mix tessera.from_lit src/my-element.ts --out packages/my_element
mix compile # writes the wire contract
# render() and methods are not converted; port by hand
Takeover is extension, not replacement: the base tag's conformance suite has to keep passing on the
taken-over element. Details in Extend without forking the package.
From shadow DOM to action clause
The bridge is a document-level capture listener installed by the generated adapter. When an event
fires it resolves the host element, parses its data-tsr-on, and dispatches.
You write the action; nothing else.
A complete page integration (README.md, demo/hologram_app)
defmodule MyPage do
use Hologram.Page
alias PersonaKit.PersonaCard
def template do
~HOLO"""
<PersonaCard display_name="Ada" on_persona_select={:chose} />
"""
end
def action(:chose, params, page), do: put_state(page, params)
end
Target a component instance instead (demo panel b)
Detail keys arrive snake_cased, converted recursively by the bridge before dispatch. If no binding
matches, or no Hologram is on the page, the bridge does nothing and the event stays an ordinary DOM
CustomEvent.
Two engines, one contract
One Elixir definition compiles into either engine, and both expose the same extension surface,
so an extender never needs to know which one ran. You pick per component, not per project.
Tier 1 · compile to Lit
An Elixir subset compiles directly to a real LitElement. The output extends
LitElement, renders with lit-html, registers with
defineOnce, and ships as ESM/UMD/import-map bundles. Lit-grade
interop and distribution; authorship stays in Elixir.
The full Hologram runtime runs inside a universal custom element — a plain
HTMLElement wrapper with an open shadow root any DOM host can mount.
Stateful: server round-trips, actions, live state, slots, forms, a native server/client split.
There is no LitElement in it, and that is the point.
status: extension contract landed · conformance 31 passing / 0 failing / 49 pending of 80 · SSR proven in a spike, not shipped
Reach for Tier 1 when
the widget must travel: it drops into any DOM host — plain HTML, React, server-rendered pages;
you ship via npm / UMD / import-map / CDN and want Lit-ecosystem tooling over the rendered element;
the interactivity is client-only and authored entirely in Elixir.
Reach for Tier 2 when
the component must talk to your server: round-trips, live state, actions with backend authority;
you want isomorphic Elixir — the same component logic on server and client;
you need stateful Hologram behavior inside one custom element.
The rule that keeps this honest: neither tier approximates the other's core. Tier 1 cannot model
the Hologram runtime; Tier 2 cannot model Lit's update lifecycle, directives or styles DSL. Both
compile from the same definition into the same extension contract.
Lit feature inventory — 227 features audited against both tiers (docs/notes/lit-parity.md §3,
recounted 2026-11-12 at the parity declaration). Counts are native / partial / escape-hatch / missing / open per priority band;
escape hatch means real hand-written Lit underneath the generated surface.
Priority
Phase 1 today
Tier 1 (Elixir→Lit)
Tier 2 (Hologram runtime)
P0 (80)
0/13/62/5/0
51/0/23/6/0
14/10/46/10/0
P1 (83)
1/3/39/40/0
15/2/30/36/0
10/3/41/26/0
P2 (64)
0/0/17/47/0
4/1/31/28/0
6/0/27/28/1
Total (227)
1/16/118/92/0
70/3/84/70/0
30/13/114/64/1
51 of Lit's 80 P0 features compile natively on Tier 1 in 0.2.0, 23 more are reachable through a
sanctioned escape hatch, and the last 6 are accepted gaps with a reason on record: the Lit
context protocol (use the bus seam), decorators (the output is decorator-free by design) and
.d.ts generation. The update-time seam and the directive and
author-CSS pipelines that used to be on the worklist have landed
(will_update, updated,
shouldUpdate, classMap,
styleMap, the styles DSL). Across all 227 features, 40 are rejected by
both tiers and accepted on purpose (34 with a hatch, 6 as design choices), including Lit
context; Signals is a deliberate design choice (built-in reactivity instead). Each is documented
as unsupported, not quietly missing. Full Tier-2 Lit
parity is not claimed.
To the browser, a tessera component is a standard custom element: a tag with a shadow root, attributes
for scalars, a tsr-props JSON attribute for structured data, and
CustomEvents dispatched with bubbles: true and
composed: true so they cross the shadow boundary. The core package has no
runtime dependencies; the Elixir side is the authoring layer and the Hologram bridge, and neither is
required by the element at runtime.
the element above carries no binding — it is just the tag
The integration target: server render plus bridge, both generated
Server-rendered tag plus the generated bridge; the binding macro is the whole integration.
The element never re-mounts. Same tag, same DOM; only the code around it changes.
The React path works the way any custom element does in React: render the tag, listen with
addEventListener, no wrapper components. We label it an example because it
is one; it is a documented snippet, not an executed test. The plain HTML host above is the
e2e-tested one.
For tooling and non-Elixir consumers, mix tessera.manifest writes a
standard Custom Elements Manifest (custom-elements.json, schema 1.0.0) from
the same specs. If your source of truth is TypeScript, tessera is not your authoring layer; the wire
contract is a plain, documented format you can target, and the manifest tells you what ships.
Extend without forking the package
The extension contract (EXTENSION-CONTRACT.md, v0.1) is the kit-vendor surface: a vendor ships a
base element and consuming apps extend it without forking it. The contract's own thesis: the
runtime is an implementation detail; the extension surface is the product. 0.2.0 implements
it on both engines. First, the JS-side mechanisms that need no Elixir at all:
Behaviors run on every connect, clean up on disconnect (EXTENDING.md §1)
import { behaviors } from "@noizu/tessera-core/behaviors";
behaviors.add("persona-card", (el) => {
const onClick = () => el.classList.add("is-selected");
el.addEventListener("click", onClick);
el.dataset.enriched = "true";
return () => {
// runs on disconnect (Hologram re-renders may mount/unmount the host)
el.removeEventListener("click", onClick);
};
});
Need the class itself? Subclass and register first (EXTENDING.md §2)
import { LitElement, html } from "lit";
import { TesseraElement } from "@noizu/tessera-core/element";
import { defineOnce } from "@noizu/tessera-core/define";
import contract from "./persona-card.contract.generated.mjs";
class MyPersonaCard extends TesseraElement(LitElement, contract) {
render() { /* your template */ }
}
// FIRST definition of the tag wins — register before the library's own
// module evaluates, or your class will be skipped (with one console.warn).
defineOnce("persona-card", MyPersonaCard);
Compose children inside your own shadow root and re-emit (EXTENDING.md §4)
import { emit } from "@noizu/tessera-core/events";
import { behaviors } from "@noizu/tessera-core/behaviors";
// persona-composer wraps <persona-card> in its own shadow root.
behaviors.add("persona-composer", (el) => {
const card = el.shadowRoot?.querySelector("persona-card");
if (!card) return;
const onPick = (e) => {
// Re-emit FROM THE OUTER ELEMENT so the composer's own
// data-tsr-on binding fires.
emit(el, "persona-select", { personaId: e.detail?.personaId ?? null });
};
card.addEventListener("persona-select", onPick);
return () => card.removeEventListener("persona-select", onPick);
});
This is not a footnote; it is the documented rule. When element B sits inside element A's shadow root
and both bind the same event name, A's action fires and B's does not. Re-emitting from the outer
element is the deliberate pattern, and persona-picker ships doing exactly
that.
The contract surface — shipped in 0.2.0 (contract v0.1)
These APIs are implemented on both engines in 0.2.0, and the semantics are the spec's. The
package is pre-1.0, so treat the surface as stable-ish rather than frozen. The conformance suite
pins the behavior; where Tier 2 has cases still pending, the status board says so.
Tier 2 shape: same-tag takeover as layered extension; overrides: names what you override
defmodule VendorKit.FilterListExt do
use Acme.FilterList, tag: :inherit, priority: 10, overrides: [:increment]
# layers overrides over the vendor element — nothing is substituted
def action(:increment, params, component) do
component = put_state(component, last_vendor_touch: true)
base_action(:increment, params, component) # next layer down, ultimately the vendor base
end
end
Tier 1 shape: the same layering lowers to a real ES super call
action :increment, fn state, params ->
state = base_action(:increment, state, params)
%{state | last_vendor_touch: true}
end
Takeover is extension, never replacement. tag: :inherit registers under
the base's own tag: inherited behavior keeps working, overrides sit on top, and the base tag's
conformance suite must still pass — the gate that keeps "extend" from becoming "replace". Several
extenders of one tag compose in a deterministic chain (dependency depth, then
priority:, then module name; the vendor base sits innermost; conflicts
warn at compile time naming both modules). Never last-wins. Fresh-tag derivation stays the default.
One engine difference, stated up front: Tier-1 layers compose through real ES
super; Hologram's IR has no super, so Tier-2
chains are linear and list their overrides with overrides:.
Deriving across engines is a compile error, not a surprise.
Every action is open. el.dispatchAction(name, params) and
el.onAction(name, interceptor) exist on every declared action;
internal: true is advisory labeling, not access control. The trade-off
is stated, not hidden: any page script can trigger any action, so guard destructive or costly
ones inside the body, and name them like they are public, because they are — renaming or removing
any action is a MAJOR version bump. el.state is a read-only camelCase
snapshot with snake_case read aliases and a tsr-state-change event;
onAction/dispatchAction accept either casing,
and interceptors wrap the pipeline in onion order, first-registered outermost.
JS and any-framework extenders are first-class: behaviors.add,
subclassing and onAction work with no Elixir and no
recompilation. Same surface on both engines; an extender never tiers their overrides.
persona-picked → %{persona_id: none}
On the Elixir side, override __tessera_init__/2 (client) or
__tessera_init__/3 (server) to seed state. Never override
init/2 or init/3; the generated inits run the
registration call, and a hand-written init written before them would shadow
it and kill registration. (Source: docs/AUTHORING.md.)
Ship your widget kit as one hex package
tessera started the way yours probably did: the same component maintained twice, once as a JS element
and once as a server-side component, drifting. The spec module is the fix. One declaration becomes
the element, the contract, the manifest and the render glue.
The mix tasks are the whole workflow: tessera.install wires the JS side,
tessera.gen.component scaffolds,
tessera.manifest aggregates across your dep tree into one committed,
diffable pair of files. Generated output is deterministic, so contract changes review like any other
diff.
Your package also ships an extension surface: derivation with
use Vendor.Element, same-tag takeover behind the base conformance gate,
open actions protected by semver (renaming any action is a MAJOR bump), and two version axes:
the wire contract at v1, and extension_api (first stable value 1.0.0),
pinnable at derivation or on behaviors.add. Each package can scaffold a
built-in interactive gallery (mix tessera.gen.gallery, driven by the
manifest from mix tessera.cem): live demos, typed attribute, property and
slot editors, and an event log, so consumers try the kit before adding the dep.
Runtime dependenciesNone in the JS core; lit is an optional peer base, vanilla HTMLElement works tooGenerated per elementWire contract, Custom Elements Manifest, the element and the Hologram bridgeHostsHologram natively; plain HTML e2e-tested; React by the custom-element rulesLicenseMIT, open source — 0.2.0 Lit-parity milestone, pre-1.0, wire contract versioned (v1), extension contract v0.1 (implemented)
amber = pending scenarios, counted and never faked green. · last checked
2026-11-12 · sources: tessera develop CI toolchain (Elixir 1.18) + docs/notes/lit-parity.md (in the tessera repo)
Hex: noizu_tessera 0.2.0 is on hex.pm
(docs on HexDocs) — the dep line below is
the install path. 0.2.0 is the Lit-parity milestone: pre-1.0, so the API may still change.
docs/notes/lit-parity.md in the repo lists exactly what is covered and what is
accepted as a gap; full Tier-2 Lit parity is not claimed. Open source (MIT) because early readers make
better contracts.
Clone the demo and run it
mix.exs — noizu_tessera 0.2.0 on hex
{:noizu_tessera, "~> 0.2.0"}
Hologram host (README.md) — sem-code island, same listing
git clone https://github.com/the-robot-lives/tessera
cd tessera/demo/hologram_app
mix deps.get
mix tessera.install
mix holo
# → http://localhost:5586 (stateless, cid-targeted, picker, behaviors, Link nav)
Plain HTML host (README.md, demo/plain_html)
cd ../plain_html
npm install && npm run build && npm run serve
# → http://localhost:5587
noizu-cli-mockup — a tobor-kit island (cdn.derobot.is/tobor-kit/v1.7.0), the same
quickstart as a virtual terminal; the code blocks above stay the no-JS fallback.
Fresh clone: also run npm ci once in
examples/persona_kit; the plain demo's bundle imports the component
sources, and esbuild resolves lit from the importer's
node_modules.
In your own app: add the dep, mix tessera.install, author or alias a
component, drop the tag in ~HOLO with
on_<event>={:action}. Full walkthrough: docs/CONSUMING.md.
Pick Tier 1 when the component has to travel: the output is a real LitElement that drops into
any DOM host and ships as ESM/UMD bundles. Pick Tier 2 when it has to talk to your server: the
full Hologram runtime inside a universal custom element, with server round-trips, actions and
live state. Both compile from the same Elixir definition and expose the same extension
contract. The decision guide is Two engines.
Is this full Lit parity?
Tier 1 parity is declared for 0.2.0; full Tier-2 parity is not claimed. The counts are
public. 227 Lit features are inventoried in docs/notes/lit-parity.md.
Tier 1 covers 51 of the 80 P0 features natively and 23 more through a sanctioned escape hatch;
the other 6 are accepted gaps with a reason on record. 40 features are rejected by both tiers
on purpose (Lit context among them), each documented with an alternative.
Does it depend on Hologram internals?
Yes, one call. The bridge's only Hologram dependency is
globalThis.Hologram.dispatchAction, built and tested against 0.10.1.
The wire contract is versioned (v1), and a canary workflow compiles and tests the library
against noizu-forks/hologram@dev weekly (manual runs too, never on
PRs). If globalThis.Hologram is absent the bridge does nothing rather
than throw. An upstream PR for native custom-event binding is on the roadmap.
Can I install it from hex?
Yes. noizu_tessera 0.2.0 is on hex.pm
(docs on HexDocs). Add
{:noizu_tessera, "~> 0.2.0"} to your deps.
The JS core is not on public npm either. mix tessera.install links it
from the package's priv/js into your assets, so there is no public
npm install to run.
Is this production-ready?
No. Version 0.2.0 is the Lit-parity milestone, pre-1.0, so the API may still change. ExUnit is 454 passed / 0 failed;
Tier-1 conformance is 55 passed / 0 failed / 26 pending; Tier-2 conformance (Cypress) is 31 passing /
0 failing / 49 pending of 80. Full Tier-2 Lit parity is not claimed, and SSR is proven in a spike but not
shipped. docs/notes/lit-parity.md in the repo lists exactly what is covered and what is accepted as a gap. See
What works today.
Won't extenders break my element?
That is the contract's headline guarantee. A same-tag takeover
(use Base, tag: :inherit) is layered extension: inherited behavior
keeps working, the base tag's conformance suite must still pass, and several extenders of one
tag compose in a deterministic chain instead of last-wins. Chain conflicts warn at compile
time, naming both modules. Spec: EXTENSION-CONTRACT.md v0.1, implemented on both engines in 0.2.0.
Every action is open. Isn't that a security hole?
It is a stated trade-off, not an oversight. Any script on the page can dispatch any action;
internal: true is advisory labeling, not access control. Guard
destructive or costly actions inside the action body, treat params as untrusted on every
path, and name actions like they are public, because they are: renaming or removing any
action is a MAJOR version bump under the contract's semver rules.
My design system is TypeScript-first.
Then tessera is not your authoring layer; the spec is Elixir by design. What travels is the wire
contract: a plain, documented format (CONTRACT.md) plus a standard Custom Elements Manifest you
can target from any stack. The JS extension API — behaviors, subclassing, and
action interceptors — is usable without touching Elixir. If you also serve Elixir hosts, that
is the ready-made path in.
What about shadow DOM inside shadow DOM?
The outer host wins. When element B sits inside element A's shadow root and both bind the same
event name, A's action fires and B's does not; if A carries no binding, the bridge scans the
composed path for a registered element that maps the event. The deliberate pattern is to re-emit
child events from the outer element with emit(), which is what
persona-picker ships doing. EXTENDING.md §4 has the rule and the code.
What happens with no Hologram on the page?
Nothing, and that is the design. The event remains an ordinary composed, bubbling
CustomEvent that any addEventListener consumer
sees. That is exactly the plain HTML demo's situation, and its specs are part of the e2e suite.
Why noizu_tessera and not tessera?
Bare tessera is already taken on hex.pm and that package owns the root
Tessera module. The hex name is
noizu_tessera; the Elixir namespace is
Noizu.Tessera.*.
Can I convert an existing Lit element?
Partly. mix tessera.from_lit path/to/my-el.ts reads the element's
properties, events, slots and styles and writes a typed spec stub plus a test skeleton. It is a
stub generator, not a transpiler: render() and methods are not converted,
and every limitation is warned rather than guessed.
Does it work with React?
The same way any custom element does: render the tag, pass attributes, listen with
addEventListener; no wrapper components. The React snippet on this page
is a documented example, not an executed test. The plain HTML host is the e2e-tested one.
Good places to help
The repo is public and MIT. Small and concrete beats large and speculative; open an issue first.
The remaining Linux e2e legs: hologram_main (a/c/d) still fail on ubuntu runners while the
blocking smoke legs pass. Repro, hypotheses and the ruled-out list are in
docs/notes/e2e-linux-ci.md
(TODO(tessera-e2e-linux)).
Tier 2's 49 pending conformance scenarios, and shipping SSR with Declarative Shadow DOM (spike-proven, not shipped).
Try mix tessera.from_lit on a real Lit element and report what the extractor missed.
Extension-contract feedback: write a derivation against a vendor element and tell us where the contract got in your way.
Turn the React example into a real, executed test host.
The upstream Hologram PR for native custom-event binding; the bridge is the interim answer.
More example packages: a data table, a chart wrapper, whatever you would want to install.
Contract tooling: validators and linters over custom-elements.json and CONTRACT.md §1.
Install it from hex, or watch the repo for releases. We will not invent a waitlist.