# ae — API v1

Tiny attribute-based behavior + reactivity lib. No virtual DOM, no hydration:
HTML is the source of truth, `ae` attaches behavior to it.

Written in TypeScript (`src/`, entry `src/ae.ts`), ships as an ES module with
type declarations. `npm run build` → `dist/*.js` + declarations (entry
`dist/ae.js`), `npm test` runs the jsdom smoke suite, `npm run test:browser`
runs the Playwright suite, `npm run serve` hosts the site at
`site/index.html`.

This file is the prose spec. The same surface, member by member, with worked
examples and live demos, is the reference site: `site/reference.html`
(handles & lifecycle), `site/reactivity.html`, `site/bindings.html`,
`site/forms.html`, `site/lists.html` — deployed at
<https://nft.github.io/ae/reference.html>.

```html
<button data-ae="save">Save</button>
<span data-ae="status"></span>
```

```js
import { ae } from '@aeroapp/ae';

const count = ae.signal(0);

ae('status').render(el => el.textContent = `${count.value} items`);

ae('save')
  .press(() => count.value++)
  .hover(el => el.classList.add('hot'), el => el.classList.remove('hot'))
  .mount(el => console.log('save button appeared', el));
```

## Markup

One attribute: `data-ae="name"`. Names are free-form strings; the same name may
appear on any number of elements.

## Global API

### `ae(name) → Handle`
Returns a **live handle** for all elements with `data-ae="name"` — current ones
and any added to the DOM later. Handles are cached: `ae('x') === ae('x')`.

### `ae(name, root) → Handle` (scoped)
Same live handle, but matching, mounting, and `.els` only consider
**descendants** of `root` (the root itself is not a match, mirroring
`ae.parts`). Global and scoped handles compose: an element inside `root`
gets bindings from `ae('x')` *and* `ae('x', root)`. Nested scopes stack the
same way (innermost first). Scoped handles are cached per `(root, name)`.

The intended use is per-list behavior without global name collisions — scope
to the container, recover the item in the handler with `ae.itemOf`:

```js
ae('todos').list(todos, (li, todo) => {
  ae.parts(li).title.textContent = todo.text;
}, (todo) => todo.id);

const container = ae('todos').els[0];
ae('remove', container).press((btn) => {
  const todo = ae.itemOf(btn);
  todos.value = todos.value.filter((t) => t.id !== todo.id);
});
```

When the root itself can come and go — stamped by `.list`, or removed and
re-added — do the setup inside **`.scope`** instead of grabbing the element
imperatively:

```js
ae('column').scope((colEl) => {
  ae('cards', colEl).list(...);
  ae('card-del', colEl).press(...);
});
```

`.scope` runs once per root and retires the scoped handles it created when
the root unmounts, so a remount rebuilds them cleanly.

Two caveats, both consequences of handles being cached and append-only:

- **Set a scoped handle up once per root.** Re-running the same setup for
  the same root stacks duplicate bindings on the cached handle. In
  particular, never create scoped handles inside a `.list` *render*
  callback (it re-runs) or a bare `.mount` callback of a removable root —
  that's exactly what `.scope` is for.
- **Moving an element out of the scope does not unbind it.** Net-state
  lifecycle means moves never remount; a bound element reparented outside
  `root` keeps its scoped bindings until it actually leaves the DOM (or is
  renamed). Remove-and-reinsert if you need a rebind.

### `ae.signal(initial) → Signal`
Reactive value.

```js
const n = ae.signal(0);
n.value        // read (tracked inside render/effect/computed)
n.value = 5    // write — schedules dependents
n.value++      // works
```

Writes are **batched**: multiple writes in the same tick trigger one re-render,
on the next microtask. Setting an identical value (`Object.is`) is a no-op.

### `ae.computed(fn) → Computed (read-only)`
Derived value. Lazy until first read; after that a dependency write marks it
stale and it re-evaluates once per flush (or on the next read — reading
right after a write is always fresh). Dependents are notified **only when
the computed value actually changed** (`Object.is`) — unchanged results
cause no re-renders, even in diamond-shaped dependency graphs.

```js
const total = ae.computed(() => price.value * qty.value);
```

`fn` must be pure: it can run when upstream values turn out unchanged, and
a computed nobody currently subscribes to skips re-evaluation entirely
until it is read again. If `fn` throws during a flush, the error is logged,
subscribers keep the last good value, and the computed retries on the next
dependency write or read; a throw during an explicit `.value` read
propagates to the reader.

### `ae.parts(root) → { name: element }`
Named lookup of `data-ae` **descendants** of `root` (the root itself is not a
part). First match wins on duplicate names. Cached per root — intended for
template-stamped nodes, whose structure is static; don't use it on subtrees
you restructure. Part elements still participate in global handles
(`ae('title')` binds them all; `parts` is just scoped access).

### `ae.itemOf(el) → item | undefined`
The current `.list` item for the stamped node containing `el` — walks up to
the **nearest** stamped node, so nested lists resolve to the innermost.
Returns `undefined` outside any stamped node, or once the item has been
removed. The read is tracked: calling it inside an effect/render re-runs
when the item is replaced by key. This is how event handlers recover "which
item was clicked" without stamping keys into the DOM:

```js
ae('remove', container).press((btn) => {
  const todo = ae.itemOf(btn);
  todos.value = todos.value.filter((t) => t.id !== todo.id);
});
```

### `ae.isSignal(v) → boolean`
True for `Signal` and `Computed` instances. The imperative helpers use this to
decide between one-shot and reactive application.

### `ae.transition(fn) → ViewTransition | undefined`
Runs `fn` inside `document.startViewTransition`, so every DOM change its
signal writes cause — list stamps, removals, reorders, text — is animated
by the browser. The "new" snapshot is taken only after ae has settled
(flush **and** mount pipeline, including cascading writes from mount
bindings). Where View Transitions are unsupported, `fn` runs plainly and
`undefined` is returned — a no-op enhancement, not a requirement.

```js
ae.transition(() => { cards.value = next; });
```

Style the animation in pure CSS (`::view-transition-old/new/group`). Give
elements a unique `view-transition-name` and they **morph** — including
across `.list` containers, which visually erases the cross-container
remount (see Lists). Respect `prefers-reduced-motion` in your CSS; the
browser does not do it for you.

### `ae.settled() → Promise<void>`
Resolves once pending signal writes and everything they cascade into (list
stamping, mounts, effects scheduled by mounts) have drained. What
`ae.transition` awaits internally; also handy in tests instead of
hand-rolled `setTimeout` ticks.

### `ae.effect(fn) → dispose`
Auto-tracked side effect not tied to an element (logging, storage, fetch
triggers). Runs immediately, re-runs when its signals change. Returns a
function that stops it.

### `ae.observe(shadowRoot) → dispose`
Extends liveness into a shadow tree — the document observer cannot pierce
shadow boundaries, and neither can this (nested shadow roots each need
their own call). Marked content already inside mounts immediately when the
host is connected; removing the host unmounts the subtree and re-inserting
it remounts; moving an element from an observed shadow root into an
unobserved one unmounts it. Observation is refcounted per root: each call
returns its own idempotent disposer, and the observer disconnects (and the
subtree unmounts) when the last one runs.

```js
const shadow = host.attachShadow({ mode: 'open' });
const stop = ae.observe(shadow);
```

## Handle API

All methods return the handle — everything chains. Callbacks receive the
element first: `fn(el, ...)` — so one handle with many elements just works.

### Lifecycle

| method | behavior |
|---|---|
| `.mount(fn)` | Runs `fn(el)` once per matching element — immediately for existing ones, and for any element added later (via one shared `MutationObserver`). If `fn` returns a function, it runs as cleanup when the element is removed from the DOM. |
| `.scope(fn)` | Like `.mount`, but for **per-root setup that creates scoped handles**. Scoped handles first created inside `fn` (synchronously) are retired when the root unmounts, so a remount re-runs `fn` against fresh handles instead of stacking duplicate bindings onto cached ones. Use it whenever the root can come and go — e.g. nodes stamped by `.list`. A returned function runs first at teardown. |

### Rendering (reactive)

| method | behavior |
|---|---|
| `.render(fn)` | Runs `fn(el)` per element, **auto-tracking** every signal read inside. Re-runs (for every element) when any of them changes. Also runs for elements added later. Tracking is per-run: only signals read on the last run are dependencies. |

### Events

Listeners are attached **per element** through the mount pipeline (and removed
on unmount), so elements added later still need no manual re-binding — the
shared `MutationObserver` binds them. Per-element listeners mean non-bubbling
events (`focus`, `blur`, …) work, `stopPropagation` behaves normally, and
nested `data-ae` elements never shadow each other. Handlers get `(el, event)`.

| method | behavior |
|---|---|
| `.press(fn)` | Activation: `click` for every element. Native-like Enter/Space is synthesized **only** for elements the browser does not natively activate (e.g. `div[tabindex]`, `[role=button]`) — native buttons/links already turn Enter/Space into `click` (adding our own would double-fire). Enter fires on keydown (key repeats included); Space fires once on keyup, its keydown only prevents page scroll, and moving focus mid-press cancels. Keys originating in nested native controls or editable text (including inherited `contenteditable`) are ignored, so typing is never hijacked. |
| `.hover(enter, leave?)` | `pointerenter` / `pointerleave` on the element itself — nesting-safe by construction. |
| `.on(type, fn, opts?)` | Escape hatch for any DOM event type. `opts` is a standard `AddEventListenerOptions` (`once`, `passive`, `capture`, …) and is passed through. |

### Imperative helpers

Apply to every element in the handle. Sugar — everything is also doable inside
`.render()`/`.mount()`. Each accepts a `Reactive<T>` value:

- **plain value** → applied once;
- **signal / computed** → applied reactively (`.text(msgSignal)`);
- **function `(el) => value`** → run inside an auto-tracked effect, so signals
  read inside make it reactive (`.cls('empty', () => count.value === 0)`).

| method | behavior |
|---|---|
| `.text(v)` | `textContent = String(v)` |
| `.cls(name, on?)` | `classList.toggle(name, on)`; omit `on` for a one-shot plain toggle |
| `.attr(name, v)` | set attribute; `null`/`undefined`/`false` removes it, `true` sets it empty |
| `.show(on)` | toggle `hidden` |

### Forms

| method | behavior |
|---|---|
| `.input(signal)` | Two-way binding, wired by field type: text-like inputs / `<textarea>` / `<select>` ↔ `Signal<string>` via `value`; `type=checkbox` ↔ `Signal<boolean>` via `checked`; `type=number\|range` ↔ `Signal<number>` via `valueAsNumber` (empty field reads as `NaN`); `type=radio` ↔ `Signal<string>` holding the group value — give radios explicit `value=` attributes; the signal enforces exclusivity across **all bound radios** even without `name=` (an unbound radio sharing a native `name` is untouched), and an unmatched signal value unchecks all bound radios; `<select multiple>` ↔ `Signal<string[]>` — values read in option order, writes select the wanted **set** of values (duplicate option values toggle together), and you must write a **new** array (in-place mutation never notifies). Signal → field is reactive; field → signal on `input`/`change`. Writes are equality-guarded (element-wise for arrays), so echoes never move the caret. Several fields bound to one signal mirror each other. Non-form elements log an error and no-op. |

### Lists

```html
<ul data-ae="todos">
  <template><li><b data-ae="title"></b> <i data-ae="due"></i></li></template>
</ul>
```

```js
ae('todos').list(todos, (el, todo, i) => {
  const p = ae.parts(el);
  p.title.textContent = `${i + 1}. ${todo.text}`;
  p.due.textContent = todo.due;
}, (todo) => todo.id);
```

| method | behavior |
|---|---|
| `.list(items, render, key?)` | Keyed list stamping. `items` is a `Reactive<T[]>` (signal/computed → reactive, function → auto-tracked, plain array → stamped once). The container's `<template>` (exactly one root element) is the item prototype; stamped nodes are kept at the end of the container in item order. `render(el, item, index)` runs per node in its own effect — re-runs when the item is replaced by key, the index moves, or any signal read inside changes. `key` defaults to item identity; duplicate keys log an error and fall back to a fresh node. |

Reconciliation guarantees: reused keys keep their DOM node (unchanged items —
same reference, same index — don't re-render); reordering moves nodes without
remounting `data-ae` bindings inside them; vanished keys remove the node and
dispose its effect; container unmount disposes everything. `data-ae` elements
inside the template participate in the global lifecycle as usual.

These guarantees hold **within one container**. An item that moves *between*
two `.list` containers (e.g. a kanban card changing columns) is a removal in
one list and a fresh stamp in the other — a **new DOM node**, so transient
state on the old node (focus, CSS transitions, scroll) does not travel with
it. Visually this is fixable: wrap the mutation in `ae.transition` and give
the stamped nodes a unique `view-transition-name` — the browser morphs the
old node into the new one.

### Escape hatches

| member | behavior |
|---|---|
| `.els` | Plain array of currently matching elements. |
| `.each(fn)` | Run `fn(el)` over current elements once (not reactive, not for future ones). |

## Semantics & guarantees

- **Liveness**: one `MutationObserver` on `document.body` powers everything —
  mount/cleanup, late elements, and `data-ae` attributes that are added,
  removed, or renamed after insertion — plus one per shadow root opted in
  via `ae.observe`. No per-handle observers.
- **Batching**: signal writes coalesce per microtask; each affected `render`/
  `effect` runs at most once per flush.
- **Disposal**: when an element leaves the DOM, its mount cleanups run, its
  render effects are disposed, and its event listeners are removed. Re-adding
  the element re-binds everything. Disposal is absolute: a disposed effect
  never runs again, even if it was already queued for the current flush.
  No manual unbinding, no leaks.
- **Net-state lifecycle**: mount/cleanup reflect the *net* DOM change per
  task, not intermediate mutations. Moving a connected element to another
  parent does not remount it; renaming `data-ae` a→b→a is a no-op; multiple
  renames in one task bind the final name exactly once.
- **Fault isolation**: a throwing binding, cleanup, or effect *re-run* is
  logged via `console.error` and does not prevent the others from running.
  The one exception: an `ae.effect` whose **initial** run throws propagates
  the error synchronously to the caller and leaves no trace (nothing
  subscribed, nothing queued). Element bindings (`.render`/`.mount`) are
  always isolated, including their first run. A computed that throws during
  a flush is logged too — its subscribers keep the last good value and it
  recovers on the next dependency write or read; only an explicit `.value`
  read surfaces the error to the reader.
- **Runaway guard**: an effect that writes a signal it also reads trips a
  circuit breaker after 100 flush cycles (with a `console.error`) instead of
  hanging the tab.
- **Templating stays native**: `.list()` stamps from a real `<template>`
  element — no template syntax, no virtual DOM; reconciliation is keyed
  node reuse.
