contract: 1

# AGENTS.md — stage drop authoring guide

You are designing a UI as a DESIGN DROP: a folder of files the product renders
byte-for-byte, hash-pinned, with real data wired in later WITHOUT anyone ever
editing your files. This drop follows the STAGE DROP PROFILE — it renders inside a
host panel of the shell, not as its own page. Copy this file forward, unmodified,
into every subsequent drop you ship for this surface (the `contract:` line above is
how the integrating CLI knows which write-vocabulary you authored against).

## Deliverable

```
<drop-id>/
├── stage.html          # the fragment (no <html>/<head>/<body> — it mounts into a host element)
├── stage.css           # all appearance; integration never opens it
├── views/*.view.js     # one module per repeating/collection view (shape below)
├── assets/             # fonts/icons if needed (shipped and hashed with the drop)
└── tools/               # one visual golden PER FIXTURE STATE, and optionally preview-states.js
```

## The one rule

A view must never contain a value that will differ at runtime. Not a count, a rate,
a timestamp, a name, a status word. If a real instance of this UI would show
something else there, it is not markup — it is a slot.

- **Standalone scalars** — annotate in markup; the literal you write IS the fixture:

  ```html
  <span class="kpi" data-bind="myapp.summary.itemCount">1,204</span>
  ```

  Forms:
  - `data-bind="slot.path"` — text (the element's `textContent` is replaced).
  - `data-bind-attr="attrName:slot.path"` — an attribute, from a fixed allowlist:
    `aria-label`, `aria-valuenow`, `data-count`, `data-state`, `hidden`, `href`, `title`.
  - `data-bind-when="slot.path"` — presence: the element is shown or hidden based on
    whether the value is truthy.

  One element, several attributes — separate the `attr:slot` pairs with commas in a
  single `data-bind-attr`. Each attribute you write still needs its own literal on
  the element, because that literal is its fixture:

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

  This is not a wider vocabulary — it is three writes that were always allowed,
  expressed on one element. Every pair is checked against the same allowlist. A pair
  with no colon (a trailing comma is the usual cause), or with an empty attribute or
  slot name, fails the integrating build rather than binding wrong.

- **Repeating content** (rows, lists, series — anything collection-shaped) — a view
  module, ES-module default export, no globals:

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

  Mark the element in `stage.html` where a view's rendered markup should land with
  `data-view-slot="<id>"`, using exactly the module's own `id`:

  ```html
  <div data-view-slot="myapp.destinations"></div>
  ```

  That attribute is the entire contract between your markup and a view module —
  integration never targets anything else to place a view's output.

- **YOU name the slots** — semantic, namespaced to your app/surface (e.g. `myapp.*`).
  The fixture doubles as the shape-by-example for developers wiring data in. They
  conform their data to your names; you never rename to match theirs.

Integration's entire vocabulary against your files is: set text, set one
allowlisted attribute, toggle a state attribute you own. It cannot write a class, a
style, or an element — it cannot create or remove anything. So: any state that
changes appearance (status colors, fresh/stale dimming, hidden panels, expanded/
collapsed) must key off attributes YOU declare and style yourself (e.g.
`[data-state="stale"]`). If you don't design a state, it cannot exist at runtime.

## What the vocabulary cannot reach

That poverty applies to your **static markup** only. Inside a view module you write the
markup yourself from the data handed to you — including inline geometry and inline
`style`. That asymmetry decides where two whole classes of content have to live, and
both are easy to discover far too late:

- **Anything continuous is a view module, not a bound scalar.** A bar's width, a
  sparkline's path, a gauge's arc, a stacked chart's geometry — none of it can be
  delivered as text or as one of the seven allowlisted attributes. Every chart you
  design is a view module. Decide that before you lay the surface out, not after.
- **Anything that varies *inside* a string is a view module too.** When a value arrives
  carrying structure in its text — a redaction marker inside a log line, a highlighted
  match in a search result, a unit suffix you want styled differently — a text bind
  prints it raw, because `textContent` is the whole of what it sets. Parsing that
  structure into markup is your code's job, inside a view module.

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

Do not respond to either case by asking integration for a new verb. The closed
vocabulary is the entire reason your files can be hash-pinned; widening it to set a
width or wrap a substring would hand back exactly the access this contract exists to
remove. The view module is the sanctioned escape hatch, and it is yours.

## Theme is data, not environment

Your fragment mounts inside a shadow root and inherits nothing — and that includes the
host's theme. A `.dark` class on some ancestor is invisible to your 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.

If the surface has more than one theme, design them all and key them off a state slot
you declare on a wrapper you own:

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

Integration feeds it the host's current theme. This is the same rule as every other
state: if you don't design it, it cannot exist at runtime.

## Fixture states

Every view module ships **five** fixture states: `default`, `loading`, `empty`
(first run — nothing collected/created yet), `error`, `overflow`. `overflow` is the
one that breaks layouts, so design it against your actual worst case, not a
comfortable one: your longest real strings, your fullest real lists, your longest
real identifiers/URLs, your largest realistic counts. If a real value could ever be
longer or more numerous than what your `overflow` fixture shows, the fixture — not
the runtime data — is what's wrong, and layout will break in production first.

## This slice is read-only, unless told otherwise

No forms, no buttons that mutate state — that's a separate, later drop unless the
ask says otherwise. Local-only interactions (tabs, expand/collapse, hover) are
entirely yours to design and implement — style and drive them off your own state
attributes; they never touch integration's vocabulary.

## Self-containment

- No external URLs, no CDN fonts, no network calls of any kind. Every asset
  (fonts, icons, images) ships inside the drop folder and is hashed with it.
- Assume the fragment mounts inside a shadow root: style everything from
  `stage.css`, inherit nothing from the host page. Nothing outside your own files
  may leak in, and nothing of yours may leak out.
- The fragment must render standalone from your fixtures, pixel-golden per fixture
  state — that golden is the acceptance surface for your side of this contract,
  independent of integration.

## Previewing your drop

```sh
npx node-design-seam preview --drop .
```

Serves the drop at `http://127.0.0.1:4321` with a **fixture state × theme × width**
toolbar. It needs no config file, no app and no developer, and it writes nothing
into your folder.

The page drives the *real* applier — the same code that will run in the product — so
a golden captured here is evidence about production and not about a harness. Do not
hand-write your own preview page: a harness that interprets `data-bind*` itself can
disagree with the runtime, and then the golden agrees with the harness rather than
with what ships.

Your view modules need no setup; each is fed its own `fixtures[state]`, and the
preview tells you when one is missing a state. Scalar slots show the literals you
authored, which *is* your `default` state. To drive the other four, add an optional
`tools/preview-states.js`:

```js
export const states = {
  default: { myapp: { summary: { total: "1,204" } } },
  loading: { myapp: { summary: { total: "—" } } },
  // …empty, error, overflow
};
// optional — merged over the selected state, so themes don't double the entries
export const themes = { light: { "myapp.theme": "light" }, dark: { "myapp.theme": "dark" } };
```

Each entry is a snapshot in the shape integration will feed at runtime, so it also
tells the developer exactly what their data has to look like.
