# control — foreign UI controls (charts, grids, maps)

> A `control` block declares the contract of a foreign UI widget — a chart, a data grid, a map — that a small JavaScript module implements. You declare its typed props and events (and the shape types it consumes) in Osy#; the compiler type-checks every call site against that contract, projecting your app's data into the control's shape. Foreign controls are how you add rich, third-party widgets beyond the platform's few native building blocks.

<!-- id: ui-controls · area: ui · stability: stable · html: https://osysharp.com/reference/ui/controls/ -->

## Summary        {#summary}
The platform ships a **small set of native building blocks** (`Stack`, `Text`, `Input`, `Button`, …) and a library
of components you compose from them. Everything richer — an interactive **chart**, a sortable **data grid**, a
**map** — is a **foreign control**: a self-contained widget implemented once in JavaScript and described to the app
by a **`control` block**.

A `control` block is the widget's **contract**. It declares the widget's name, the typed **props** it accepts, the
**events** it emits, and the **shape types** its data must take. You write the block in Osy# alongside your app; the
compiler then type-checks every use of the control — so `Chart(data: …)` is verified against the control's declared
props exactly like a call to any native component, and a wrong-shaped projection is a compile error, not a runtime
surprise.

A control is always **100% owned by your app**: the platform ships none. You obtain (or write) a widget's small
adapter module, declare its contract, and reference it from your pages.

## Signature      {#signature}
```osy title="the contract block — props in, events out" syntax
control <Name> {
  contractVersion "<x.y>"           // the adapter interface this control targets
  version "<x.y.z>"                 // the control's own version (its prop/event schema)
  participation <headless|hookable|opaque>   // REQUIRED — what the platform hands this control

  props {
    <Type> <name>;                  // required by default
    [Values(a, b, c)] <Type> <name> = <default>;   // a closed value set + a default → optional
    <Type>? <name>;                 // nullable → optional
  }
  events {
    <name>(<Type> <param>, …);      // an event the control emits, with a typed payload
  }
}
```

The prop and event **types are the control's OWN shape types** — ordinary `class` declarations that live next to the
`control` block, never your app's entities:

```osy title="the control's own shape types, and why every field is public"
class Point  { public decimal X; public decimal Y; }
class Series { public string Label; public Point[] Points; }
```

Declare those fields **`public`**. A class field is private by default, and the app builds these shapes at its call
site with an object initializer (`new Series { Label = … }`) — which a private field refuses.

## Description    {#description}

### When do I need a control instead of a component?   {#why}
The platform deliberately ships **few native building blocks** and keeps richness in two places: a library of
components you compose from those blocks, and **foreign controls** for anything that needs bespoke rendering or a
third-party library. A control is the right tool when a widget:

- wraps a **third-party library** (a charting engine, a mapping SDK, a rich data grid), or
- needs **imperative, canvas- or SVG-level rendering** that composition from native blocks can't express.

Widgets that are **near-universal and security-sensitive** (Markdown rendering, whose sanitization is
correctness-critical) are native primitives instead — you don't ship those as controls. Everything else bespoke is a
control.

### What a `control` block declares — versions, participation, props   {#the-control-block}
A `control` block declares four things:

- **`contractVersion`** — the version of the adapter interface the control's implementation targets. The client
  refuses to load a control whose `contractVersion` it doesn't support, showing a placeholder instead of a broken
  mount.
- **`version`** — the control's *own* version, which moves as its author evolves its props and events. This is
  independent of `contractVersion`.
- **`participation`** — **required.** How much the control cooperates with the app's design tokens and layout, and
  with it what the platform hands the control: a `headless` or `hookable` control renders through your theme and may
  be given a credentialed channel to your app's own data, while an `opaque` one gets neither. There is no default,
  because an omission would decide that for you. One of three rungs:
  - **`headless`** — the control computes its geometry/state and the platform renders it through your **theme
    tokens**, so it inherits your colors, spacing, and dark mode automatically. Best consistency. *Preferred.*
  - **`hookable`** — the control renders its own DOM but exposes CSS-variable hooks, so it can partially adopt your
    tokens.
  - **`opaque`** — the control renders everything itself; a self-contained visual island with no token participation.
- **`props`** and **`events`** — the typed inputs the control accepts and the typed signals it emits (below).

### Props — the control's typed inputs    {#props}
Each prop is a typed field. Prop types are the control's own shape types (or scalars) — never your app's entities,
which keeps a control **reusable across apps**.

- A prop is **required** unless it has a default value or is declared nullable (`Type?`). Omitting a required prop is
  a compile error.
- **`[Values(a, b, c)]`** constrains a prop to a **closed set** of values; passing anything outside the set is a
  compile error (a typo is caught, exactly like an enum member).
- A **default** (`kind = "line"`) makes the prop optional and supplies the value used when a call omits it.

### Events — the control's typed outputs    {#events}
An `events { … }` block declares the signals the control emits, each with a **typed payload**. An app binds a handler
to an event using the **same syntax as a native component's event handler** — a control is just another render node
that emits events, and the action it binds is one of your app's own actions.

Two rules for the payload:

- **Bind the NAME of an action whose parameters match the event's payload.** The control emits a positional payload
  (`host.emit('rowSelected', …)`), which the platform maps onto your action's parameters **in order** — their names
  are yours to choose. So `rowSelected(T row)` binds to `action SelectRow(User u)`. The types must match, and the
  action may not declare more parameters than the event emits; declaring fewer is fine, and ignores the rest.
  A handler is a bare action name, never a lambda: `rowSelected: SelectRow`, not `rowSelected: id => SelectRow(id)`.
- **Emit the ROW, not a display position, when the control reorders.** A control that sorts, filters, or paginates
  changes the *display* order of rows, so a positional index no longer lines up with the data your app passed in.
  Declare such an event to carry the row itself — `rowSelected(T row)` on a generic control, or a named entity — and
  the handler receives that record: `action OpenUser(User u) { … }`. A positional index is only safe for a control
  that never reorders its items (e.g. a chart point).

  In the shim, emit the row **exactly as you received it**: `host.emit('rowSelected', row)`, where `row` is one of the
  records that arrived in your rows prop. You send the flattened record; your app's action receives the record as one
  of its own rows, read live — so a handler still sees the current values even if something changed the row after you
  were handed your props.

### Typing a control to the app's own entity — `DataGrid<T>`    {#generics}
A control can be **generic** over the app's own type: `control DataGrid<T> { … }`. The type parameter `T` binds at the
call site — inferred from the collection argument — so the app hands its **live entity list straight in** with no
projection copy, and the compiler type-checks the columns, the other props, and the event payloads against the app's
own entity:

```osy title="a generic control, and a call site" test app=ui-controls-grid
[Principal] entity User {
  [Required] string Email;
  security { allow create, read, update when IsAuthenticated; }
}

// The control's OWN shape. `public` matters: a class field is private by default, and the call site below
// builds one with an object initializer — a private field is not reachable from there.
class GridColumn {
  public string Key;
  public string Label;
}

control DataGrid<T> {
  contractVersion "1.0"
  participation headless
  props {
    T[] rows;                 // the app passes its live list — T binds to the entity here
    GridColumn[] columns;     // the control's OWN shape (a field key + a label)
  }
  events {
    rowSelected(T row);       // the row itself, not a display index
  }
}

[Page("/users")] [Render(CSR)]
component UsersPage() {
  var users = User.ToList();
  render {
    DataGrid(                 // T is inferred = User
      rows: users,
      columns: [ new GridColumn { Key = "Email", Label = "Email" } ],
      rowSelected: OpenUser
    );
  }
  action OpenUser(User u) { /* the row itself — no lookup to do */ }
}
```

**What this example needs to actually run:** the control declaration above is the *contract*; a `control` that is
rendered also needs a **bundle registered and pinned** (`osy control add …`, which writes `osyrin.lock`). Without one
the compile refuses the render — see [Pinning a kit version (using Ui@2)](https://osysharp.com/reference/ui/kit-versioning/). The fence is compiled here with a stand-in package,
because a markdown fence has no `.js` to point at.

The control declares only its *own* shapes (`GridColumn`) and the type parameter — never the app's types — so it
stays app-independent, yet every call site is checked against your real entity.

**Responsive & touch live in the shim, driven by your tokens.** A control that adapts to screen size (a grid that
renders as a table on wide screens and cards on narrow ones, with touch-sized hit targets) implements that *mechanic*
itself — but reads the **values** (the breakpoint, the tap-target floor, density) from your app's design tokens via
`host.tokens`. So the look and the responsive thresholds stay in your design system, consistent across controls; the
shim only carries the imperative rendering.

**Declarative layout — you shape both views from the call site.** A well-designed control exposes its layout as typed
props so the *app* controls how each view reads, without touching the shim. A data grid, for example, gives each
column layout hints and takes a whole-grid density; the shim honors them in **both** the table and the card view:

```osy title="layout hints as typed props" test app=ui-controls-columns
[Principal] entity User {
  [Required] string DisplayName;
  [Required] string Email;
  bool IsActive;
  security { allow create, read, update when IsAuthenticated; }
}

class GridColumn {
  public string Key;          // the field to read from each row
  public string Label;        // the column header / the card label
  public string? Align;       // cell alignment: "left" (default) | "right" | "center"
  public string? Format;      // value format: "text" | "number" | "currency" | "date" | "bool"
  public bool? Title;         // in the card view, this column is the card's heading
  public bool? Secondary;     // in the card view, this column is the card's subtitle
}

control DataGrid<T> {
  contractVersion "1.0"
  participation headless
  props {
    T[] rows;
    GridColumn[] columns;
    // The density knob the call site below passes. A prop must be DECLARED to be passable — a closed set,
    // so `density: cosy` is a compile error naming the three that exist.
    [Values(compact, comfortable, spacious)] string density = "comfortable";
  }
  events { rowSelected(T row); }
}

[Page("/users")] [Render(CSR)]
component UsersPage() {
  var users = User.ToList();
  render {
    DataGrid(
      rows: users,
      density: comfortable,                    // a closed set: compact | comfortable | spacious
      columns: [
        new GridColumn { Key = "DisplayName", Label = "Name",  Title = true },
        new GridColumn { Key = "Email",       Label = "Email", Secondary = true },
        new GridColumn { Key = "IsActive",    Label = "Active", Format = "bool", Align = "center" }
      ],
      rowSelected: OpenUser
    );
  }
  action OpenUser(User u) { /* the row itself */ }
}
```

The wide layout renders a table with those alignments and formats; the narrow layout renders one card per row, using
the `Title` column as the card's heading and the `Secondary` column as its subtitle. Nothing here is a platform
mechanism — it is ordinary typed props the control declares and its shim honors, so a different control chooses whatever
layout vocabulary fits it.

### A per-row template — writing a bespoke cell/card in Osy#    {#templates}
Flat props shape a cell; they can't compose one. When you need a **bespoke cell or card** — an avatar next to a name, a
status pill, a two-line identity — a control can accept a **builder template**: you write the row as ordinary Osy# in a
trailing block, and the platform renders it once per datum where the control asks. The block's parameter binds each
row, so the template type-checks against your entity exactly like the rest of your UI:

```osy title="a builder template per row" test app=ui-controls-template
[Principal] entity User {
  [Required] string DisplayName;
  [Required] string Email;
  bool IsActive;
  security { allow create, read, update when IsAuthenticated; }
}

class GridColumn { public string Key; public string Label; }

control DataGrid<T> {
  contractVersion "1.0"
  participation headless
  props { T[] rows; GridColumn[] columns; }
  events { rowSelected(T row); }
}

[Composable] component Badge(bool on) {
  render { Text(on ? "Active" : "Inactive"); }
}

[Page("/users")] [Render(CSR)]
component UsersPage() {
  var users = User.ToList();
  render {
    DataGrid(rows: users, columns: [ new GridColumn { Key = "DisplayName", Label = "Name" } ]) { u =>
      Stack {                                          // u binds to each User row
        Text(u.DisplayName);
        Text(u.Email);
        Badge(u.IsActive);
      }
    }
  }
}
```

The template is **yours** — any component, atom, binding, or action-bound handler works inside it, and `u.Bogus` is a
compile error just like anywhere else. The control decides only **where** each row's content sits (a table cell, a card
body); the platform renders **what** you wrote. This is the escape hatch beyond flat props: a control that supports a
template documents which region it renders it into (a grid, for instance, uses it as the card body on narrow screens
while the table view still follows the column hints).

**Where the datum's type comes from.** On a generic control (above) it is `T`, bound from the argument you passed. On a
control that names its row type outright, it is the element type of the **collection prop** the control declares —
`props { Row[] rows; }` makes the datum a `Row`. Either way the datum has a real type, which is what makes a typo
inside the template a compile error.

A control declaring **two** collection props is refused when you write a template against it: the datum could be a row
of either, and a template that silently type-checked against the wrong rows is worse than being asked. Make such a
control generic so the call site says which rows the template is for.

Two things to know:

- The template is a function of the **row datum** (plus your component's actions). A cell that reads component *state*
  won't re-render when that state later changes — pass what the cell needs as row data, or drive interaction through an
  action.
- A control exposes **one** template (the trailing block). It's the row/card template; richer controls with multiple
  named template regions are a later addition.

### Named slots — one template per column, lane, or field    {#slot-vocabulary}
Some controls accept **more than one** template, and the names aren't fixed by the control: they're decided at the call
site. A data grid takes one template per **column**; a board takes one per **lane**; a form builder one per **field**.
A control says where those names come from with one line:

```osy title="where the slot NAMES come from — one line on the control" syntax
control DataGrid<T> {
  participation headless
  props { T[] rows; GridColumn[] columns; }
  slots from columns.Key            // the slot names ARE the Keys of whatever `columns` is passed
}
```

`slots from <prop>.<member>` names a prop and the member of its elements that spells each slot name — and that is all
the platform learns. It never learns what a column *is*; a board writes `slots from lanes.Id`, a form builder
`slots from fields.Name`, and the same machinery checks all three. Callers then write one `slot <Name> { row => … }`
per name:

```osy title="the call site decides the names, then fills one" syntax
DataGrid(rows: files, columns: [
  new GridColumn { Key = "Name",    Label = "Name" },
  new GridColumn { Key = "Size",    Label = "Size", Align = "right" },
  new GridColumn { Key = "Actions", Label = "" }        // renders no field — it's a place to put buttons
]) { f =>
  Stack { Strong(f.Name); }                             // the default row/card template

  slot Actions { f =>                                   // one named template, for the Actions column
    Pressable(onClick: () => DeleteFile(f.Id)) { Icon(Icons.Trash); }
  }
}
```

**A slot name is checked against the columns that call passes**, so `slot Actons` is a compile error that lists the
names you did pass. Note what this does *not* require: `Actions` is not a field of the row and never could be — a
column that exists to hold buttons is as legitimate as one that shows data, and it gets to be called what it is.

Declaring `slots from` is optional. A control that omits it accepts slot names unchecked — the platform won't invent a
vocabulary it wasn't given. Checking is also skipped where the names can't be read at the call site (a `columns:` built
at runtime rather than written as a literal list), because a name the compiler couldn't read is not the same as a name
that isn't there.

### Calling a control from a page — projecting your data in   {#using}
Call a control like any component. The app **projects its own data into the control's shape** at the call site, and
binds handlers to its events:

```osy syntax
component SalesDashboard() {
  var sales = Sale.ToList();
  render {
    Chart(
      data: sales.Select(s => new Series { Label = s.Region, Points = s.Trend }),
      kind: bar,
      pointSelected: DrillInto
    );
  }

  action DrillInto(int index) { /* … */ }
}
```

This fragment shows only the call. **To run it you also need** the `Chart` control declared (its props and events are
what the call is checked against), the `Series`/`Point` classes it names — with `public` fields, or the projection
above cannot reach them — a `Sale` entity, and a registered bundle for the control. The [Examples](#examples) section
below carries all of that as one compiled unit.

The compiler checks the call site against the control's contract and reports, at compile time:

- an **unknown** prop or event name (`Chart(bogus: …)` — the contract is authoritative);
- a **missing required** prop (`Chart(kind: bar)` with no `data`);
- a **`[Values]`** violation (`kind: circle` when the set is `line, bar, scatter`);
- a **result-type mismatch** — a scalar where the control wants a shape, or the *wrong* shape (a `Point[]` where it
  declares `Series[]`); the projection is checked against the declared prop type;
- an **event handler** whose parameters don't match the event's declared payload (too many parameters; a body that
  reads an undeclared name).

A single-element-vs-list distinction on an otherwise-correct shape is not yet enforced; every other mismatch above is.

### The implementation — the JavaScript shim    {#implementation}
A control is implemented by a **small, framework-agnostic JavaScript module** — the "shim", a thin adapter over a
third-party library (a charting engine, a data grid) or a hand-written widget. It exports one function:

```js
// grid.js — the shim (you bundle it with esbuild into one self-contained module)
export function mount(el, props, host) {
  // el     — the element to render into
  // props  — the app's projected data, prepared by the platform
  // host   — what the platform offers this control (below)
  draw(el, props, host);
  return {
    update(nextProps) { draw(el, nextProps, host); },   // re-flowed when the app's data changes
    destroy()         { /* release listeners / timers / observers */ },
  };
}
```

A control whose start **continues after `mount` returns** — a document to fetch, a chunk to load — also returns
**`ready`**, a promise that resolves once it is running and **rejects when it is not**. The platform fails a control
whose `ready` rejects exactly as it fails a `mount` that threw, so a test's probe reports *never started*, with the
reason, instead of answering from chrome drawn over nothing. Omit `ready` when `mount` is the whole start.

The **`host`** the platform passes in:

- **`host.emit(event, …payload)`** — raise one of your declared events; the platform runs the app's bound handler.
  For `events { rowSelected(T row) }` you call `host.emit('rowSelected', row)` with the record you were handed.
- **`host.tokens`** (present for a `headless`/`hookable` control) — the app's design tokens, so the control renders
  THROUGH the app's theme (and dark mode) rather than hardcoding colors:
  - `host.tokens.cssVar('colors.border')` → `var(--colors-border)` — use it directly in a style (stays live).
  - `host.tokens.get('colors.border')` → the current resolved value — for a decision in JS.
  - An `opaque` control gets no `host.tokens` (it's a self-contained visual island).

Your `mount` **must return `{ update, destroy }`** — the platform calls both, and it checks the shape at the mount, so
a forgotten `return` is reported against the mount rather than surfacing later as a puzzling `update` failure.

The platform wraps every control in an **error boundary** (a throw from `mount`/`update`/`destroy` degrades to a
placeholder — never takes the page down) and a **`contractVersion` gate** (a control whose `contractVersion` this
platform doesn't support shows a placeholder, not a broken mount).

**Never write to DOM the wrapped library owns.** If your shim adapts a library that renders and re-renders its own
nodes — an editor, a virtualised grid, a canvas scene graph — setting a class or an attribute on one of those nodes
works, and then silently stops working: the library redraws, and your change leaves with the node it was on. Nothing
throws and nothing logs; the feature simply does not happen, intermittently, depending on whether a redraw follows.

Use the library's own mechanism for saying *"this node looks like this"* — a decoration, a cell renderer, a class hook
— because that is re-applied on every redraw by definition. Own the DOM you created (`el` and its children); treat
everything the library created as read-only.

### What a prop actually looks like in JavaScript    {#prop-shapes}
A shim reads `props[name]` directly, so it matters exactly what shape arrives. There is one rule, and it is worth
learning once:

> **Containers are flattened. Values are not.**

A **container** is something whose fields a plain JS module could not otherwise reach — a queried row, a query result,
a `class` instance. Each is flattened to a plain object with readable own-properties, all the way down:

| The Osy# prop | What `props[name]` is |
|---|---|
| an entity (`Order`) | `{ id, ...fields }` — `id` is the row's id |
| a list / query (`Order.ToList()`) | an **array of those records** — never a query handle |
| a `class` instance | a plain record; nested classes flatten too |
| a `Collection` (child rows) | **absent** — child rows load through their own query |

A **value** arrives exactly as the runtime holds it. In particular, the exact types stay **boxed objects**, not
primitives — converting them here would destroy the precision they exist for:

| The Osy# type | What you get | Reading it |
|---|---|---|
| `decimal` | an exact decimal object | `total.toString()` — never `Number(total)` |
| `long` | an exact 64-bit integer object | `seq.toString()` |
| `DateTime` / `DateOnly` / `TimeOnly` / `TimeSpan` | the matching exact object | `.Year`, `.toString()` |
| `int` / `double` / `bool` / `string` | a JS number / boolean / string | directly |
| `Guid`, or a reference to another record | a `string` id | directly |
| `Json` / `RichText` | a **string containing JSON**, not an object | `JSON.parse(v)` |
| binary | a `Uint8Array` | directly |

The `Json` row is the one that surprises people: it looks like it should already be an object, and it is not.

**A prop that the call site may omit is genuinely ABSENT.** Only the props an app actually wrote are sent, so a
nullable prop and a prop with a default both arrive as `undefined` when the call leaves them out — **a default is not
delivered**; it is the call site's licence to omit the argument, and the shim supplies the value:

```ts
const density = props.density ?? 'comfortable';   // not defensive — this is the contract
```

The generated types say so: such a prop is declared optional, so reading it without a fallback does not compile.

If a shim wants text, it asks for text (`value.toString()`). The platform does not decide that for you — a control
that renders a total to two places and one that renders it to four are both legitimate, and only the control knows
which it is.

#### Enum fields — the key, plus the word    {#enum-labels}
An enum-typed field keeps its **stored key**, and its display label rides alongside under `$labels`:

```js
row.Status            // 1        — the stored key
row.$labels?.Status   // "Shipped" — the word the app declared
```

Both, and in that order, on purpose. The key is what app code compares against (`o.Status == OrderStatus.Shipped`), so
overwriting it with the word would make that comparison silently false. A shim that wants to show a word reads
`row.$labels?.[key] ?? row[key]`. When a row has no enum fields there is no `$labels` key at all — absent rather than
empty, so "this row has no enum" is distinguishable from "the platform supplied nothing".

### Generated types for your shim    {#typings}
`osy control build` writes a TypeScript declaration file beside each control's bundle —
`model/controls/<name>.control.d.ts` — generated from that control's `control` block by the same compiler that checks
the call site. It is **checked in**, so your editor sees it with no build step and no toolchain, and it needs no
bundler to produce (generating it is pure compilation).

Import it in your shim and the ABI stops being folklore:

```ts
import type { FolderNode, FolderTreeHost, FolderTreeProps, ControlHandle } from './tree.control.js';

export function mount(el: HTMLElement, props: FolderTreeProps, host: FolderTreeHost): ControlHandle<FolderTreeProps> {
  const first: FolderNode = props.nodes[0];
  host.emit('folderSelected', first.FolderId);   // typed from your `events` block
  return { update(next) { /* … */ }, destroy() { /* … */ } };
}
```

What it gives you:

- **`<Name>Props`** — every prop in the shape it actually arrives in (see [[#prop-shapes|What a prop actually looks
  like]]), with the `.osy` spelling in a doc comment beside each one.
- **`<Name>Host`** — with **`emit` typed from your declared events**, one overload each. An event name you never
  declared, or the right name with the wrong number of arguments, is a compile error in your editor rather than a
  silence at every layer. This is the single most valuable thing on the page: that exact drift has shipped before.
- **The ABI types** (`TokenScope`, `SlotHandle`, `ControlHandle`) pinned to your control's `contractVersion`. They
  ride with the declarations instead of coming from a package, because a package can drift from the platform that
  compiles your call site — which is the skew `contractVersion` exists to prevent.
- **`EntityRow`**, for a generic control — the row shape an app binds `T` to, carrying `id` and `$labels`.

**Do not edit it by hand.** Editing it does not change what the platform sends your shim; it only makes your editor
disagree with the runtime. `osy control build --check` fails when the file on disk differs from what the declaration
would produce, so a stale copy is caught in CI rather than in a browser. Generation is deterministic, so a clean tree
never sees that error.

### If your shim raises an event the control never declared    {#emit-conformance}
The platform checks this in three places, so it is caught wherever you are working:

1. **In your editor**, if your shim is TypeScript — `host.emit` is typed from your `events` block, so a wrong name or
   the wrong number of arguments will not compile. See [[#typings|Generated types for your shim]].
2. **At `osy control validate`** — the bundle is scanned for `emit("name")` and every literal name is checked against
   your declaration. This works on minified output and needs no JavaScript toolchain. A **dynamic** name
   (`emit(kind)`) is skipped rather than guessed at.
3. **At runtime on a dev server** — the control's actual emits are checked against the declaration, including the
   dynamic names the scan could not read, and the real argument count. It warns; it never kills a working control.

An undeclared event is not a style problem. The app can only bind events the control declares, so an undeclared one
is raised into the void — the control believes it reported something and nothing is listening.

### How do I ship a control with my app? — bundle, validate, register   {#packaging}
A control is **vendored into your app** (version-controlled with it), not fetched at runtime — so the running page
stays same-origin. The workflow:

1. **Bundle** the shim with esbuild into one self-contained module (`grid.js`) — this pulls in its third-party deps.
   Either run esbuild yourself, or let [[#building|`osy control build`]] run it for you.
2. **`osy control validate grid.js`** — the app-neutral API check: statically confirms the bundle exports the ABI
   (`mount`), that its sibling `control` block (`grid.osy`) parses and declares a participation rung, and that it's
   within the size cap. It runs no JavaScript, so it checks the surface — not the running behaviour.
3. **`osy control add grid.js`** — registers it into *this* app: the bundle and its `control` block are
   vendored into your project (under `model/` by default, so the block compiles like any model source), and the
   bundle's content hash is pinned in `osyrin.lock`. Add `--source shim.ts` to record what the bundle was built
   FROM, which is what makes it rebuildable below.
4. On the next **compile** the registered package **rides the compile** — uploaded **content-addressed**
   (the server dedups, so an unchanged package re-uploads as a no-op) and served **same-origin** with an immutable
   cache; a package no control references any more is garbage-collected.

### Authoring a shim from an npm library    {#authoring}
You can wrap **any** framework-agnostic JavaScript library in a shim — a charting engine, a data grid, a **tree**. The
library is **bundled INTO** the shim (inlined by esbuild), so the running page loads one self-contained same-origin
module and never fetches from a CDN. The recipe, end to end:

1. **Write the shim in TypeScript**, importing the library and exporting `mount(el, props, host)` (the ABI above). It
   projects `props` into the library, renders the DOM through `host.tokens`, and calls `host.emit(...)` for events.
   **The shim belongs to your app** — source and bundle both live under `model/controls/`, beside the `control` block
   they implement. See the shipped examples in `demo/file-manager/model/controls/`: `grid.ts` (TanStack Table) and
   `tree.ts` (a folder tree over `@headless-tree/core`).
2. **Install the library** and **bundle** with esbuild — one command inlines the library into the shim:
   ```bash
   npm install --save-dev @headless-tree/core esbuild     # in your app, beside its model/
   esbuild model/controls/tree.ts --bundle --format=esm --minify --outfile=model/controls/tree.js
   ```
   `--bundle` pulls the library's code into the output; `--format=esm` matches the loader; `--minify` keeps it under
   the size cap. *You* run this — the platform never runs your build. The library is **your app's** dependency: the
   platform client ships no third-party control code, so nothing is inherited from it.
3. **Validate + register** as above: `osy control validate tree.js` (static ABI check — exports `mount`, its sibling
   `tree.osy` `control` block parses, size cap), then
   `osy control add tree.js --source model/controls/tree.ts` (vendors the bundle + block under `model/`, pins
   the content hash in `osyrin.lock`, and records the source so you never have to run that esbuild line by hand
   again). The next compile ships it.

### Rebuilding a shim — `osy control build`    {#building}
Once a control records a `--source`, one command rebuilds it and re-pins the new hash:

```bash
osy control build            # bundle every control that has a source, re-pin what changed
osy control build --watch    # ...and keep doing it as you edit
```

That replaces the loop of running esbuild, hashing the output, and editing `osyrin.lock` by hand — miss the last step
and the next compile refuses the stale pin. The rebuilt bundle is re-validated before anything is pinned, so a shim
that stops exporting `mount` fails *here*, at build, rather than as a control that renders nothing in the browser.

`build` uses **your** esbuild — the project's own `node_modules/.bin/esbuild` first (so a pinned version wins), then
your `PATH`. Nothing is downloaded or installed on your behalf. If there is no esbuild, `build` tells you how to
install it and how to carry on without it; every other command, `add` included, keeps working with no JavaScript
toolchain at all.

A control registered from a prebuilt bundle simply has no source to rebuild, and `build` leaves it alone.

The `control` block declares the contract the page compiles against; the bundled `.js` is the implementation that
mounts at runtime. Keep the two in sync — a prop/event you add to the block must be honored by the shim, and vice versa.

### The shim's stylesheet — a real `.css` file    {#stylesheet}
A control that needs CSS of its own puts it in a **`.css` file next to the shim** and imports it. `build` inlines the
file as **text**, so the shim receives its contents as a string and injects them once:

```ts syntax
import CONTROL_CSS from './my-control.css';

function ensureStyles(doc: Document) {
  if (doc.getElementById('my-control-styles')) return;
  const style = doc.createElement('style');
  style.id = 'my-control-styles';
  style.textContent = CONTROL_CSS;
  doc.head.appendChild(style);
}
```

TypeScript needs to be told what a `.css` import is — one declaration, once per project:

```ts syntax
declare module '*.css' {
  const css: string;
  export default css;
}
```

**Why text rather than a stylesheet the page links.** A control is served as exactly one artifact — its bundle — so a
separate `.css` emitted beside it would be a file nothing ever loads. Inlining is what makes the import mean something.

**Prefer this to a template literal in the shim.** CSS held in a `` ` ``-quoted string is a string first and a
stylesheet second: a backtick anywhere in it — *including inside a comment* — ends it. An odd number breaks the build
somewhere unrelated; an **even** number builds clean and silently rewrites the CSS, because the text between them stops
being string content and becomes JavaScript. In a `.css` file a backtick is an ordinary character, and your editor
knows what the file is.

Write the CSS against the app's theme tokens (`var(--colors-surface)`, `var(--radius-md, 10px)`) so the control
inherits the app's look and its dark mode, and declare the control's own geometry as [styles — a control's own look knobs](https://osysharp.com/reference/ui/control-styles/) knobs
rather than as literals.

### The edit loop — `osyrin dev` rebuilds and recompiles for you    {#dev-loop}
Keep `osyrin dev` running while you work on a shim. It watches every control source pinned in `osyrin.lock`, and on
each save it **rebuilds the bundle, re-pins its hash, and recompiles the app** into the running server:

```console
✓ FolderTree rebuilt + re-pinned (230717a61074… → 963970d1c350…)
  control rebuilt — recompiling…
✓ recompiled — refresh the page to pick it up
```

Refresh the page and you are looking at your edit. What is gone is the ceremony: bundle by hand, hash the file, edit
the lock, recompile — four steps with a compile error waiting at the end of any one you forgot.

**When `osyrin dev` is not running**, `osy control build --watch` does the same rebuild and re-pin on its own. It
cannot recompile — there is no server to compile into — so it tells you to run `osy compile` when you are ready. Use
it when the dev server has exited (it stops after its idle keep-alive window) or when you are working on a shim
without a server up.

A save that changes nothing the bundler emits — a comment, a reformat — is detected and skipped, so a keystroke-save
does not trigger a compile.

**The browser is not reloaded for you.** After a recompile, refresh the page yourself.

### My control mounted but drew nothing — the dev diagnostics   {#diagnostics}
On a local dev server the platform watches the two failures a shim can produce without erroring, and says so in the
console. A deployed app never shows either — they are for the person writing the shim.

**"mounted but produced no DOM."** The control was handed correct data, threw nothing, and painted nothing. Nearly
always a shim that never started its library's own lifecycle. Checked after the current task drains, so a shim that
fetches before painting is not accused while it is still working.

**"re-entered `host.<method>` N levels deep."** The shim called back into the platform from inside a call the platform
had not yet returned from, and kept going — typically a shim reacting to its own change (its `setState` runs the app's
handler, which re-renders, which updates the shim, which calls `setState`). It recurses until the stack dies, far from
the line that started it. Break the cycle by making the callback asynchronous, or by ignoring a prop update the shim
itself caused.

Rendering many rows is *not* this: calling `host.slot.render(...)` once per row is breadth, and each call finishes
before the next begins. The warning fires only on a call that re-enters its own still-open frame, and only once the
nesting is far deeper than any real layout — so a control that legitimately renders children inside a parent stays
quiet.

Both also appear on the control's entry in `__osy.controls`, alongside the last props that crossed the ABI and the
events it raised — including any it raised that the app bound no handler for, which from the outside looks exactly like
never having emitted at all.

## Examples       {#examples}

Declaring a chart control and its shape types:

```osy title="the control's contract" test app=ui-controls-chart
// `public` on every field: the app builds these at its call site with an object initializer, and a class
// field is private by default.
class Point  { public decimal X; public decimal Y; }
class Series { public string Label; public Point[] Points; }

control Chart {
  contractVersion "1.0"
  version "2.1.0"
  participation headless
  props {
    Series[] data;                              // required
    [Values(line, bar, scatter)] string kind = "line";   // optional, closed set
    string? xLabel;                             // optional
  }
  events {
    pointSelected(int index);
  }
}
```

Using it, with the compiler checking the projection and the handler:

```osy title="projecting the app's data into it" test app=ui-controls-chart
[Principal] entity Analyst { [Required] string Email; }

entity Sale {
  [Required] string Region;
  decimal Amount;
  security { allow create, read, update when IsAuthenticated; }
}

[Page("/sales")] [Render(CSR)]
component SalesDashboard() {
  var sales = Sale.ToList();
  render {
    Chart(
      data: sales.Select(s => new Series { Label = s.Region, Points = [] }),
      kind: bar,
      pointSelected: DrillInto
    );
  }
  action DrillInto(int index) { /* open the region detail */ }
}
```

Mistakes the compiler rejects:

```osy title="✗ the calls the compiler rejects, and what each says" syntax
Chart(kind: bar);              // error: 'Chart' requires prop 'data'
Chart(data: sales, kind: pie); // error: 'kind' must be one of: line, bar, scatter
Chart(data: 42);               // error: 'data' expects Series[] but got int
Chart(data: sales, whatever: 1);            // error: 'Chart' has no prop or event 'whatever'
Chart(data: sales, pointSelected: index => DrillInto(index)); // error: a control event handler must NAME an `action` — write `pointSelected: DrillInto`
Chart(data: sales, pointSelected: DrillInto);   // error (if DrillInto takes 2 params): 'pointSelected' emits 1
```

A **shim** (`grid.js`) — a headless control that renders through the app's tokens and emits an event. It implements a
`control DataGrid<T> { props { T[] rows; Column[] columns; } events { rowSelected(T row); } }`, not the `Chart` above;
it is here because a grid shows the row-payload rule in action. This is a real, framework-agnostic module; a
production one typically wraps a library (e.g. a data grid or chart engine):

```js
export function mount(el, props, host) {
  const draw = () => {
    const table = document.createElement('table');
    // Render THROUGH the app's tokens (falls back gracefully when a token isn't declared):
    table.style.color = host.tokens?.cssVar('colors.onbg') ?? 'inherit';
    for (let i = 0; i < (props.rows ?? []).length; i++) {
      const tr = document.createElement('tr');
      // Emit the ROW, not `i` — the grid sorts, so a display position stops matching the app's data.
      tr.onclick = () => host.emit('rowSelected', props.rows[i]);   // → the app's `action Open(User u)` runs
      for (const c of props.columns ?? []) {
        const td = document.createElement('td');
        td.style.borderBottom = `1px solid ${host.tokens?.cssVar('colors.border') ?? 'currentColor'}`;
        td.textContent = String(props.rows[i][c.key] ?? '');
        tr.appendChild(td);
      }
      table.appendChild(tr);
    }
    el.replaceChildren(table);
  };
  draw();
  return { update(next) { props = next; draw(); }, destroy() { el.replaceChildren(); } };
}
```

### Adding an interaction — e.g. double-click    {#extending}
A control's events are its own contract, so adding a new interaction is a change to the control (which you own — it's
vendored in your app), then a binding in the page. To add double-click that opens a row, three small edits:

```osy title="step 1 — declare the event, carrying the ROW not a position" syntax
// 1. Declare the event on the control — carry the ROW, not a position (the grid sorts):
control DataGrid<T> {
  participation headless
  props  { T[] rows; Column[] columns; }
  events { rowSelected(T row); rowDoubleClicked(T row); }
}
```
```js
// 2. Emit it from the shim — the row exactly as you received it:
tr.onclick    = () => host.emit('rowSelected', row.original);
tr.ondblclick = () => host.emit('rowDoubleClicked', row.original);
```
```osy title="step 3 — bind an action whose parameters take the payload" syntax
// 3. Bind an Osy# action in the page (the payload maps onto its parameters in order):
component Orders() {
  var orders = Order.ToList();
  action Open(Order o) { /* navigate / open a detail */ }
  render { DataGrid(rows: orders, columns: [...], rowDoubleClicked: Open); }
}
```

## See also       {#see-also}
- [component](https://osysharp.com/reference/ui/component/) — the native components a control sits alongside in a render tree
- [Slot (child content)](https://osysharp.com/reference/ui/slots/) — how a control participates in layout via a slot
- [theme tokens](https://osysharp.com/reference/ui/theming/) — the theme tokens a `headless` control renders through
- [styles — a control's own look knobs](https://osysharp.com/reference/ui/control-styles/) — a control's OWN look knobs (`styles { }`), and the two ways an app overrides them
- [commands — the verbs a control accepts](https://osysharp.com/reference/ui/control-commands/) — the verbs a control accepts (`commands { }`), the mirror of its events
- [probe — what a control says about itself](https://osysharp.com/reference/ui/control-probe/) — what a control says about ITSELF (`probe { }`), so a test can ask a control it did not write
- [chunks — assets a control loads on demand](https://osysharp.com/reference/ui/control-chunks/) — assets a control loads on demand (`chunks { }`), so a heavy optional feature costs nothing on the pages that never use it
- [Osysharp.Ui (the UI kit)](https://osysharp.com/reference/ui/kit/) — the versioned component-library model (a control is distributed per-app, not as a kit)
