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#
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:
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#
When do I need a control instead of a component?#
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#
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 whosecontractVersionit 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 ofcontractVersion.participation— required. How much the control cooperates with the app's design tokens and layout, and with it what the platform hands the control: aheadlessorhookablecontrol renders through your theme and may be given a credentialed channel to your app's own data, while anopaqueone 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.
propsandevents— the typed inputs the control accepts and the typed signals it emits (below).
Props — the control's typed inputs#
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#
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. SorowSelected(T row)binds toaction 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, notrowSelected: 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), whererowis 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>#
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:
[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). 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:
[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##
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:
[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#
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:
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:
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#
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:
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 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 nodata); - a
[Values]violation (kind: circlewhen the set isline, bar, scatter); - a result-type mismatch — a scalar where the control wants a shape, or the wrong shape (a
Point[]where it declaresSeries[]); 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#
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:
// 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 */ },
};
}The host the platform passes in:
host.emit(event, …payload)— raise one of your declared events; the platform runs the app's bound handler. Forevents { rowSelected(T row) }you callhost.emit('rowSelected', row)with the record you were handed.host.tokens(present for aheadless/hookablecontrol) — 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
opaquecontrol gets nohost.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#
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:
const density = props.density ?? 'comfortable'; // not defensive — this is the contractThe 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
An enum-typed field keeps its stored key, and its display label rides alongside under $labels:
row.Status // 1 — the stored key
row.$labels?.Status // "Shipped" — the word the app declaredBoth, 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#
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:
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.osyspelling in a doc comment beside each one.<Name>Host— withemittyped 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'scontractVersion. 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 skewcontractVersionexists to prevent. EntityRow, for a generic control — the row shape an app bindsTto, carryingidand$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#
The platform checks this in three places, so it is caught wherever you are working:
- In your editor, if your shim is TypeScript —
host.emitis typed from youreventsblock, so a wrong name or the wrong number of arguments will not compile. See [[#typings|Generated types for your shim]]. - At
osy control validate— the bundle is scanned foremit("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. - 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#
A control is vendored into your app (version-controlled with it), not fetched at runtime — so the running page stays same-origin. The workflow:
- 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. osy control validate grid.js— the app-neutral API check: statically confirms the bundle exports the ABI (mount), that its siblingcontrolblock (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.osy control add grid.js— registers it into this app: the bundle and itscontrolblock are vendored into your project (undermodel/by default, so the block compiles like any model source), and the bundle's content hash is pinned inosyrin.lock. Add--source shim.tsto record what the bundle was built FROM, which is what makes it rebuildable below.- 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#
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:
- Write the shim in TypeScript, importing the library and exporting
mount(el, props, host)(the ABI above). It projectspropsinto the library, renders the DOM throughhost.tokens, and callshost.emit(...)for events. The shim belongs to your app — source and bundle both live undermodel/controls/, beside thecontrolblock they implement. See the shipped examples indemo/file-manager/model/controls/:grid.ts(TanStack Table) andtree.ts(a folder tree over@headless-tree/core). - Install the library and bundle with esbuild — one command inlines the library into the shim:
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--bundlepulls the library's code into the output;--format=esmmatches the loader;--minifykeeps 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. - Validate + register as above:
osy control validate tree.js(static ABI check — exportsmount, its siblingtree.osycontrolblock parses, size cap), thenosy control add tree.js --source model/controls/tree.ts(vendors the bundle + block undermodel/, pins the content hash inosyrin.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#
Once a control records a --source, one command rebuilds it and re-pins the new hash:
osy control build # bundle every control that has a source, re-pin what changed
osy control build --watch # ...and keep doing it as you editThat 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#
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:
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:
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 knobs
rather than as literals.
The edit loop — osyrin dev rebuilds and recompiles for you#
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:
✓ FolderTree rebuilt + re-pinned (230717a61074… → 963970d1c350…)
control rebuilt — recompiling…
✓ recompiled — refresh the page to pick it upRefresh 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#
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#
Declaring a chart control and its shape types:
// `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:
[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:
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 1A 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):
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#
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:
// 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); }
}// 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);// 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#
- component — the native components a control sits alongside in a render tree
- Slot (child content) — how a control participates in layout via a slot
- theme tokens — the theme tokens a
headlesscontrol renders through - styles — a control's own look knobs — a control's OWN look knobs (
styles { }), and the two ways an app overrides them - commands — the verbs a control accepts — the verbs a control accepts (
commands { }), the mirror of its events - probe — what a control says about itself — 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 — assets a control loads on demand (
chunks { }), so a heavy optional feature costs nothing on the pages that never use it - Osyrin.Ui (the UI kit) — the versioned component-library model (a control is distributed per-app, not as a kit)