# Runtime API

```js
import {
  mountDropFragment,
  createSeam,
  wireStage,
  applyBindings,
  ALLOWED_ATTRIBUTES,
} from "@econ-v1/node-design-seam";
```

Zero dependencies, ES modules, no globals. The package is typed through JSDoc and
`checkJs`; `Binding` is re-exported from the package root so a generated
`bindings.gen.js` resolves it under strict NodeNext.

`.d.ts` declarations are generated from that JSDoc (`npm run build`, wired into
`prepack`) and published alongside the source — a TypeScript stage imports the
package directly, with no `@ts-expect-error`. See [Types](#types) below.

---

## `mountDropFragment(options)`

Hosts a drop's markup and CSS, byte-identical, inside a host element.

```js
const { root, dispose } = mountDropFragment({
  host: ctx.host.root,
  html: DROP_HTML,
  css: [DROP_CSS],
  shadow: true,
});
```

| Option | Type | Default | |
| --- | --- | --- | --- |
| `host` | `Element` | — | **required**; throws if absent |
| `html` | `string` | — | the drop's own markup, verbatim |
| `css` | `readonly string[]` | `[]` | one `<style>` element per entry, inserted before the markup |
| `shadow` | `boolean` | `true` | attach (or reuse) an open shadow root |

**Returns** `{ root, dispose() }` — `root` is the shadow root when `shadow` is true,
otherwise `host` itself. Pass it to `createSeam`. `dispose()` empties it.

Existing children of the mount point are cleared first. The markup is wrapped in a
`<div data-design-seam-fragment="">`.

This function injects `html` with `innerHTML`, and that is not a hole in the
vocabulary: those bytes are the **design's own**, imported at build time and hashed
by `pin`. This module renders design-owned code; it never writes integration data.
Data enters only through the applier.

With `shadow: true` the drop's CSS cannot escape and the host's cannot reach in —
which also means the drop inherits **no** theme from the page. See
[theme is data](write-vocabulary.md#theme-is-data-not-environment).

Throws: `node-design-seam: mountDropFragment requires a host element`.

---

## `createSeam(options)`

Creates a seam instance scoped to one mounted root. Instance-scoped, not global — two
stages on one page each carry their own.

```js
const seam = createSeam({ root, bindings: BINDINGS, views: [cards] });
```

| Option | Type | Default | |
| --- | --- | --- | --- |
| `root` | `ParentNode` | — | the value from `mountDropFragment` |
| `bindings` | `readonly Binding[]` | `[]` | normally the generated table |
| `views` | `readonly ViewModule[]` | `[]` | the drop's view modules |

Throws if a view module is not `{ id: string, view: (d) => string, fixtures? }`:
`node-design-seam: a view module must export { id, view(d), fixtures }`.

### `seam.update(snapshot) → ApplyResult`

Applies every scalar binding from a snapshot. Idempotent — applying the same snapshot
twice writes the same values, so it is safe to call on every kernel tick.

```js
const { applied, unresolved, missingSources } = seam.update(snapshot);
```

If any anchor was unresolved, the seam starts a `MutationObserver` on the root and
re-applies when the DOM changes, until every anchor resolves or 50 passes elapse.
That covers a drop whose own script renders part of the markup after mount. The
observer is event-driven, never a timer, and detaches during each pass so the seam's
own writes don't retrigger it.

Returns a zeroed result after `dispose()`.

### `seam.feedView(id, data) → boolean`

Renders a registered view module into the element carrying `data-view-slot="<id>"`.

```js
seam.feedView("wallet.cards", { rows: [...] });
```

Returns `true` on success. Returns `false` and logs a `console.warn` naming the cause
when the id is unregistered, or when the fragment has no matching
`[data-view-slot]`. A missing view slot is not fatal at runtime.

This writes `host.innerHTML = module.view(data)`. The module is design-owned code and
its output is design-owned markup — the same trust boundary as the drop rendering its
own fixtures. **`data` must be plain data.** A `project` function that returns markup
would smuggle integration-authored HTML across the seam.

### `seam.renderFixtures()`

Feeds every registered view module its own `fixtures.default`, for a dev harness or a
standalone smoke check with no kernel attached. Modules without a `default` fixture
are skipped.

### `seam.dispose()`

Disconnects the observer and makes `update` and `feedView` no-ops. Call it from the
stage's `unmount()`.

---

## `wireStage(seam, ctx, wiring) → () => void`

Optional sugar: one `ctx.liveQuery` subscription per wiring entry, each result run
through that entry's `project` and pushed into the seam.

```js
const unwire = wireStage(seam, ctx, {
  snapshot: { query: "wallet.summary.v1", params: {}, project: toSummarySnapshot },
  views: {
    "wallet.cards": { query: "wallet.cards.v1", params: {}, project: toCardsModel },
  },
});
```

| Field | | |
| --- | --- | --- |
| `wiring.snapshot` | `{ query, params?, project? }` | drives `seam.update` |
| `wiring.views` | `Record<viewId, { query, params?, project? }>` | drives `seam.feedView(viewId, …)` |

`project` is optional; without it the raw rows are passed straight through.

**Returns a disposer** that unsubscribes everything. Calling it is the caller's job —
lifecycle-pair it to the stage's `unmount()`. The disposer is the function
`ctx.liveQuery` returned, or a call to `.unsubscribe()` on its result.

If `ctx.liveQuery` throws partway through wiring, subscriptions already created are
disposed before the error is rethrown, so a partial failure doesn't leak.

Apps with bespoke subscription needs should skip this and call `seam.update` /
`seam.feedView` from their own callbacks. It is thin on purpose.

---

## `applyBindings(root, bindings, snapshot) → ApplyResult`

The applier. `createSeam` calls it for you; call it directly only if you are not
using a seam.

```js
const result = applyBindings(root, BINDINGS, snapshot);
```

For each binding it resolves `binding.anchor` with `querySelector`, reads
`binding.source` out of the snapshot ([how, exactly](#how-a-source-is-resolved)),
and writes according to `binding.as.kind`:

- **`text`** — sets `textContent`. Strings pass through unchanged; numbers and
  booleans stringify; `null` and `undefined` become `""`; anything else becomes `""`.
  No truncation, no ellipsis, no case changes — presentation is the design's job.
- **`attr`** — `setAttribute` with the allowlisted name.
- **`presence`** — sets the attribute to `""` when the value is `false`, `0`, `""` or
  `null`; removes it otherwise.

Both attribute forms re-check the allowlist at runtime and **throw** on a violation:
`node-design-seam: attribute "x" is not bindable`. That is unreachable through the
types; it is guarded because the package's whole value rests on the allowlist
actually holding.

### How a source is resolved

`binding.source` is read from the snapshot in two attempts, in order:

1. **As a dotted path.** `wallet.summary.total` reads
   `snapshot.wallet.summary.total`. This is the normal case, and what `project`
   functions should target.
2. **As a single literal key**, if the walk found nothing or was blocked by a
   primitive. `{ "wallet.summary.total": "1,204" }` also resolves.

A nested value **wins** wherever both exist, so adding the fallback did not change
what any existing snapshot means.

The second attempt exists for slot names that collide by prefix. Nothing in the
contract stops a design from declaring both `obs.health.errorRate` for a KPI's text
and `obs.health.errorRate.state` for that KPI's state attribute — the first drop
authored against this contract does exactly that. A nested object cannot express
both: the string sitting at `errorRate` blocks the walk to `.state`. Without the
flat-key fallback there is no projection shape that satisfies the pair, and the
only ways out are asking design to rename a slot — but the contract says design
names slots — or leaving the attribute dead at its fixture value.

So when two declared slots collide by prefix, flatten just that pair:

```js
const toSnapshot = (rows) => ({
  obs: { health: { errorRate: fmtPct(rows.errRate) } },
  "obs.health.errorRate.state": rows.errRate > 0.05 ? "elevated" : "ok",
});
```

### `ApplyResult`

| Field | Type | Meaning |
| --- | --- | --- |
| `applied` | `number` | bindings whose anchor resolved and whose value was written |
| `unresolved` | `readonly string[]` | anchors matching nothing in the live DOM |
| `missingSources` | `readonly string[]` | sources absent from the snapshot |

An **unresolved anchor** is non-fatal at runtime — a chrome element may legitimately
not be mounted yet, and the seam re-tries. The same condition is a build failure
under `verify`, which is where it matters.

A **missing source** leaves the value the design shipped, rather than blanking the
UI. Absent is not the same as falsy — see
[missing values](write-vocabulary.md#missing-values-leave-the-fixture-in-place).

Several bindings may share one anchor; each re-resolves it and writes its own
attribute. That is how
[multi-pair `data-bind-attr`](write-vocabulary.md#several-attribute-writes-on-one-element)
is applied.

---

## `ALLOWED_ATTRIBUTES`

`ReadonlySet<AllowedAttribute>` — the seven attribute names the applier will write:

```js
["aria-label", "aria-valuenow", "data-count", "data-state", "hidden", "href", "title"]
```

Exported so tooling can check against the same set the applier enforces; the CLI's
scanner uses exactly this import. `class`, `style` and `id` are excluded on purpose.

---

## Types

```ts
type AllowedAttribute =
  | "aria-label" | "aria-valuenow" | "data-count"
  | "data-state" | "hidden" | "href" | "title";

type BindingWrite =
  | { kind: "text" }
  | { kind: "attr", name: AllowedAttribute }
  | { kind: "presence", hiddenWhenFalsy: AllowedAttribute };

type Binding = {
  anchor: string;   // selector into the design's markup, verified against the drop
  source: string;   // dotted path into the snapshot, e.g. "wallet.summary.total"
  as: BindingWrite;
  note: string;     // why this binding exists — for humans reviewing the seam
};

type ViewModule = {
  id: string;
  view: (d: any) => string;
  fixtures?: Record<string, unknown>;
};
```

`BindingWrite` has exactly three members and none of them names appearance. That
absence is the mechanism — see [Concepts](concepts.md#the-closed-vocabulary-is-a-mechanism-not-a-rule).

### Where these come from

These are not hand-written `.ts` — they are `.d.ts` files `tsc --declaration
--emitDeclarationOnly` generates from the same JSDoc that `checkJs` already
type-checks (`npm run build`, over `tsconfig.build.json`, scoped to `src/**/*.js`
minus tests). `npm run prepack` runs the build automatically, so `npm pack` /
`npm publish` — and therefore a registry install — always ship a current `types/`
tree; nothing under `types/` is committed or hand-maintained, so it can never drift
from `src/`.

`package.json#exports` points the `"types"` condition at `types/index.d.ts` for the
package root and `types/*.d.ts` for the `./src/*.js` subpath, so both resolve under
`moduleResolution: "NodeNext"`:

```ts
import { createSeam, mountDropFragment, wireStage, applyBindings } from "@econ-v1/node-design-seam";
import type { Binding } from "@econ-v1/node-design-seam";
```

`Binding` matters beyond hand-written imports — it is what every generated
`bindings.gen.js` imports (`cli/shared/emit.mjs` writes the
`@import { Binding } from "@econ-v1/node-design-seam"` header into each one), so it
must resolve from the package root, not just a subpath.
