# AGENTS.md - mx.js

Instructions for AI coding agents working with mx.js codebases.

**Full documentation: [mxjs.dev](https://mxjs.dev)** - API reference, patterns, gotchas, playground.

When transferring from React/Vue/Svelte, read [mxjs.dev/what-mx-is-not](https://mxjs.dev/what-mx-is-not) first. mx deliberately has no hooks, no JSX, no fragments, no portals, no memo, no synthetic events, no signals. That page lists every omission and what replaces it.

---

## Hard Rules

| Rule | Do | Don't |
|------|----|-------|
| Component definition | `$(props) {}` (method shorthand) | `$: function(props) {}` - legacy |
| Component name | `define('my-widget', {...})` kebab-case (camelCase also works — auto-converts to kebab) | Mismatched names between define and call site |
| Call site | `mx.myWidget()` camelCase | `mx.MyWidget()` - PascalCase gives `-my-widget` (invalid) |
| State updates | `this.$({ key: value })` | `this.$state.key = value` - bypasses the merge, forbidden |
| Empty re-render | Pass actual changed data: `this.$({ key: newValue })` | `this.$({})` with empty object - code smell |
| Numbers in render | `mx.span(count)` | `mx.span(String(count))` - auto-casts |
| Event names | `onclick` (lowercase) | `onClick` - not React |
| Class attribute | `class: 'active'` | `className: 'active'` - not React |
| Label for | `for: 'input-id'` | `htmlFor: 'input-id'` - not React |
| Inline styles | `style: 'width:' + pct + '%'` | `style: { width: pct + '%' }` - not supported |
| Array children | `mx.ul(...items.map(i => mx.li(i)))` | `mx.ul(items.map(...))` - stringifies to `"1,2,3"` text |
| No attrs, just children | `mx.div(child1, child2)` | `mx.div({}, child)` or `mx.div(null, child)` |
| Boolean attributes | `disabled: true` sets `disabled="true"` | Expecting it to set a JS property |
| First arg type | `mx.div({class: 'x'}, child)` | `mx.div(myMap, child)` - non-plain-Object treated as child |

---

## Common Mistakes

```js
// ✗ Legacy method syntax
define('foo', { $: function(props) { ... } });
// ✓ Method shorthand
define('foo', { $(props) { ... } });

// ✗ Mutating $state directly bypasses the declarative flow
this.$state.count = 5;
// ✓ Always go through $()
this.$({ count: 5 });

// ✗ Empty re-render with side-effect mutations - hidden data flow
$() {
    this._items.push(newItem);
    this.$({});
}
// ✓ Pass the actual change
$({ items = [] }) {
    mx.button({ onclick: () => this.$({ items: [...items, newItem] }) }, 'Add');
}

// ✗ Arrays of children stringified to text
mx.ul(items.map(i => mx.li(i.name)))
// ✓ Spread so each li is a child
mx.ul(...items.map(i => mx.li(i.name)))

// ✗ Mutates parent's array
assets.sort((a, b) => a.price - b.price);
// ✓ Copy first
let sorted = [...assets].sort((a, b) => a.price - b.price);

// ✗ Stacks listeners on every re-render
$() { this.addEventListener('scroll', () => doStuff()); }
// ✓ Property assignment overwrites safely
$() { this.onscroll = handler; }

// ✗ Unnecessary wrapping
mx.span(String(count))
// ✓ Numbers auto-cast
mx.span(count)

// ✗ React-style event names
mx.button({ onClick: handler })
// ✓ Lowercase
mx.button({ onclick: handler })

// ✗ Number leaks into render as text node "0"
items.length && mx.div('Has items')
// ✓ Coerce to boolean
!!items.length && mx.div('Has items')

// ✗ dom() with no attrs skips component init
let picker = dom.datePicker();
// ✓ Pass {} to trigger $()
let picker = dom.datePicker({});

// ✗ PascalCase produces leading hyphen (invalid tag)
mx.MyWidget()
// ✓ camelCase or string
mx.myWidget() // or mx('my-widget')

// ✗ Static array re-allocated on every render
$({ value }) {
    let statuses = ['open', 'pending', 'closed'];   // fresh array each call
    return mx.select({ '.value': value }, ...statuses.map(s => mx.option({ value: s }, s)));
}
// ✓ Hoist constants to module scope
let STATUSES = ['open', 'pending', 'closed'];

// ✗ Positional rows lose typed input / checkbox state after a sort
container.render(...rows.map(r => mx.div(mx.span(r.name), mx.input())));
// ✓ Keyed dom() rows move by identity, state intact (see Keyed Lists below)

// ✗ Logic driven by rendered text
if (Number(this.querySelector('.count').textContent) > 0) this.$({ hasItems: true });
// ✓ Read from state / data attributes
if (this.$state.items.length) this.$({ hasItems: true });
```

---

## Reserved Tag Names

The internal tag cache is a plain object; it inherits `Object.prototype` methods. Don't use any of these as component names:

```
toString, valueOf, constructor, hasOwnProperty, isPrototypeOf,
propertyIsEnumerable, toLocaleString, __defineGetter__,
__defineSetter__, __lookupGetter__, __lookupSetter__, __proto__
```

`mx.toString()` produces a broken description that throws `InvalidCharacterError` at `createElement` time. Also applies to `define('toString', ...)`.

---

## Checklist for New Components

1. `define('kebab-name', { $(props) {} })` - kebab-case only in define()
2. Destructure all props with defaults in `$()` signature - no `??=` needed for primitives
3. Early return on empty state: `if (!data) return [];`
4. Use `this._name` for private caches (Maps, flags, intervals)
5. Use `this._name ||= new Map` for persistent references
6. Compute derived values once, reuse
7. Return mx nodes - single element or array
8. Direct property assignment for events on `this`: `this.onclick = handler`
9. Callbacks via `onchange?.(value)` or prop check
10. Event bus for cross-component communication
11. Never mutate input arrays - copy before sort/filter
12. `null` / `false` for conditional elements
13. `clearInterval(this._interval)` at top of `$()`
14. CSS transitions for animations, not JS
15. Formatters in render, not in data prep
16. Never write `this.$state.x = ...` directly - always `this.$({...})`
17. Hoist constant arrays/objects out of `$()` - don't re-allocate per render
18. Stateful list rows use a keyed `dom()` Map, not positional `mx()` rows
19. Drive logic from `$state` / data attributes, never from rendered text

---

## Decision Tree

```
Description for render to reconcile?  →  mx.tag()
Real element to hold a reference?     →  dom.tag()
Need to call .$() on it later?        →  dom.tag()
```

### `$state` vs `this._property`

This is the single most common source of confusion for teams adopting mx. Pick the narrowest tier that holds the data's actual lifetime.

| | `this.$state.x` | `this._x` |
|---|---|---|
| **What it holds** | Accumulated props from the parent | Internal bookkeeping - tracking values, DOM refs, caches, flags, timers |
| **Who writes it** | The parent (via `el.$({...})`) and the component itself (via `this.$({...})`) | Only the component itself |
| **Merge behavior** | `Object.assign(this.$state, props)` runs before every `$()` call | Plain own property - set directly, no merge |
| **Read from outside** | OK, for aggregation (e.g. form submit reading `nameField.$state.value`) | Forbidden - it's private |
| **Write from outside** | Forbidden - always go through `comp.$({ key: newValue })` | Forbidden - it's private |

**Rule:** if a parent needs to see or set it, it's `$state`. Everything else is `this._`.

**Never** put internal bookkeeping (`_interval`, Maps, DOM refs) in `$state` - it pollutes the props namespace and risks collisions.

---

## 4 Globals

| Global | Returns | Use for |
|--------|---------|---------|
| `mx.tag(attrs?, ...nodes)` | Array (description) | Inside `render()` - efficient reconciliation |
| `dom.tag(attrs?, ...nodes)` | HTMLElement | Persistent references, calling `.$()` later |
| `define(name, { $() {} })` | void | Register a component |
| `components` | object | Component registry lookup |

Both `mx` and `dom` are Proxies. CamelCase auto-converts to kebab-case (memoized):

```js
mx.priceChart()   // → <price-chart>
dom.vC()          // → <v-c>
```

---

## How `$()` Works

The `$` wrapper does `Object.assign(this.$state ||= {}, props)` then calls your function with the merged state object. Incoming keys merge in; keys not passed survive untouched.

```js
define('greeting', {
    $({ name = 'World' }) {
        return mx.div('Hello, ' + name);
    }
});
```

When parent calls `el.$({ count: 5 })`, count updates. When the component calls `this.$({ count: count + 1 })`, it self-updates. The destructuring default (`= 0`) applies whenever that key's value is `undefined` in `$state`.

### The parent always wins the merge

Because `Object.assign` runs **before** your `$` function, a parent re-passing the same prop will overwrite locally updated state. Usually that's what you want (controlled components). If you need state that survives parent re-passes, take the prop as a seed and store local state under a different key on `this._`.

---

## render() Algorithm

`el.render(...children)` reconciles children left-to-right against existing DOM:

| Input | Behavior |
|-------|----------|
| mx array (`._=true`) | Create/reuse by tag name, apply attrs, recurse children |
| Real DOM node | Identity-match by reference. Same = no-op, different = move/insert |
| String / Number | Create/update text node |
| `null` / `false` | Skipped - use for conditional rendering |

After processing all children, excess old DOM nodes are removed.

---

## Attribute System

| Syntax | Behavior | Example |
|--------|----------|---------|
| `name: 'value'` | `setAttribute` | `class: 'active'` |
| `'.name': value` | `el[name] = value` (property) | `'.value': text` |
| `name: null` / `false` | `removeAttribute` | `disabled: null` |
| `name: true` | `setAttribute(name, 'true')` | `disabled: true` |
| `onname: fn` | `el[name] = fn` (property) | `onclick: handler` |

**Dual-track**: `value`, `checked`, `selected` automatically set both attribute AND property.

**`el.$attrs(obj)`**: Applies attributes to the element using the same rules above. Useful for setting attributes on `this` inside `$()`:

```js
this.$attrs({ disabled, class: 'my-component' });
```

Caveats vs `render()`: `$attrs` cannot clear event handlers (it calls `removeAttribute`, which is a no-op for handlers) and does not reset `value`/`checked`/`selected` properties on null. For full cleanup semantics, use `render()` with fresh descriptions. See [mxjs.dev/reference](https://mxjs.dev/reference).

**Stamp system**: Event handlers set via `render()` attrs in render N but absent in render N+1 are auto-nullified. You never manually remove handlers.

---

## Conditional Rendering

```js
// Short-circuit (preferred)
isPlaying && mx.div({ class: 'waveform' })
!!results.length && mx.div({ class: 'results' }, ...items)

// Inside arrays - falsy values skipped
return [
    mx.div({ class: 'header' }, title),
    showClear && mx.button({ onclick: clear }, 'x'),
    mx.div({ class: 'footer' })
];
```

`false`, `null`, and `undefined` are skipped. `0` is not - it's a valid number that renders as a text node. Prefix with `!!` any time the left side of `&&` might be a number (`.length`, counters, etc.).

---

## Keyed Lists (Identity Matching)

Create real elements with `dom()`, store references, render them - mx physically moves nodes:

```js
this._rowMap ||= new Map;
let rowMap = this._rowMap;

// Clean removed items
for (let [id] of rowMap) {
    if (!items.find(d => d.id === id)) rowMap.delete(id);
}

// Create/update persistent elements
for (let d of items) {
    let el = rowMap.get(d.id);
    if (!el) { el = dom.myRow(); rowMap.set(d.id, el); }
    el.$({ data: d });
}

// Render - nodes move by identity, internal state preserved
container.render(...items.map(d => rowMap.get(d.id)));
```

---

## Class Binding

```js
class: 'nav-link' + (path === href ? ' active' : '')
class: 'col-change ' + (pct >= 0 ? 'positive' : 'negative')
```

---

## Event Bus

```js
let events = (() => {
    let listeners = new Map;
    return {
        send(event, data) { listeners.get(event)?.forEach(fn => fn(data)); },
        on(event, fn) {
            let s = listeners.get(event);
            s ? s.add(fn) : listeners.set(event, new Set([fn]));
        }
    };
})();
```

---

## Memoization (cache the function, not the component)

Before adding any cache, measure - the reconciler is cheap enough that component-level memoization is usually pointless. When a function is genuinely expensive, put the cache on the function itself:

```js
function expensiveFormat(arg) {
    let c = expensiveFormat.lookup ||= {};
    return c[arg] ??= /* actual expensive work */;
}
```

For object-identity inputs, use a Map (plain objects stringify keys):

```js
function expensiveFormat(item) {
    let c = expensiveFormat.lookup ||= new Map;
    if (!c.has(item)) c.set(item, /* expensive work */);
    return c.get(item);
}
```

Function-level caches work across all callers and all component instances. No per-component bookkeeping, no invalidation logic scattered through `$()`.

---

## Pattern Library

### Counter (minimal stateful component)

```js
define('counter', {
    $({ count = 0 }) {
        return [
            mx.button({ onclick: () => this.$({ count: count + 1 }) }, '+'),
            mx.span(count),
            mx.button({ onclick: () => this.$({ count: count - 1 }) }, '-')
        ];
    }
});
```

### Sparkline (stateless, SVG)

```js
define('sparkline', {
    $({ history = [], width = 80, height = 24 }) {
        if (!history.length) return [];

        let min = Math.min(...history);
        let max = Math.max(...history);
        let range = max - min || 1;
        let len = history.length;

        let points = history.map((v, i) =>
            (i / (len - 1) * width).toFixed(1) + ',' +
            (height - ((v - min) / range * (height - 2) + 1)).toFixed(1)
        ).join(' ');

        let color = history[len - 1] >= history[0] ? '#22c55e' : '#ef4444';

        return [
            mx.svg({ viewBox: '0 0 ' + width + ' ' + height, width, height, fill: 'none' },
                mx.polyline({ points, stroke: color, 'stroke-width': '1.5', 'stroke-linecap': 'round' })
            )
        ];
    }
});
```

### Sortable table (self-updating state)

```js
define('data-table', {
    $({ rows = [], sortKey = 'name', sortDir = 1 }) {
        let sorted = [...rows].sort((a, b) => {
            let va = a[sortKey], vb = b[sortKey];
            return sortDir * (typeof va === 'string' ? va.localeCompare(vb) : va - vb);
        });

        let header = (label, key) =>
            mx.th({
                onclick: () => this.$({ sortKey: key, sortDir: sortKey === key ? -sortDir : 1 })
            }, label, sortKey === key ? (sortDir > 0 ? ' ▲' : ' ▼') : null);

        return mx.table(
            mx.thead(mx.tr(header('Name', 'name'), header('Value', 'value'))),
            mx.tbody(...sorted.map(r => mx.tr(mx.td(r.name), mx.td(r.value))))
        );
    }
});
```

### Form with callbacks

```js
define('my-form', {
    $({ title = '', saving = false, onsubmit, onclose }) {
        let submit = async () => {
            if (!title.trim()) return;
            this.$({ saving: true });
            await onsubmit?.({ title });
            this.$({ saving: false });
        };

        return mx.div({ class: 'form' },
            mx.input({
                value: title,
                placeholder: 'Title',
                oninput: e => this.$({ title: e.target.value })
            }),
            mx.div({ class: 'flex gap-3' },
                mx.button({ onclick: onclose }, 'Cancel'),
                mx.button({ onclick: submit, disabled: saving },
                    saving ? 'Saving...' : 'Save'
                )
            )
        );
    }
});
```

### Toast notifications

```js
let toastContainer = null;

function toast(message, type = 'info') {
    if (!toastContainer) {
        toastContainer = dom.div({ class: 'toast-container' });
        document.body.append(toastContainer);
    }
    let el = dom.div({ class: 'toast toast-' + type },
        mx.span(message),
        mx.button({ onclick() { el.remove() } }, '×')
    );
    toastContainer.append(el);
    setTimeout(() => el.remove(), 4000);
}

toast.success = msg => toast(msg, 'success');
toast.error = msg => toast(msg, 'error');
```

### Timer component (reading latest state)

```js
define('player-bar', {
    $({ track, isPlaying = false, progress = 0 }) {
        clearInterval(this._interval);

        if (isPlaying && track) {
            this._interval = setInterval(() => {
                // Read from $state, NOT the closed-over `progress` (that's stale from render time)
                let p = this.$state.progress + 1;
                if (p >= this.$state.track.duration) return;
                this.$({ progress: p });
            }, 1000);
        }

        if (!track) return [mx.div('No track selected')];

        return [
            mx.div(track.title),
            mx.div(track.artist),
            mx.progressBar({ value: progress, max: track.duration })
        ];
    }
});
```

### Virtual scroll

```js
define('v-c', {
    $({ height = 400, rowHeight = 56, rows = [] }) {
        this._visibleRows = Math.floor((height - 1) / rowHeight);
        this._lastOffset = 0;
        this.style.height = height + 'px';

        let renderVisible = offset => {
            let visible = rows.slice(offset, offset + this._visibleRows + 1);
            return [
                mx.div({ style: 'height:' + (offset * rowHeight) + 'px' }),
                ...visible,
                mx.div({ style: 'height:' + ((rows.length - offset - visible.length) * rowHeight) + 'px' })
            ];
        };

        this.onscroll = e => {
            let scrollTop = Math.max(0, e.target.scrollTop);
            let offset = Math.floor(scrollTop / rowHeight);
            if (offset === this._lastOffset) return;
            this._lastOffset = offset;
            this.render(...renderVisible(offset));
            this.scrollTop = scrollTop;
        };

        return renderVisible(0);
    }
});
```

### Lazy-loaded async component

```js
define('code-editor', {
    $({ value = '', disabled, onChange }) {
        let me = this;
        me.$attrs({ disabled });

        if (!me._initialized) {
            me._initialized = true;
            me._container = dom.div();

            loadLibrary('codemirror').then(() => {
                me.$editor = CodeMirror(me._container, { lineNumbers: true });
                me.$editor.on('change', () => onChange?.(me.$editor.getValue()));
                me.$editor.setValue(value);
            });
        }

        if (me.$editor && me.$editor.getValue() !== value)
            me.$editor.setValue(value);

        return [me._container];
    }
});
```

### Debounced search

```js
define('search-bar', {
    $({ value = '', onsearch }) {
        return mx.input({
            value,
            placeholder: 'Search...',
            oninput: e => {
                let query = e.target.value;
                this.$({ value: query });
                clearTimeout(this._debounce);
                this._debounce = setTimeout(() => onsearch?.(query), 300);
            }
        });
    }
});
```

### Icon helper (SVG)

```js
let icons = {
    check: 'M20 6L9 17l-5-5',
    x: 'M18 6L6 18M6 6l12 12',
    menu: 'M4 12h16M4 6h16M4 18h16',
};

function icon(name, size = 24) {
    return mx.svg({
        viewBox: '0 0 24 24', width: size, height: size,
        fill: 'none', stroke: 'currentColor', 'stroke-width': '2',
        'stroke-linecap': 'round', 'stroke-linejoin': 'round'
    }, mx.path({ d: icons[name] || '' }));
}
```

### Dynamic styles

```js
mx.div({ style: 'width:' + pct + '%;background:' + color })
```

---

More at [mxjs.dev](https://mxjs.dev).
