Why Osy# · Chapter 08 · The UI model
The screen is written in the same language as the rules. There is no second stack.
No CSS framework, no state library, no theming system to choose, learn and keep in step with each other. Reactivity, layout and theming are parts of one language: a breakpoint is a branch, a query is a subscription, a colour is a token with two values. Write the pair once and every control follows.
- a CSS framework
- a state library
- a theming system
theme Room { Colors { Bg = Modes.Of(light: "#FBFBFA", dark: "#1A1A19"); OnBg = Modes.Of(light: "#1E1E1C", dark: "#ECEAE6"); TextMuted = Modes.Of(light: "#6C6A64", dark: "#A6A29A"); } FontSize { Caption = "12px"; Body = "15px"; Hero = "20px"; } FontWeight { Regular = 400; Medium = 500; } }
01
Start from a shell, not from a blank page
An app shell is the frame around every page: a brand, a navigation tree, the signed-in person and the menu behind them, a search slot and a place for a page's own actions. You declare that once, as an AppChrome, and hand it to the shell you want. Each shell is a starting architecture for a kind of application, not a skin — and moving between them is one word, because every one takes the same value and the same slots.

Persistent, hierarchical navigation for an app with many areas and a dense information architecture. ERP, CRM, administration consoles, the back office. SidebarShell →

A permanently narrow icon rail that gives the content every pixel it can; labels on hover, groups in a flyout. Analytics, developer tools, operations consoles, trading. RailShell →


A strip of tabs on a desktop and a bottom tab bar within thumb reach on a phone, from one navigation tree. Expense management, project tools, customer portals, smaller business apps. TabbedShell →


A clear primary action, your position in a sequence, and a way out. Onboarding, checkout, approvals, guided flows — and a task inside a bigger app, which is where it is used here. FocusedShell →
1 TabbedShell(new AppChrome { Product = "Expensely", Tagline = "Finance operations", Mark = Icons.Expensely, Home = "/t", User = new ShellUser { Name = "Olivia Rhye", Secondary = "Finance manager", Initials = "OR", Menu = [ new MenuAction { Label = "Account", Icon = Icons.User, OnPress = OpenAccount }, new MenuAction { Label = "Appearance", Icon = Icons.Eye, OnPress = Appearance }, new MenuAction { Label = "Sign out", Icon = Icons.Logout, OnPress = SignOut, Tone = Tone.Danger, Divided = true }, ], }, 2 Nav = [ // ⚠ `Exact` BECAUSE THIS HOME IS NOT `/`. The prefix rule claims every route under a nav entry, and `/` // alone is exempt from it — so without this, "Overview" would light on every page in the arrangement and // two tabs would read as current at once. new NavItem { Label = "Overview", To = "/t", Icon = Icons.Home, Exact = true }, new NavItem { Label = "Approvals", To = "/t/approvals", Icon = Icons.Receipt, Badge = "12", BadgeTone = Tone.Warning }, // A tab that NAVIGATES AND DISCLOSES — the "Settings" shape the contract promises. It renders as a link // plus its own chevron, so `/t/settings` is reachable AND its children are one press away. new NavItem { Label = "Settings", To = "/t/settings", Icon = Icons.Gear, Children = [ new NavItem { Label = "People", To = "/t/settings/people", Icon = Icons.Users }, new NavItem { Label = "Billing", To = "/t/settings/billing", Icon = Icons.Tag, Children = [ new NavItem { Label = "Invoices", To = "/t/settings/billing/invoices", Icon = Icons.File }, ] }, ] }, new NavItem { Label = "Reports", To = "/t/reports", Icon = Icons.Chart }, new NavItem { Label = "Vendors", To = "/t/vendors", Icon = Icons.Users }, new NavItem { Label = "Budgets", To = "/t/budgets", Icon = Icons.Tag }, // A SECTION at the top level: the rail would draw the heading, and a BAR has nowhere to put one — so // `Primary()` replaces it with its children. This is the entry that proves that rule renders. new NavItem { Kind = NavKind.Section, Label = "Administration", Children = [ new NavItem { Label = "Audit log", To = "/t/audit", Icon = Icons.File }, ] }, ], }) { Outlet(retain: 8); 3 slot search { ShellSearch("Search requests, people or departments", onPress: Palette); } slot actions { ShellCountButton("Notifications", 3, onPress: Inbox) { Icon(Icons.Bell, size: 18); } }
The one word. This is the sidebar demo's layout with SidebarShell changed to TabbedShell; RailShell and FocusedShell take the same value. Nothing else in the file knows which shell it is in.
The navigation tree, once. A sidebar draws it as a column, a tab strip as tabs and a bottom bar, a rail as icons with flyouts, a focused shell as steps. Which row is current comes from the route, never from a flag you pass.
A slot is a position, not chrome. The search box, the notification count and a page's aside arrive with the content you put there. An empty slot costs nothing — no hairline, no reserved band.
Coming next — one for each way of using an app
- Storefrontcoming soonCatalogue and shopping — discovery and selection over application functions. Shops, ordering, booking, ticketing.
- Marketplacecoming soonA market of participants, not one catalogue: listings, provider identity, ratings, messaging.
- Feedcoming soonThe content stream is the application; navigation supports it. Social, community, news, activity.
- Mediacoming soonDiscovery, then immersive consumption: shelves, playback that persists, a queue. Video, music, podcasts.
- Portalcoming soonConsumer self-service — visit to get something done. Banking, insurance, telecom, government.
- Immersivecoming soonThe shell gets out of the way: the map, the photo or the drawing is the point.
- Dashboardcoming soonAn overview of your world as the home screen — and it combines with any of the others.
02
A theme is a declaration, and dark mode is the second half of each line
Not a second stylesheet, and not a class you toggle. A colour is a token with a light value and a dark one, written together — so they cannot drift, and nothing has to be kept in step.
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; } }
Both modes on one line. Adding a dark mode to an app is editing the lines that already exist, not writing a parallel set of them.
A token with no pair is the same in both. The brand colour usually is.
Not just colour. Radii, type sizes, weights and lengths are tokens too, so rounded: Radius.Card is a decision made once.
Your breakpoints, named by you. There is no sm/md/lg vocabulary to learn — and see §3, where this name is used as a branch rather than as a media query.
03
Layout is arguments
There is no class attribute, no utility soup, and no stylesheet that has to agree with the markup. Spacing, colour, radius and size are typed arguments on the element, and every value is a token.
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); } } } } } [Composable] component Board<T>(T[] lanes) { render { 4 if (Layout.AtLeast(Cozy)) { 5 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); } } } } }
Generic over what it holds. A lane knows it stacks things; it does not know what a card looks like.
Arguments, checked by the compiler. A typo is an error at the position it happened, not a style that silently does nothing.
The caller supplies the child. One lane component serves every board, because the page that uses it decides what goes in.
⭐ A breakpoint is a BRANCH. Wide enough and the lanes are a Row; narrower and they are a Stack. Two structures from one source, with no media query and no second template — and because it is ordinary control flow, a phone can render genuinely different markup rather than the same markup pushed around.
Accessibility is in the declaration, on the element that is the region. UiRole is a real vocabulary the compiler checks, not a raw string a typo breaks silently.
04
A query is a subscription, and state is just a field
The reactivity model is two words long: a plain field is client state, and a live var stays true. There is no store, no selector, no query key and no invalidation.
component BoardPage() { 1 live var lanes = Lane.Include(l => l.Items).OrderBy(l => l.Ordinal); on mount { SeedBoard(); } string moved = ""; 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; 2 live var moves = CardFlow.For(picked).Transitions;
Server, and live. An entity query runs on the server under your declared security, and live keeps it true — a card somebody else moves lands here with no poll and no refetch.
The moves available on the selected card, from the WORKFLOW. Not a list the page maintains: the workflow already knows which transitions are legal and who may take them, so the board asks it.
05
Four apps, and nothing new to learn between them
A personal archive, a chat client, an arcade game and the platform's own control plane — five screens between them. The same kit of controls and atoms, the same tokens, same render block — and no two of them look remotely alike. Flip each one to see what drew it.

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); } } } }
Markdown field rendered by an atom — read-only here, because a note you are only reading should not pay for the editor.
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); }
foreach over a query. The dark mode is the same theme — one token declaration with both values, not a second stylesheet.
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); } } }
Canvas, a camera, a light and a few hundred Draw.Mesh calls a frame. The scene is Osy# — and the whole loop runs in the browser, because that is where its work is.
// 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();

DataGrid( label: "Organizations", 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 => o.Status.Label, Width = 140 } ], 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 Status { o => Row(align: Align.Center) { if (o.Status == OrganizationStatus.Active) { Badge(tone: Tone.Success) { Text(o.Status); } } else { Badge(tone: Tone.Warning) { Text(o.Status); } } } }
What happens when you change it
UI changes, and what they cost
Each of these is a thing you would do on a Tuesday. The verdict is the compiler's, not a convention's.
| You do this | Verdict | Because |
|---|---|---|
| Move an app from a sidebar to tabs | compiles | One word. Every shell takes the same AppChrome and the same slots, so the navigation, the person and the search box come along unchanged. |
| Change a token | compiles | Every use follows, in both modes. That is what a token is for. |
| Add a dark value to a colour that had none | compiles | Edit the line. There is no second file, and no chance of adding it in one place and not the other. |
| Use a token the theme does not declare | refused | Refused, by name. A missing token is a compile error rather than a colour that renders as nothing. |
| Move a breakpoint | compiles | It is a number in the theme, and the branch reads it. Nothing else changes. |
| Fork a kit control | compiles | osy get ui/field copies it into ui/lib/, where it shadows the kit's by name. It is ordinary Osy# — the same language your app is written in. |