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.

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

Elixir spec

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: []

event "persona-select", detail: %{persona_id: :string, click_count: :integer}

slot "default"
cy "persona-card-root"
end

contract.generated.mjs

export default {
version: 1,
tag: "persona-card",
attrs:  [{ name: "display-name", key: "displayName", type: "string", required: true, default: null }],
props:  [{ name: "tagList", type: "list", json: true, default: [] }],
events: [{ name: "persona-select", detail: {personaId: "string", clickCount: "integer"} }],
slots:  ["default"],
cy: "persona-card-root",
};

Rendered DOM

<persona-card
display-name="Ada"
tsr-props='{"tagList":["a","b"]}'
data-tsr-on="persona-select:chose@page;persona-hover:hover@picker1"
data-cy="persona-card-root"></persona-card>

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.

Suites (tessera develop, 2026-11-12): ExUnit 454 passed / 0 failed; Tier-1 conformance 55 passed / 0 failed / 26 pending; Tier-2 conformance 31 passing / 0 failing / 49 pending of 80. What works today.

What's in 0.2.0

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
  • Lifecycle hooks: should_update, will_update, first_updated, updated
  • Templates: ?attr booleans; class_map, style_map, if_defined, nothing
  • Template regions with slot_template overrides; form_associated: true for form-participating elements
  • Tier 2: a full Hologram component inside a custom element, with state, actions, lifecycle, slots and forms

Extend

  • use Vendor.Element derivation on both tiers
  • Same-tag takeover as a layered extension: tag: :inherit, priority:, deterministic chain, base conformance suite as the gate
  • Every action is open: el.dispatchAction and el.onAction (onion order)
  • el.state: camelCase snapshot with snake_case read aliases, plus tsr-state-change
  • extendElement for pre-define JS mixins; behaviors.add with an api pin

Tooling

  • Interactive component gallery: mix tessera.gen.gallery, driven by the manifest from mix tessera.cem
  • Lit converter: mix tessera.from_lit reads a Lit element and writes a spec stub (a stub generator, not a transpiler)
  • mix tessera.install, tessera.gen.component, tessera.manifest, tessera.golden

Quality

  • One conformance suite, run on both tiers: the same shared case bodies, not two suites that drifted
  • ExUnit 454 / 0 failed; Tier-1 conformance 55 / 0 / 26 pending; Tier-2 conformance 31 / 0 / 49 pending of 80
  • Pending is counted, never faked green

Honest limits

  • Pre-1.0: the API may still change
  • 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)
defmodule MyApp.Components.ClickCounter do
  use Noizu.Tessera.Component,
    tag: "click-counter",
    js: "click_counter.mjs"

  attr(:step, :integer, default: 1)
  state(:count, :integer, default: 0)
  event("count-changed", detail: %{count: :integer})
  cy("click-counter-root")

  styles(~CSS"""
  :host {
    display: inline-block;
    padding: var(--tsr-click-counter-padding, 8px);
  }
  .count { color: var(--tsr-click-counter-accent, #6cf); }
  """)

  template(~TSR"""
  <div data-cy="click-counter-root">
    <button type="button" $click={:bump}>+</button>
    <span class="count" part="count">{@count}</span>
  </div>
  """)

  action(:bump, fn state, _params ->
    emit("count-changed", %{count: state.count + @step})
    %{state | count: state.count + @step}
  end)
end
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)
const el = document.querySelector("click-counter");

// First-registered interceptor is outermost.
// Return without calling next() to veto.
const off = el.onAction("bump", (params, next) => {
  analytics.tick("bump", params);   // params: camelCase
  return next({ ...params, step: 2 });
});

// Either casing; unknown names return { ok: false }
el.dispatchAction("bump", { step: 5 });

// Fresh frozen camelCase snapshot (snake_case reads alias)
console.log(el.state.count);

el.addEventListener("tsr-state-change", (e) => {
  console.log(e.detail.action, e.detail.keys);
});

off();   // unsubscribe
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)
<PersonaCard
cid="p1"
display_name="Grace Hopper"
on_persona_select={{:chose, target: "p1"}}
/>

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.

status: Lit-parity declared 2026-11-12 · conformance 55 passed / 0 failed / 26 pending

Tier 2 · Hologram in an element

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/051/0/23/6/014/10/46/10/0
P1 (83)1/3/39/40/015/2/30/36/010/3/41/26/0
P2 (64)0/0/17/47/04/1/31/28/06/0/27/28/1
Total (227)1/16/118/92/070/3/84/70/030/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.

One tag, any host

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
<PersonaCard display_name="Ada" on_persona_select={:chose} />

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.

Scaffold a component with tests (AUTHORING.md)
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 register),
#       test/components/persona_card_test.mjs
Declare the package inventory
use Noizu.Tessera.Package,
components: [MyApp.Components.PersonaCard]
Aggregate the manifest across deps
mix tessera.manifest
# writes tessera-manifest.json + custom-elements.json (CEM 1.0.0)

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.

What works today, what doesn't

Mainline v0.2.0 — real today

  • Spec DSL (attr, prop json:, event, slot, cy) with compile-time validation
  • Byte-deterministic contract module and CEM codegen; goldens checked in (mix tessera.golden)
  • Hologram 0.10.1 adapter: kebab attrs, tsr-props, data-tsr-on, page/layout/cid targets
  • JS core with no runtime deps; Lit or vanilla HTMLElement base
  • mix tessera.install, mix tessera.gen.component, mix tessera.manifest
  • Example package (examples/persona_kit), Hologram demo, plain HTML demo
  • Extension mechanisms: behaviors.add, defineOnce, emit, the bridge, __tessera_init__/2,3, and the 0.2.0 contract surface below
  • Interactive component gallery (mix tessera.gen.gallery, mix tessera.cem) and the Lit converter (mix tessera.from_lit)
  • ExUnit 454 / 0 failed; fast lane 164 / 0 / 13; persona_kit 5 / 0 (tessera develop, 2026-11-12)
  • MIT at the repo root, public repo; weekly canary compiles against noizu-forks/hologram@dev

Tier 1 — Lit parity declared

  • Elixir subset compiled to a real LitElement; state, template ~TSR, action in one module
  • Styles DSL, property options, lifecycle hooks, ?attr, class_map / style_map / if_defined / nothing, regions, form_associated
  • ESM/UMD/import-mapped bundles, defineOnce collision-safe registration, class export
  • Tested the Lit way: Tier-1 conformance (Web Test Runner, @open-wc/testing) 55 passed / 0 failed / 26 pending
  • P0: 51 native, 23 behind an escape hatch, 6 accepted gaps (Two engines)

Tier 2 — working lane

  • Wrapper runs the Hologram interpreter inside a plain TesseraHoloElement extends HTMLElement with an open shadow root — no LitElement anywhere
  • Server round-trips, actions, live state, slots, forms, native isServer split
  • Rides the same use Vendor.Element macro as Tier 1; takeover chains are linear
  • Conformance (Cypress): 31 passing / 0 failing / 49 pending of 80; full Tier-2 Lit parity is not claimed. SSR is proven in a spike, not shipped

Extension contract — v0.1, implemented

  • Derivation and takeover chains, el.state, onAction/dispatchAction, extendElement, theming (::part + --tsr-* custom properties), form participation: shipped in 0.2.0
  • X-01…X-21 are shared, tier-agnostic cases; Tier 1 runs the full family, Tier 2 runs the ones its runtime can host and counts the rest pending
  • extension_api 1.0.0 is the first stable value; wire contract is v1

Roadmap

  • Tier 2: the 49 pending conformance scenarios; shipping SSR and Declarative Shadow DOM (proven in a spike)
  • The owner ballot on the accepted-gap categories (docs/notes/gap-acceptance.md)
  • Hex: noizu_tessera 0.2.0 is published — hex.pm · HexDocs
  • Public npm and CDN, the upstream Hologram custom-event PR, the element: host swap (Phase 2)
ExUnit 454 0 failed
Tier-1 conformance 55 passed · 0 failed · 26 pending
Tier-2 conformance 31/80 passing · 0 failing · 49 pending
persona_kit 5 passed · 0 failed
Runtime dependencies None in the JS core; lit is an optional peer base, vanilla HTMLElement works too Generated per element Wire contract, Custom Elements Manifest, the element and the Hologram bridge Hosts Hologram natively; plain HTML e2e-tested; React by the custom-element rules License MIT, 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.

Fair questions, straight answers

Which tier do I pick?

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.