# Authoring tessera components (Elixir side)

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

```elixir
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

| 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

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:

```sh
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

```sh
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

```elixir
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:

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