Osy#betaRun it on Osyrin: osyrin.com

Why Osy# · Chapter 08 · The UI model

The screen is written in the same language as the rules.

A screen is a component, and a component is ordinary Osy#: its state is fields, its behaviour is actions, its structure is a render block in which if and foreach are the language's own, and its data can be an expression over the application. Learn this small component and you can read every screen on this page.

the board example app · model/tally.osyverbatim — this file compiles
[Route("/tally")]
component Tally() {
  int count = 0;

  action Add() { count = count + 1; }

  render {
    Stack(gap: 2) {
      Text($"{count} so far");
      if (count == 0) { Text("Press the button to start."); }
      Button("Add one", onPress: Add);
    }
  }
}

In practiceA screen is a component: fields, actions and a render block, in the language the rules are in.

01

The screen is a component

A page that counts button presses, whole. The basic shape of a screen is all here, and every part of it is a construct you have already met in this book.

Platform.Tools.SiteGen/content/apps/board/model/tally.osyverbatim — this file compiles
[Route("/tally")]
1component Tally() {
2  int count = 0;

3  action Add() { count = count + 1; }

4  render {
    Stack(gap: 2) {
      Text($"{count} so far");
5      if (count == 0) { Text("Press the button to start."); }
      Button("Add one", onPress: Add);
    }
  }
}

1

A component is a piece of screen. The attribute above it gives this one a route, which makes it a page; a component without one is called from other components, the way a function is.

2

A field is local state. An ordinary declaration; the screen holds it while the page is open.

3

An action is behaviour the screen starts. It is a function; assigning to a field is how the screen changes.

4

The render block describes what the component presents, by calling controls: a Stack, two Texts, a Button bound to the action.

5

Control flow is the language's own. An if shows a line while it holds; a foreach repeats one per item. What the block presents follows the fields.

State is fields, behaviour is actions, structure is a render block. Components compose by calling one another, which the next section shows, and the rest of this page adds one idea at a time to exactly this shape.

02

Appearance is named, and layout is part of the call

A screen has to look like something, and the look has to be one decision rather than a hundred. A theme gives names to visual decisions: colours, radii, type sizes, lengths and breakpoints. A colour can carry a light value and a dark value on the same line.

demo/kanban/model/look/theme.osyverbatim — this file compiles
theme Studio {
  Colors {
1    Bg        = Modes.Of(light: "#F6F7F9", dark: "#0E1116");
    OnBg      = Modes.Of(light: "#16181D", dark: "#E6EAF0");
    Surface   = Modes.Of(light: "#FFFFFF", dark: "#171B21");
    Border    = Modes.Of(light: "#E3E6EA", dark: "#2A2F37");
    Subtle    = Modes.Of(light: "#6B7280", dark: "#8A94A6");
2    Accent    = "#4F46E5";
    OnAccent  = "#FFFFFF";
    LaneBg    = Modes.Of(light: "#EEF0F4", dark: "#1C212A");
  }
3  Radius   { Card = "12px"; Control = "8px"; Pill = "999px"; }
  FontSize { Caption = "12px"; Body = "14px"; Section = "16px"; Display = "26px"; }
  FontWeight { Medium = "600"; }
  Length     { LaneW = "300px"; }
4  Breakpoints { Cozy = 900; }
}
1

Both modes on one line. The two values are one declaration, so they cannot drift, and adding a dark mode to an app is editing the lines that already exist.

2

A token with one value is the same in both modes. The brand colour usually is.

3

Names for more than colour. Radii, type sizes, weights and lengths are tokens too, so rounded: Radius.Card is a decision made once.

4

A width with a name. §3 branches on it.

The names are then used where the structure is declared. Spacing, colour, radius and size are typed arguments on the control call, so the compiler checks a layout the way it checks the rest of the program. Here is a component that other components call: a lane on a board.

demo/kanban/model/board/board.osyverbatim — this file compiles
1[Composable] component Column<T>(T[] cards, string title, int count) {
  render {
2    Stack(gap: 2, p: 2, w: Length.LaneW, bg: Colors.LaneBg, rounded: Radius.Card) {
      Row(gap: 2, align: Align.Center, px: 1) {
        Text(title, fontSize: FontSize.Section, fontWeight: FontWeight.Medium);
        Text(count, fontSize: FontSize.Caption, color: Colors.Subtle, px: 2, py: 1, bg: Colors.Surface, rounded: Radius.Pill);
      }
      Stack(gap: 2) {
        foreach (var c in cards) {
3          Slot(c);
        }
      }
    }
  }
}
1

A component with parameters, generic over what it holds. A lane knows it stacks things; it does not know what a card looks like.

2

Layout is part of the call. Every argument is typed and every value is a token, so a typo is an error at the position it happened.

3

The caller supplies the child. The page that uses a lane decides what goes in it, so one lane serves every board.

A theme names the decisions; a call places them. Nothing else has to agree with either, because the compiler already checked both.

03

The width is a value, so structure can branch on it

A board has lanes; on a wide screen they sit side by side, on a phone they stack. In Osy# the available width is a value the render block can read, and a breakpoint from the theme is a name for one, so choosing a structure is an ordinary if.

demo/kanban/model/board/board.osyverbatim — this file compiles
[Composable] component Board<T>(T[] lanes) {
  render {
1    if (Layout.AtLeast(Cozy)) {
2      Row(gap: 3, align: Align.Start, overflowX: "auto", pb: 2, role: UiRole.Region, label: "Board") {
        foreach (var l in lanes) { Slot(l); }
      }
    } else {
      Stack(gap: 3, role: UiRole.Region, label: "Board") {
        foreach (var l in lanes) { Slot(l); }
      }
    }
  }
}
1

A breakpoint is a branch. Wide enough and the lanes are a Row; narrower and they are a Stack. Two structures from one source, and because it is control flow, a phone can get genuinely different markup.

2

Accessibility is in the declaration, on the element that is the region, in a vocabulary the compiler checks.

Responsive structure is the same control flow as everything else in the block. Move the breakpoint in the theme and the branch reads the new number.

04

A live expression ties the screen to the application

So far the tally's state has been its own. A real screen shows application data: rows, and where a workflow has got to. In Osy# that is a live var: an expression over the application that stays current. A plain field is local state; a live expression is the application's, seen from this screen.

demo/kanban/model/board/board.osyverbatim — this file compiles
component BoardPage() {
1  live var lanes = Lane.Include(l => l.Items).OrderBy(l => l.Ordinal).Take(50);

  on mount { SeedBoard(); }

2  string moved = "";

3  action Move(Card card, Lane from) {
    var next = lanes.Where(l => l.Ordinal > from.Ordinal).FirstOrDefault();
    if (next == null) { return; }
    moved = card.Title + " → " + next.Name;
    card.Lane = next;
  }

  action Save() { UnitOfWork.Commit(); }

  Card? picked;

4  live var moves = CardFlow.For(picked).Transitions;
1

An entity query, live. It runs on the server under the rules chapter 05 declared, and live keeps it true: a card somebody else moves lands in this list as it happens.

2

A plain field beside it: the tally's kind of state, local to the screen.

3

An action over application data. Assigning card.Lane changes the row; the live query above already reflects it.

4

Workflow state, queried the same way. The workflow of chapter 03 knows which transitions are legal for the picked card and who may take them, so the screen asks it, and the buttons it renders follow.

The screen is not a second description of the application. It is an expression of the application's data, rules and workflow state, in the same program.

05

The frame around every page is a component too

An application's chrome, its brand, navigation tree, signed-in person, search and a page's own actions, is one declaration handed to a shell. A shell is a supplied Osy# component: it takes that declaration and its slots and arranges them for a kind of application, and the same application can choose a different arrangement by naming a different shell.

A wholesale-distribution back office in a sidebar shell: six navigation sections, purchase orders open
SidebarEnterprise and operational

Persistent, hierarchical navigation for an app with many areas. SidebarShell →

A service-operations console in a rail shell: an icon rail, an incident queue and an on-call aside
RailDense, power-user tools

A narrow icon rail that gives the content every pixel it can. RailShell →

An expense app in a tabbed shell: seven tabs under the brand rowThe same app on a phone: a bottom tab bar with a More sheet
TabbedA handful of major areas

Tabs on a desktop and a bottom bar within thumb reach on a phone, from one navigation tree. TabbedShell →

A new-claim flow in a focused shell: a vertical stepper, the current step, a summary asideThe same step on a phone: step 2 of 4, with a progress bar
FocusedOne task at a time

A clear primary action, your position in a sequence, and a way out. FocusedShell →

06

Four applications, one UI model

You can now read a component, a theme, a layout call, a branch on the width and a live expression. Here are a personal archive, a chat client, an arcade game and the platform's own control plane, built from that vocabulary and nothing else. Flip each one to see what drew it.

A personal archive, set like a book Cream paper, one serif carrying the actual words, colour used once, rules where a box would be. It is the same kit, with this app's token values.
A personal notes app: an index of notes set in a serif on cream paper
Apps/recall-osy/model/pages/notes.osy verbatim — this file compiles
component NotesPage() {
  live var notes = Note.OrderByDescending(n => n.ModifiedAt).ToList();

  string draft = "";

  action Add() {
    if (draft == "") { return; }
    var created = CreateNote(draft);
    draft = "";
    Navigation.Go("/notes/" + created.Id);
  }

  render {
    Stack {
      ScreenHead(eyebrow: notes.Count == 1 ? "One note" : notes.Count + " notes", title: "Notes");
      Composer(value: draft, placeholder: "New note", onAdd: Add, disabled: draft == "");

      if (notes.Count == 0) {
        Blank(title: "Nothing written down yet",
              body: "A note is the fastest way to stop holding something in your head.");
      }

      CardGrid {
        foreach (var n in notes) {
          RuledRow(title: n.Title, sub: n.Tags == null ? "" : n.Tags, href: "/notes/" + n.Id);
        }
      }
    }
  }
…and one of its notes, open The same app, two screens. The body is a Markdown field rendered by an atom, read-only here, because a note you are only reading should not pay for the editor.
One note from that app, open for reading, its Markdown body set as prose
Apps/recall-osy/model/pages/notes.osy verbatim — this file compiles
    Stack(maxW: Size.Read, mx: "auto", w: "100%") {
      Row(align: Align.Center, pb: 5) {
        Link(href: "/notes") {
          Text("←  Notes", fontFamily: Font.Sans, fontSize: FontSize.Label, letterSpacing: "0.14em",
               textTransform: TextTransform.Uppercase, color: Colors.TextMuted);
        }
        Spacer();
        if (note != null) {
          Link(href: "/notes/" + note.Id + "/edit") {
            Text("Edit", fontFamily: Font.Sans, fontSize: FontSize.Label, fontWeight: FontWeight.Semibold, letterSpacing: "0.14em",
                 textTransform: TextTransform.Uppercase, color: Colors.Primary);
          }
        }
      }

      if (note != null) {
        Stack(gap: 3, pb: 6) {
          if (note.Tags != null && note.Tags != "") { Eyebrow(text: note.Tags); }
          Text(note.Title, fontFamily: Font.Display, fontSize: FontSize.Title, color: Colors.OnBg,
               letterSpacing: "-0.015em", lineHeight: "1.05");
        }
        // The read-only renderer, set as prose: serif, long line height, on the paper rather than in a box. A note
        // you are only READING should not pay for a megabyte of editing machinery.
        Box(fontFamily: Font.Text, fontSize: FontSize.Body, color: Colors.OnBg, lineHeight: "1.7") {
          Markdown(note.Body);
        }
A team chat client, in the dark half of its theme The transcript is a foreach over a query. The dark mode is the same theme: one token declaration with both values.
A team chat client in dark mode, with a channel rail and a transcript
demo/ember/model/pages/channel.osy verbatim — this file compiles
        foreach (var p in history) {
          Row(gap: Space.Beat, align: Align.Start) {
            Avatar(initials: p.Author.DisplayName, size: Size.Sm);
            Stack(gap: "2px", minW: "0") {
              Row(gap: Space.Step, align: Align.Center) {
                Text(p.Author.DisplayName, fontSize: FontSize.Caption, fontWeight: FontWeight.Semibold);
                Text(p.SentAt.ToString("HH:mm"), fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
              }
              MessageBody(body: p.Body, handles: handles);
            }
          }
        }

        foreach (var m in arriving) {
          Row(gap: Space.Beat, align: Align.Start) {
            Avatar(initials: m.Author, size: Size.Sm);
            Stack(gap: "2px", minW: "0") {
              Row(gap: Space.Step, align: Align.Center) {
                Text(m.Author, fontSize: FontSize.Caption, fontWeight: FontWeight.Semibold);
                Text(m.SentAt.ToString("HH:mm"), fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
              }
              MessageBody(body: m.Body, handles: handles);
            }
          }
        }
A lit 3D valley, at 60 frames a second One Canvas, a camera, a light and a few hundred Draw.Mesh calls a frame, in the same language. The whole loop runs in the browser, because that is where its work is.
A low-poly 3D valley rendered on one canvas: a bird flying between green pipes, with sun shadows and fog
demo/arcade/model/pages/birds.osy verbatim — this file compiles
      // A pipe that slid off the left edge respawns at the back of the train — the list never grows.
      foreach (var p in pipes) {
        if (p.X < -11.0) {
          p.X = far + 9.0;
          far = p.X;
          p.GapY = 1.9 + (Roll() % 1000) / 1000.0 * 2.6;
          p.Scored = false;
        }
      }
    }
    valley.Advance(dt, speed);
    rooster.Advance(dt);

    if (state == 0) { birdY = 2.6 + Math.Sin(t * 2.2) * 0.16; }
    if (state == 2) {
      deadFor = deadFor + dt;
      vy = vy - 13.5 * dt;
      birdY = birdY + vy * dt;
      if (birdY < birdR + 0.04) { birdY = birdR + 0.04; vy = 0; }
    }

    // The camera breathes after the bird instead of being bolted to it.
    camY = camY + (2.4 + birdY * 0.25 - camY) * 5.0 * dt;

    sky.Paint();
    Draw.Camera(3.4, camY + 1.3, 17.5, 3.4, camY, 0, 42);
    Draw.Light(-0.35, -1.0, 0.45, "#ffeec2", "#bdd7f0");       // a late-afternoon sun, front-right and high
    Draw.Fog("#ffeccd", 13, 56);       // warm haze: the far ridge dissolves into the horizon rather than ending
    valley.Paint();
    DrawPipes();
    rooster.Paint(birdY, vy, state);
    DrawHud();
The platform's own control plane A grid is a control like any other, and a column is a selector, so renaming the field is a compile error here.
The osyrin control plane listing organizations in a data grid with status pills
admin/model/pages/organizations.osy verbatim — this file compiles
        DataGrid(
          label: "Organization list",
          rows: orgs,
          columns: [
            new GridColumn<Organization> { Name = "Name", Label = "Organization", Value = o => o.Name, Title = true, Fill = true },
            new GridColumn<Organization> { Name = "Slug", Label = "Slug", Value = o => o.Slug, Secondary = true },
            new GridColumn<Organization> { Name = "Type", Label = "Type", Value = o => o.Type.Label, Width = 140 },
            new GridColumn<Organization> { Name = "Status", Label = "Status", Value = o => HeldReasonOf(o.Id, deletions) != "" ? "Held" : o.Status.Label, Width = 220 },
            // Straight to the two places an organization is worked on (card org-sections-reachable: the owner looked for
            // Invite and could not find it — it is on the organization's Users page).
            new GridColumn<Organization> { Name = "Open", Label = "Go to", Value = o => "", Width = 200, Sortable = false }
          ],
          rowSelected: OpenOrg
        ) {
          // A CELL TEMPLATE (D78): the Status column renders the app's own Osy#, not the grid's text. The pill is the
          // kit's Badge — app code, forkable — and the TONE is a presentation decision about the status, so it lives
          // HERE, in the app's UI layer, never on the enum (metadata must not dictate UI). The label inside the pill
          // is the enum's own [Label], resolved because `o.Status` is a direct read of an enum-typed field.
          slot Open { o =>
            Row(gap: 3, align: Align.Center) {
              TextLink("Applications", to: "/org/" + o.Slug + "/applications");
              TextLink("Users", to: "/org/" + o.Slug + "/users");
            }
          }
          slot Status { o =>
            Stack(gap: 1) {
              // A HELD deletion first: the organization is untouched, and the reason is what an operator acts on.
              if (HeldReasonOf(o.Id, deletions) != "") {
                Row(align: Align.Center) { Badge(tone: Tone.Warning) { Text("Held"); } }
                Hint(HeldReasonOf(o.Id, deletions));
              }
              else if (o.Status == OrganizationStatus.Active) { Row(align: Align.Center) { Badge(tone: Tone.Success) { Text(o.Status); } } }
              else { Row(align: Align.Center) { Badge(tone: Tone.Warning) { Text(o.Status); } } }
            }
          }
        }

What happens when you change it

Change the program. The screen follows.

Each of these is a thing you would do on a Tuesday. The verdict is the compiler's, not a convention's.

You do thisVerdictBecause
Change the type of a field the render readsrefusedRefused wherever it no longer fits. Every read of the field is checked, in the render block as in an action.
Change a tokencompilesEvery use follows, in both modes. That is what a token is for.
Use a token the theme does not declarerefusedRefused, by name: a missing token is a compile error.
Move a breakpointcompilesIt is a number in the theme, and the branch reads it. Nothing else changes.
Change a live querycompilesThe screen shows the new expression and stays tied to it: what it presents follows the rows the query now selects.
Rename an entity field a screen readsrefusedRefused at the read, named. The screen is part of the resolved program chapter 07 described.