Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / UI

Osyrin.Ui (the UI kit)

Osyrin.Ui — a bundled set of styled controls (Button, …) plus the layout + tone vocabularies, in scope for every app

The bundled UI kit — ready-made styled controls like `Button`, the shared design-system vocabularies (`Tone`, `Size`) and a starter theme — is in scope for every app with nothing to write: no `using`, no `use`. You reference a kit control just like your own components, and you can fork any control by declaring one with the same name.

stable6 examples compiled by CIuikitcomponentsusing

Summary#

The UI kit — a bundled set of ready-made, styled controls and the shared vocabularies they use — is in scope for every app. There is nothing to write: no using, no use. Drop a kit control straight into a render block:

[Page("/")]
[AllowAnonymous]
[Render(CSR)]
component Home() {
  action Save() { }
  render {
    Stack(align: Align.Center) {
      Button("Save", onPress: Save);
    }
  }
}

Button is a kit control — it isn't declared in your app, and it needs no import. (align: Align.Center is built in too, and always was. See layout primitives.)

Button is the one name the kit takes over. The renderer also has a lower-level Button ATOM — onClick, no label — and the kit's control shadows it. If you specifically want the primitive, name it Osyrin.Button(…); that Osyrin. prefix reaches any atom whose name a control shadows. You will rarely want it.

Signature#

// nothing — the kit is in scope for every app.
// Optional, and only to PIN a version, in app.osy:
app MyApp { use Osyrin.Ui@2; }

The kit contributes three things to your app:

  • Controls — styled components you reference by name.

    Input and action

    Every value: below is a two-way binding, and it takes one of exactly three things: an assignable field of your component, a field of an entity row, or a Binding<T> you were handed. A field of a class value is refused — there is no row to write the edit through — so a list a person can EDIT is a list of entities. See [[ui-component#two-way]].

    Button(label, onPress, tone, size, disabled, canPress, whenDenied)The pressable action. One filled Primary per view; Ghost for the rest. canPress: takes a declared policy — the button greys out and says whenDenied when it does not hold for the caller.
    Field(label, value, placeholder, hint, error, type, disabled)Labelled text input. value is a two-way binding, so typing writes straight to what you bound. The label also NAMES the input for a screen reader (and for Ui.Fill), and an error marks it invalid. type: is the kind of text a browser knows about — "password" masks what is typed, "email" / "tel" change the keyboard a phone offers. It is a CLOSED set, checked at compile time when written as a literal: text, password, email, number, tel, url, search, date, time, datetime-local, month, week, color, range. A checkbox, a radio group, a file picker and a submit button are their own controls (Checkbox, Dropdown, Upload, Button) rather than a type: — each keeps its state somewhere other than value, so the binding would be wired to nothing.
    NumberField(label, value, min, max, unit, prefix, placeholder, hint, error, disabled)A whole number. Binds an int directly, so what is typed is stored AS a number — there is no string draft to parse. min/max are the field's RANGE and reach the input element itself, so the browser enforces them; prefix prints before the box (a currency symbol) and unit after it: NumberField("How often", value: draft.EveryDays, min: 1, unit: "days").
    DecimalField(label, value, min, max, unit, prefix, placeholder, hint, error, disabled)The same control over a decimal — money, a measurement, a rate: DecimalField("Price", value: line.Price, min: 0m, prefix: "£"). Two controls rather than one generic because a generic cannot do arithmetic over a number type it does not know.
    DatePicker(label, value, hint, error, disabled)A date field that opens a real month calendar — not <input type=date>, which cannot be styled and looks different in every browser. / move a month, the chosen day is a filled circle, today carries a ring, and days from the neighbouring months are muted. The backdrop closes it: DatePicker("Due", value: draft.DueOn).
    TimePicker(label, value, minuteStep, use12Hour, hint, error, disabled)A time field that opens hour and minute columns. minuteStep is 5 by default, so quarter past is two clicks rather than fifteen; use12Hour switches the trigger to h:mm tt: TimePicker("Opens", value: draft.OpensAt, minuteStep: 15).
    DateTimePicker(label, value, minuteStep, hint, error, disabled)The calendar with a time strip under it, over one DateTime — a start, a deadline, a moment. Click the 14 : 30 strip to open the columns: DateTimePicker("Starts", value: draft.StartsAt). For a calendar day with no time of day, use DatePicker.
    Dropdown(label, value, options, placeholder, disabled)A styled select — (label, value, …) like every other field control here. Bind an enum and there is nothing else to say, because the options are its members: Dropdown("Status", value: order.Status). Give it options: and it picks from any rows instead, with your own template per row: Dropdown("Lead", value: project.Lead, options: people, placeholder: "Choose a lead…") { p => Avatar(p.Initials); Text(p.Name); }. label is required and is what the control is announced and addressed by; only you know whether it means "Status" or "Order status". Clicking outside closes it, and so does Escape.
    Option(label, selected)One row of a dropdown panel — the tick, and what a screen reader is told. Its content is the default slot, so Option(selected: …) { Avatar(…); Text(…); } draws your own row without losing the part that is not visible. Build your own picker from it.
    Checkbox(label, value, onToggle, disabled)On/off in a form. The label is part of the hit target, and is also what it is announced as.
    Switch(label, value, onToggle, disabled)On/off that takes effect now rather than on save.
    ThemeToggle(glyph, label)Flips light/dark and remembers the choice. glyph is the button FACE (an emoji by default); label is what it is announced as — some apps say "Theme", some "Appearance".

    Structure

    Card(title, subtitle)A raised surface. The block you wrap is its body; slot actions { … } puts controls in the header.
    PageHead(title, subtitle)A page's heading row — title left, your actions right, hairline under.
    Toolbar()A strip of controls with consistent spacing. Add a Spacer() to split left from right.
    ListRow()One row of a list, with the between-rows hairline and rhythm.
    Tabs() · Tab(label, selected, onPress, disabled)A tab strip. You own which tab is current.
    Divider(vertical) · Spacer()A hairline, and a flex spacer that pushes what follows to the far end.

    Type roles — say what the text is; the size follows.

    PageTitle(text) · CardTitle(text)A page's h1, and a section heading.
    TextAreaField(label, value, placeholder, hint, error, rows, disabled)A labelled MULTI-LINE input — a reason, a note, a description. Everything Field does, plus rows for how tall it starts. Named for the kit's convention (NumberField, DecimalField); a control called TextArea would shadow the atom of that name.
    TextLink(label, to, tone)A navigation link — to is the route, label is what the reader sees. The Link ATOM takes the HREF as its first positional, which is the shape HTML habits get backwards.
    SectionLabel(text)The small uppercase label above a group.
    FieldLabel(text) · Hint(text)A field's label, and secondary/help text.
    Strong(text) · Metric(text)Emphasised body text, and a big stat number.

    Status and feedback

    Badge(label, tone)A tinted status pill, sized to sit in a table cell. label is a string, so an enum needs .LabelBadge(o.Status.Label, tone: Tone.Success). Its block replaces the label when you give it one, and inside a block the enum needs nothing: Badge(tone: Tone.Success) { Text(o.Status); }, because it is Text that turns an enum into its [Label] words.
    Alert(message, title, tone)An inline message banner — a validation summary, a warning above a destructive form.
    EmptyState(title, body)What a list looks like before it has anything in it. Wrap a call to action in it.
    Avatar(initials, src, alt, size)Image when there is one, initials when there is not.
    Spinner(size)Indeterminate work. Prefer a skeleton { } block for a first data read.
    ProgressBar(percent, tone)Determinate progress, 0-100. Announces its value (role="progressbar" + aria-valuenow).
    Skeleton(shape) · SkeletonText()Stand-ins for content still loading — put them in a skeleton { } block.

    Overlays — render these behind an if.

    Dialog(title, onDismiss, subtitle)A modal. Brings its own scrim; slot actions { … } is the footer.
    Menu() · MenuItem(label, onPress, tone, disabled)A floating menu panel and its rows.
    Backdrop(onDismiss) · Scrim(onDismiss)Click-outside-to-close, invisible or dimmed. This is the mechanism behind every popover — pair it with your own panel.
    Toast(message, tone)A transient message pinned to the corner. You own the timer.

    Data

    DataGrid(rows, columns, rowSelected)A sortable, resizable, templatable table that becomes a card list on a narrow screen. Each column names its value with a selector over the row, so a rename is a compile error rather than a blank cell. A column's Value selector may read the page's own state as well as the row. Fill slot <Column> { row => … } to template a cell; set Sortable = false on a column that renders only through its template, so its header stops offering a sort that would reorder nothing.
    IconButton(onPress, label, tone, size, disabled, canPress, whenDenied)A square icon-only action — a row action, a toolbar affordance. The glyph is a slot, since icons are yours. Pass label: an icon has no text to be announced by, and only you know whether this one means "Save" or "Save draft". Reflects a policy exactly as Button does.

    The APP SHELL is not in this list. A rail, a work area and open-document tabs ship as a sample you copy — osy docs sample admin-shell — rather than as controls you reference. A shell is one navigation model rather than a general one, and it is where an app's identity lives, so the source is yours from the first day. The app shell (rail, work area, tabs) is the page for it.

    Each one is ordinary Osy# — atoms, style props and Slot — so you can read them to learn the house style, and fork any of them by dropping a same-named component in your own source.

    The kit's source ships inside the binary rather than in your project, so osy kit is how you read it:

    $ osy kit              # every control's signature — the whole catalogue, one line each
    $ osy kit Field Card   # what those two are FOR, with a call you can copy
    $ osy kit --for "a labelled input"   # …when you cannot name the one you want
    $ osy kit Card         # Card's own declaration in full — the source you read to learn it, or fork
    $ osy kit Card --file  # …and the REST of the file it lives in: its file-mates, and the header's reasoning
    $ osy kit --atoms      # the renderer's own primitives, which need no `using`
    $ osy kit --tokens     # the starter theme's design tokens — every one, by group, with its value
    $ osy kit --json       # the same, for tools

    Both the listing and each signature are read out of the kit's own source, so they always describe the kit you actually have.

    This kit is not the only one. osy kit closes with the other kits the platform ships — what each is for, the controls it adds and the one line that turns it on — because a control you do not know exists is one you cannot choose. osy kit --names names the kit each set belongs to, and osy kit <Control> finds a control in any of them, telling you which kit it is in. They are listed from what the platform can actually resolve, not from what happens to be documented, so a kit with no reference page still appears.

  • Vocabularies — the design-system enums a styled control reads: Tone, Size, Step. These are yours to extend (a brand adds Tone.Success).

  • A starter theme — a complete token vocabulary, so the kit's own controls and your pages are written in names rather than pixels. It covers colour (Bg, Surface, Border, Primary, … with a light/dark mode map, so an app themes its whole page for free), radii, spacing, type (Font, FontSize, FontWeight), sizes (ControlMd, IconMd, AvatarMd, …), shadows, motion, z-layers and breakpoints. See theme tokens.

Those tokens are in scope for your components too, not just the kit's — Bg = Surface, FontSize = Body, H = ControlMd resolve in a page you wrote, with no theme of your own. Declaring a theme is then how you override: re-declare a token in the same group (Radius { Md = "4px"; }) and yours wins.

Description#

A kit control is an ordinary component — you call it, pass its arguments, and it arg-checks exactly like a component you wrote yourself. Button takes a label, an onPress handler, and tone / size variants:

[Page("/checkout")]
[AllowAnonymous]
[Render(CSR)]
component Checkout() {
  action Pay() { }
  render {
    Button("Pay now", onPress: Pay, tone: Tone.Primary, size: Size.Lg);
  }
}

Greying out a button the user may not use — canPress#

A control that acts on data usually acts on data the caller may not be allowed to change. canPress: takes a declared policy and greys the button out when it does not hold, with whenDenied as the reason — so the person looking at it learns why instead of pressing and being refused:

[Principal]
entity Member { string Email; bool IsOwner = false; }

policy IsOwner => Member.Any(m => m.Email == user.Email && m.IsOwner);

[Page("/team")]
[Render(CSR)]
[AllowAnonymous]
component TeamPage() {
  action Invite() { }
  render {
    Button("Invite a member", onPress: Invite, tone: Tone.Primary,
           canPress: IsOwner, whenDenied: "Only an owner can invite members.");
  }
}

It only ever reflects: the server enforces the rule, and no button is a gate. IconButton takes the same pair.

The Tone and Size vocabularies are the variant dimensions a styled control exposes, and you can use them in your own components too:

[Composable] component Badge(Tone tone) {
  render { Text("badge"); }
}

Forking a control#

The kit is forkable: to change how a control looks or behaves, declare a component with the same name in your own source. Your version shadows the kit's everywhere in your app — including inside other kit components used by reference (a kit Table that renders Button renders your Button) — with no extra step:

// This app's own Button — replaces the kit's for every reference in this app.
component Button(string label, Action onPress) {
  render { Pressable(label, onClick: onPress); }
}

A control of that name shadows the kit's too, and the same way — so an app whose grid is a foreign shim (control DataGrid<T> { … }) keeps its own grid, and its call sites are checked against the shim it declares. One name may only be declared once in your own source, though: a component and a control sharing a name is a compile error naming both files, because between two of your own declarations there is nothing to prefer.

The fastest way to start from the kit's exact source is osy get, which vendors a control into ui/lib/ as your own source — the control's own declarations, verbatim, under a short header recording where they came from:

$ osy get ui/hint        # one control → ui/lib/Hint.osy
$ osy get ui/*           # every control

You get the control, not the file it lives in. The kit groups components per file for the reader, and the two groupings want opposite things from a fork: Hint shares a file with six other type roles you almost certainly do not want to own, while a DataGrid is useless without its cells. So the unit is the control, and what travels with it is reported:

$ osy get ui/datagrid
✓ Forked DataGrid → ui/lib/DataGrid.osy
  ↳ also GridColumn (the class only DataGrid uses)
  ↳ also HeaderCell ([Internal] — part of DataGrid)
  ↳ also FixedCell ([Internal] — part of DataGrid)
  …

Everything your fork does not take still resolves to the kit's. A vendored PageHead goes on rendering the kit's PageTitle and Hint — resolution is by name, and only the names you vendored are shadowed. That is what makes a narrow fork safe: you own the one control you meant to change, and the rest keeps improving.

The vendored file is picked up by your ui/**/*.osy glob automatically, so it shadows the kit's with no manifest edit. A forked control should stay call-compatible (keep its public parameters) so existing call sites — and kit components that call it — keep resolving. Only your app is affected; the kit's original is untouched for every other app.

Examples#

A small form using a kit Button and the layout vocabulary:

[Page("/contact")]
[AllowAnonymous]
[Render(CSR)]
component Contact() {
  string email = "";
  action Send() { }
  render {
    Stack(gap: 3, align: Align.Center) {
      Text("Get in touch");
      Input(value: email, placeholder: "you@example.com");
      Button("Send", onPress: Send, tone: Tone.Primary);
    }
  }
}

See also#

Related

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…

theme tokens

A `theme` block names your app's design tokens — colors, spacing, radii, and more — as reusable values. A token can…

layout primitives

The built-in layout primitives and how they arrange children. `Stack` stacks children in a column, `Row` lays them in a…

accessibility

Tags already give an element its role, focus and keyboard behaviour. The semantic props say the rest: `role:` for a…