# Write vocabulary

Everything integration is permitted to do to a drop. The design declares its slots as
`data-bind*` attributes in its own markup; `bindings` scans them into the generated
table; `verify` checks the table against them; the applier can do nothing that isn't
one of these three forms.

## The three forms

| In the drop's markup | What integration may do | Emitted as |
| --- | --- | --- |
| `data-bind="slot.path"` | replace the element's `textContent` — never its markup | `{ kind: "text" }` |
| `data-bind-attr="attrName:slot.path"` | set an allowlisted attribute ([several pairs allowed](#several-attribute-writes-on-one-element)) | `{ kind: "attr", name }` |
| `data-bind-when="slot.path"` | toggle `hidden` when the value is falsy | `{ kind: "presence", hiddenWhenFalsy: "hidden" }` |

```html
<span class="kpi" data-bind="wallet.summary.total">1,204</span>
<a class="link" data-bind-attr="href:wallet.detailUrl" href="#">details</a>
<p class="alert" data-bind-when="wallet.hasAlerts" hidden>alerts pending</p>
```

The literal in each position is the fixture — the drop renders standalone from it,
and it is the shape-by-example for whoever wires the data.

### text

Sets `textContent`, never `innerHTML`. No markup can be injected through this form,
which is the entire reason it is safe to point at design-owned elements.

Values are rendered exactly as supplied: no truncation, no ellipsis, no case changes.
Presentation of a long value is the design's problem, solved in CSS. Solving it in
the applier would be precisely the quiet visual decision this package prevents.

Non-string values are coerced: numbers and booleans stringify, `null` becomes the
empty string, and anything else (an object, an array) becomes the empty string
rather than `[object Object]`. Format your values in `project` — that is what it is
for.

### attr

Sets one attribute from a fixed allowlist of **seven** names:

`aria-label` · `aria-valuenow` · `data-count` · `data-state` · `hidden` · `href` · `title`

They are content, identity and accessibility — none of them can carry a visual
decision. `class`, `style` and `id` are excluded on purpose, and the applier has no
branch that writes them. The set is exported at runtime as `ALLOWED_ATTRIBUTES`.

A `data-bind-attr` naming anything outside the allowlist is a **scan error**:
`bindings` refuses to write the table, and `verify` fails. It is not silently
dropped.

### presence

Shows or hides an element the design already styles for both states, by toggling the
attribute the drop's own CSS keys on. For generated bindings that attribute is
`hidden`.

The attribute is set to `""` when the value is `false`, `0`, `""` or `null`, and
removed otherwise. A slot missing from the snapshot entirely is *not* falsy — see
[missing values](#missing-values-leave-the-fixture-in-place).

Presence stays a design decision: the drop declares which element can disappear and
styles both states. Integration only supplies the boolean.

## Missing values leave the fixture in place

A slot whose source is **absent from the snapshot** is not treated as empty, false or
hidden. The applier skips it, leaving whatever the design shipped, and records the
path in `missingSources` on the result. Blanking the UI because a query hasn't
resolved yet would be a worse failure than showing a stale fixture.

This is the one case where "the value is missing" and "the value is falsy" differ:
`data-bind-when` hides an element for `false`, `0`, `""` or `null`, but does nothing
at all when the path simply isn't there. If you want an element hidden, send a falsy
value — don't omit the key.

## Several attribute writes on one element

A single `data-bind-attr` may carry several comma-separated `attr:slot` pairs:

```html
<span class="meter" data-state="ok" data-count="1" aria-valuenow="38"
      data-bind-attr="data-state:obs.queue.state, data-count:obs.queue.step, aria-valuenow:obs.queue.total">
```

Each pair is scanned, allowlist-checked and emitted as its own binding. Whitespace
around a pair is trimmed. Every attribute still needs its own literal on the element,
because that literal is its fixture.

That element produces **three rows** in the generated table, all sharing one anchor —
the literal attribute value:

```js
{ anchor: '[data-bind-attr="data-state:obs.queue.state, data-count:obs.queue.step, aria-valuenow:obs.queue.total"]',
  source: "obs.queue.state", as: { kind: "attr", name: "data-state" }, note: "generated" },
// …and two more with the same anchor
```

Multiple bindings against one anchor are expected here, not duplicates to prune: each
one re-resolves the selector and writes its own attribute. Deleting the "extras"
deletes real writes.

This is syntax for expressing writes the vocabulary already permitted — it is **not**
a widening of it, and the contract version stays at 1. A malformed pair (no colon, or
an empty attribute or slot name) is a scan error; a trailing comma is the usual cause.

> **Before 0.1.5 the scanner misread this form**, killing every write on such an
> element while `verify` still reported full coverage. See the
> [CHANGELOG](../CHANGELOG.md) and
> [Troubleshooting](troubleshooting.md#after-a-package-upgrade).

## What the vocabulary cannot reach

Two kinds of content cannot be delivered through any of the three forms. Both have
the same answer, and it is not a new verb — it is a **view module**: design's own
code, building design's own markup from data you feed it.

- **Continuous geometry** — a bar's width, a sparkline's path, a gauge's arc, a
  stacked chart. None of it is expressible as text or as one of the seven attributes,
  and adding a `style` verb would hand integration exactly the restyling power the
  package removes. Every chart is a view module; that has to be decided when the
  surface is laid out, not after.
- **Structure inside a string** — a redaction marker inside a log line, a highlighted
  match in a search result, a unit suffix styled differently. `data-bind` sets
  `textContent`, so it prints such a value raw. Feed the raw string and let the
  drop's code segment it.

**The test:** if the thing that varies is *inside* a value rather than *being* the
value, it is view-module content.

If the drop shipped a chart as static markup, that is a **drop bug** — file an Ask
with design. Do not work around it by inventing a selector or asking for a verb.

## Theme is data, not environment

A drop mounts inside a shadow root and inherits nothing — including the host's theme.
A `.dark` class on some ancestor is invisible to the drop's stylesheet, and
`prefers-color-scheme` follows the operating system rather than the host's own theme
control, so the two desync the moment someone overrides it in the product.

A multi-theme drop therefore declares a state slot on a wrapper it owns:

```html
<div class="root" data-state="light" data-bind-attr="data-state:app.ui.theme">
```

Integration feeds it the host's current theme, exactly like any other state. If the
drop declares no theme slot, it supports one theme — ask design; don't force it.

## View modules

Collection-shaped content — rows, lists, series — is a view module: an ES module with
a default export, no globals.

```js
export default {
  id: "wallet.cards",
  view: (d) => `…markup built from d…`,
  fixtures: { default: {…}, loading: {…}, empty: {…}, error: {…}, overflow: {…} },
};
```

The drop marks where its output lands with `data-view-slot`, using exactly the
module's own `id`:

```html
<div data-view-slot="wallet.cards"></div>
```

That attribute is the entire contract between the markup and the module. Integration
registers the module with `createSeam({ views: [cards] })` and pushes data with
`seam.feedView("wallet.cards", data)`; it never targets anything else to place a
view's output.

Scanning treats view modules differently from markup: a `.view.js` file is read only
for its `id`. Any `data-bind*` inside a view module's template string is **not**
scanned as a slot, because that markup is rendered at runtime and its bindings
resolve after `feedView`.

Every view module ships five fixture states — `default`, `loading`, `empty`, `error`,
`overflow` — and `seam.renderFixtures()` renders each module's `default` with no
kernel attached.

## Scan errors

These stop `bindings` from writing the table at all, and fail `verify` under the
`contract:` prefix. The scan fails closed by design — a binding that cannot be read
correctly is never a binding that gets guessed at.

| Error | Cause |
| --- | --- |
| `data-bind-attr uses "x" — not in the allowlist [...]` | an attribute outside the seven |
| `data-bind-attr pair "x" is missing a colon — expected "attr:slot"` | a malformed or empty pair; usually a trailing comma |
| `data-bind-attr pair "x" is malformed — both attr and slot must be non-empty` | `":slot"` or `"attr:"` |
| `data-bind source "x" contains a quote character — not a valid slot name` | a quote in a slot name would break the generated string literal |
| `view module has no id: "…" field` | a `.view.js` with no `id` |

## Where slots are read from

- **`.html` files only** for the scalar forms. A `data-bind` token appearing in
  `stage.css`, a README, or any other file mints nothing.
- **`.view.js` files** for view ids only.
- Files named in `notDropContent` (default: `AGENTS.md`) are excluded from both the
  scan and the hash.
