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

Samples

ember

EMBER — a small team workspace, and the first app built entirely on the topic surface ([[M256]]/[[M261]]/[[M262]]).

12 source files3 test files

The ember sample, running.
Running, signed in, compiled from the source below.

Get it

$ osy init ember
$ osy launch

The app

app.osy8 lines
// EMBER — a small team workspace, and the first app built entirely on the topic surface ([[M256]]/[[M261]]/[[M262]]).
app Ember {
  use Osyrin.Ui;

  model "model/**/*.osy";
  tests "tests/**/*.test.osy";
}
model/direct.osy115 lines
// DIRECT MESSAGES — and the whole feature turns on one modelling decision: a conversation is addressed by a ROW,
// never by the people in it.

// ⭐ THE GROUP-MEMBERSHIP READ RULE, and it is the one thing here that had to be MEASURED rather than assumed
// (2026-08-18, probe app). It reads *down* the relation — "the parties of this conversation" — rather than restating
// a join, and it is not circular even though `Party` decides `Party`: a hop inside a rule resolves against the data
// AS IT IS, not against the rows the caller may read, which the platform states outright and which the probe
// confirmed. Ada creates the conversation; **Bo, who created nothing, reads both party rows; Cy reads neither.**
entity Conversation {
  [MaxLength(60)] string Title = "";
  [ForeignKey(Conversation)] Party[] Parties;

  security {
    allow read where Parties.Any(p => p.Person == user);
    allow create when IsAuthenticated;
  }
}

[Unique(Conversation, Person)]
entity Party {
  [Required] Conversation Conversation;
  [Required] User Person;
  [Required] DateTime LastReadAt;

  security {
    allow read where Conversation.Parties.Any(p => p.Person == user);
    allow create where Person == user || Conversation.Parties.Any(p => p.Person == user);
    allow update where Person == user;
  }
}

entity Line {
  [Required] Conversation Conversation;
  [Required] User Author;
  [Required, MaxLength(2000)] string Body;
  [Required] DateTime SentAt;

  security {
    allow read   where Conversation.Parties.Any(p => p.Person == user);
    allow create where Conversation.Parties.Any(p => p.Person == user);
  }
}

class DirectLine {
  public string Body;
  public string Author;
  public DateTime SentAt;
}

topic DirectFeed(Guid conversationId) {
  Candidates = u => Party.Any(p => p.Conversation.Id == conversationId && p.Person == u);
  Carries    = DirectLine;
}

topic DirectPresence(Guid conversationId) {
  Candidates = u => Party.Any(p => p.Conversation.Id == conversationId && p.Person == u);
  Carries    = Seat;
  Presence   = true;
}

Conversation OpenDirect(User other) {
  var me = Session.CurrentUser;
  if (other == me) { return null; }

  var visible = Party.Include(p => p.Conversation).ToList();

  foreach (var theirs in visible.Where(p => p.Person == other)) {
    if (theirs.Conversation.Title == "" && visible.Where(p => p.Conversation == theirs.Conversation).Count() == 2) {
      return theirs.Conversation;
    }
  }

  var now = DateTime.UtcNow;
  var conversation = new Conversation { Title = "" };
  new Party { Conversation = conversation, Person = me,    LastReadAt = now };
  new Party { Conversation = conversation, Person = other, LastReadAt = now };
  return conversation;
}

void SendDirect(Guid conversationId, string body) {
  var text = body.Trim();
  if (text == "") { return; }
  if (TrySlashCommand(text)) { return; }

  var conversation = Conversation.Where(c => c.Id == conversationId).FirstOrDefault();
  if (conversation == null) { return; }

  var me = Session.CurrentUser;
  var sent = DateTime.UtcNow;

  new Line { Conversation = conversation, Author = me, Body = text, SentAt = sent };

  DirectFeed.For(conversationId).Publish(new DirectLine { Body = text, Author = me.DisplayName, SentAt = sent });

  var href = "/d/" + conversationId;
  foreach (var them in Party.Where(p => p.Conversation == conversation).Include(p => p.Person).ToList()) {
    if (them.Person != me) {
      Inbox.For(them.Person.Id).Publish(new Nudge {
        Kind = NudgeKind.Direct, From = me.DisplayName, Preview = text, Href = href,
      });
    }
  }

  NudgeMentions(text, href, me);

  MarkConversationRead(conversationId);
}

void MarkConversationRead(Guid conversationId) {
  var me = Session.CurrentUser;
  var mine = Party.Where(p => p.Conversation.Id == conversationId && p.Person == me).FirstOrDefault();
  if (mine == null) { return; }
  mine.LastReadAt = DateTime.UtcNow;
}
model/identity.osy60 lines
// Ember's credentials. No special access is minted, and a forged ticket buys nothing (the server re-verifies every
// request).

[Role] enum AppRole { Authenticator, Member }

enum StatusKind { Available, Busy, Away }

entity RoleGrant {
  User Grantee;
  [Required] AppRole Level;
  security { }
}

policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator);

[Principal] entity User {
  [Unique, MaxLength(200)] string Email;
  [MaxLength(200)] string PasswordHash;
  [MaxLength(80)] string DisplayName = "";

  [Required] StatusKind Status = StatusKind.Available;
  [MaxLength(80)] string StatusMessage = "";

  security {
    allow read when IsAuthenticator;
    allow create when IsAuthenticator;
    allow update when IsAuthenticator;
    allow read when IsAuthenticated;
    allow update where Id == user.Id;

    deny read PasswordHash when !IsAuthenticator;
  }
}

[AuthMethod]
string Signup(string email, string password) {
  if (password.Length < 8) { return ""; }
  if (User.Where(x => x.Email == email).FirstOrDefault() != null) { return ""; }
  var handle = email;
  var at = email.IndexOf("@");
  if (at > 0) { handle = email.Substring(0, at); }
  var u = new User { Email = email, PasswordHash = Security.HashPassword(password), DisplayName = handle };
  return Security.IssueJwt(u.Id, u.Email);
}

[AuthMethod]
string Login(string email, string password) {
  var u = User.Where(x => x.Email == email).FirstOrDefault();
  if (u == null) { Security.VerifyPassword(password); return ""; }
  if (Security.VerifyPassword(password, u.PasswordHash)) { return Security.IssueJwt(u.Id, u.Email); }
  return "";
}

app.AuthBootstrap = new AuthBootstrap {
  Role      = AppRole.Authenticator,
  Login     = Login,
  Signup    = Signup,
  LoginPage = LoginPage,
};
model/inbox.osy31 lines
// THE INBOX — the piece that makes everything else reach somebody who is not already looking at it.

enum NudgeKind {
  Direct,
  Mention,
}

class Nudge {
  public NudgeKind Kind;
  public string From;
  public string Preview;
  public string Href;
}

topic Inbox(Guid personId) {
  Candidates = u => u.Id == personId;
  Publishers = _ => true;
  Carries    = Nudge;
}

void NudgeMentions(string text, string href, User author) {
  var words = text.Split(" ");
  foreach (var person in User.ToList()) {
    if (person != author && words.Any(w => w == "@" + person.DisplayName)) {
      Inbox.For(person.Id).Publish(new Nudge {
        Kind = NudgeKind.Mention, From = author.DisplayName, Preview = text, Href = href,
      });
    }
  }
}
model/status.osy30 lines
// SAYING WHERE YOU ARE — `/away back at 3`, `/busy in a meeting`, `/back`.

// ⭐ ONE ENTRY POINT FOR BOTH ROOMS. A channel and a direct message both call this before they post, so `/away`
// means the same thing in either — and a command added here is added to both.
bool TrySlashCommand(string body) {
  var text = body.Trim();
  if (!text.StartsWith("/")) { return false; }

  var verb = text;
  var rest = "";
  var space = text.IndexOf(" ");
  if (space > 0) {
    verb = text.Substring(0, space);
    rest = text.Substring(space + 1).Trim();
  }

  if (verb == "/away")      { SetStatus(StatusKind.Away, rest);      return true; }
  if (verb == "/busy")      { SetStatus(StatusKind.Busy, rest);      return true; }
  if (verb == "/back")      { SetStatus(StatusKind.Available, "");   return true; }
  if (verb == "/available") { SetStatus(StatusKind.Available, "");   return true; }

  return true;
}

void SetStatus(StatusKind kind, string message) {
  var me = Session.CurrentUser;
  me.Status = kind;
  me.StatusMessage = kind == StatusKind.Available ? "" : message;
}
model/theme.osy45 lines
// EMBER'S DESIGN LANGUAGE — "editorial dark".
theme Ember {

  Colors {
    Surface0 = Modes.Of(light: "#faf8f5", dark: "#0e0d0c");   // page canvas
    Surface1 = Modes.Of(light: "#ffffff", dark: "#171614");   // panel: the rail, the composer, a card
    Surface2 = Modes.Of(light: "#f1ede7", dark: "#221f1c");   // raised: a hovered row, the selected channel

    // ⚠ THE PRIMARY TEXT COLOUR IS `OnBg`, WHICH IS THE KIT'S OWN NAME — deliberately, not incidentally. This
    //    block used to declare a `TextPrimary` beside it holding the identical pair, read by nothing while `OnBg`
    //    was read by the pages and by every kit control. A second spelling of a name the kit already has is how an
    //    app ends up with two of everything, and it drifts on the first edit to either.
    TextSecondary = Modes.Of(light: "#6b6156", dark: "#9c928a");
    OnBg          = Modes.Of(light: "#17140f", dark: "#f2ede4");

    Border = Modes.Of(light: "#e7e0d6", dark: "#2b2724");

    FillAccent  = Modes.Of(light: "#c2410c", dark: "#fb923c");
    FillSuccess = Modes.Of(light: "#15803d", dark: "#4ade80");
    FillDanger  = Modes.Of(light: "#b91c1c", dark: "#f87171");
    FillMuted   = Modes.Of(light: "#a8a29e", dark: "#57534e");

    Primary   = Modes.Of(light: Palette.From("#c2410c"), dark: Palette.From("#fb923c"));
    OnPrimary = Modes.Of(light: "#fffaf5", dark: "#1a1310");
    Surface   = Modes.Of(light: "#ffffff", dark: "#171614");   // = Surface1
    OnSurface = Modes.Of(light: "#17140f", dark: "#f2ede4");   // = OnBg
    Muted     = Modes.Of(light: "#f1ede7", dark: "#221f1c");   // = Surface2 — the kit's hover fill
    Danger    = Modes.Of(light: "#b91c1c", dark: "#f87171");
    Success   = Modes.Of(light: "#15803d", dark: "#4ade80");
    Warning   = Modes.Of(light: "#b45309", dark: "#fbbf24");
  }

  Radius     { Control = "10px"; Card = "14px"; Pill = "999px"; }

  Space      { Tick = "4px"; Step = "8px"; Beat = "14px"; Rest = "22px"; Span = "36px"; }

  Font {
    Sans = "\"Inter\", system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif";
    Mono = "\"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, monospace";
  }

  FontSize   { Micro = "11px"; Caption = "12px"; Body = "14px"; Section = "16px"; Title = "22px"; Display = "30px"; }
  FontWeight { Regular = 400; Medium = 500; Semibold = 600; Bold = 700; }
}
model/workspace.osy94 lines
// EMBER'S WORKSPACE — channels, membership, posts, and the two topics.

entity Channel {
  [Required, MaxLength(60)] string Name;
  [MaxLength(160)] string Purpose = "";

  security {
    allow read, create when IsAuthenticated;
  }
}

[Unique(Channel, Person)]
entity Membership {
  [Required] Channel Channel;
  [Required] User Person;
  [Required] DateTime LastReadAt;

  security {
    allow read where Person == user;
    allow create where Person == user;
    allow update where Person == user;
  }
}

entity Post {
  [Required] Channel Channel;
  [Required] User Author;
  [Required, MaxLength(2000)] string Body;
  [Required] DateTime SentAt;

  security {
    allow read where Membership.Any(m => m.Channel == Channel && m.Person == user);
    allow create where Membership.Any(m => m.Channel == Channel && m.Person == user);
  }
}

class PostLine {
  public string Body;
  public string Author;
  public DateTime SentAt;
}

class Seat {
  public User Person;
  public string Activity;
}

topic ChannelFeed(Guid channelId) {
  Candidates = u => Membership.Any(m => m.Channel.Id == channelId && m.Person == u);
  Carries    = PostLine;
}

topic ChannelPresence(Guid channelId) {
  Candidates = u => Membership.Any(m => m.Channel.Id == channelId && m.Person == u);
  Carries    = Seat;
  Presence   = true;
}

Channel CreateChannel(string name, string purpose) {
  var me = Session.CurrentUser;
  var channel = new Channel { Name = name, Purpose = purpose };
  new Membership { Channel = channel, Person = me, LastReadAt = DateTime.UtcNow };
  return channel;
}

void JoinChannel(Channel channel) {
  var me = Session.CurrentUser;
  if (Membership.Any(m => m.Channel == channel && m.Person == me)) { return; }
  new Membership { Channel = channel, Person = me, LastReadAt = DateTime.UtcNow };
}

void PostMessage(Channel channel, string body) {
  var text = body.Trim();
  if (text == "") { return; }
  if (TrySlashCommand(text)) { return; }
  var me = Session.CurrentUser;
  var sent = DateTime.UtcNow;

  new Post { Channel = channel, Author = me, Body = text, SentAt = sent };

  ChannelFeed.For(channel.Id).Publish(new PostLine { Body = text, Author = me.DisplayName, SentAt = sent });

  NudgeMentions(text, "/c/" + channel.Id, me);

  MarkRead(channel);
}

void MarkRead(Channel channel) {
  var me = Session.CurrentUser;
  var mine = Membership.Where(m => m.Channel == channel && m.Person == me).FirstOrDefault();
  if (mine == null) { return; }
  mine.LastReadAt = DateTime.UtcNow;
}
model/pages/channel.osy188 lines
using Osyrin.Ui;

[Page("/c/{channelId}")]
[Layout(EmberShell)]
[Render(CSR)]
component ChannelPage(Guid channelId) {
  var me = Session.CurrentUser;

  var channel = Channel.Single(c => c.Id == channelId);

  live var mine = Membership.Where(m => m.Channel.Id == channelId).ToList();

  var history = Post
    .Where(p => p.Channel.Id == channelId)
    .Include(p => p.Author)
    .OrderBy(p => p.SentAt)
    .ToList();

  live var arriving = ChannelFeed.For(channelId).Listen();

  live var here = ChannelPresence.For(channelId).Here;

  live var people = User.OrderBy(u => u.DisplayName).ToList();
  live var handles = people.Select(u => "@" + u.DisplayName).ToList();

  string draft = "";

  string announced = "reading";
  string lastSeenDraft = "";
  int idleTicks = 0;

  on every (TimeSpan.FromMilliseconds(700)) {
    if (draft != lastSeenDraft) { lastSeenDraft = draft; idleTicks = 0; }
    else { idleTicks = idleTicks + 1; }

    var want = draft != "" && idleTicks < 4 ? "typing" : "reading";
    if (want != announced) {
      announced = want;
      ChannelPresence.For(channelId).Announce(new Seat { Activity = want });
    }
  }

  on mount {
    MarkRead(channel);
    ChannelPresence.For(channelId).Announce(new Seat { Activity = "reading" });
  }

  // ⛔ THE COMMIT IS NOT OPTIONAL, and its absence was invisible until 2026-08-27. `PostMessage` and `JoinChannel`
  //    take an ENTITY, so they run inside THIS page's unit of work rather than opening their own — the write lands
  //    in the optimistic overlay, the screen updates from it and looks saved, and it is discarded when the page goes
  //    away. Nothing reported it: the demo's own tests pass because a `[Test]` assertion settles to the database
  //    before it reads, so the one path that never commits is the one a person actually uses.
  action Send() {
    PostMessage(channel, draft);
    UnitOfWork.Commit();
    draft = "";
  }

  action Join() {
    JoinChannel(channel);
    UnitOfWork.Commit();
    Navigation.Go("/c/" + channelId);
  }

  action Mention(User person) {
    draft = (draft.TrimEnd() + " @" + person.DisplayName + " ").TrimStart();
  }

  render {
    Stack(gap: 0, minH: "100vh", minW: "0") {

      Row(gap: Space.Beat, align: Align.Center, justify: Justify.SpaceBetween, px: Space.Rest, py: Space.Beat, borderBW: 1, border: Colors.Border, bg: Colors.Surface1) {
        Stack(gap: "0", minW: "0") {
          Row(gap: Space.Tick, align: Align.Center) {
            Text("#", color: Colors.TextSecondary, fontFamily: Font.Mono, fontSize: FontSize.Section);
            Text(channel.Name, fontSize: FontSize.Section, fontWeight: FontWeight.Semibold);
          }
          if (channel.Purpose != "") {
            Text(channel.Purpose, fontSize: FontSize.Caption, color: Colors.TextSecondary);
          }
        }

        Row(gap: Space.Step, align: Align.Center) {
          foreach (var p in here) {
            Row(gap: Space.Tick, align: Align.Center, bg: Colors.Surface2, rounded: Radius.Pill, px: Space.Step, py: Space.Tick) {
              Box(w: "6px", h: "6px", bg: Colors.FillSuccess, rounded: Radius.Pill);
              Text(p.Person.DisplayName, fontSize: FontSize.Micro, fontFamily: Font.Mono);
              if (p.Activity == "typing") {
                Text("typing…", fontSize: FontSize.Micro, color: Colors.FillAccent, fontFamily: Font.Mono);
              }
              if (p.Activity != "typing" && p.Activity != "") {
                Text(p.Activity, fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
              }
            }
          }
          if (here.Count == 0) {
            Text("nobody else here", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
          }
        }
      }

      Stack(gap: Space.Beat, grow: 1, px: Space.Rest, py: Space.Rest, overflowY: Overflow.Auto) {

        if (history.Count == 0 && arriving.Count == 0 && mine.Count > 0) {
          Stack(gap: Space.Tick, py: Space.Span) {
            Text("Nothing here yet.", fontSize: FontSize.Section, fontWeight: FontWeight.Medium);
            Text("Say the first thing.", fontSize: FontSize.Caption, color: Colors.TextSecondary, fontFamily: Font.Mono);
          }
        }

        if (mine.Count == 0) {
          Stack(gap: Space.Tick, py: Space.Span) {
            Text("#" + channel.Name, fontSize: FontSize.Section, fontWeight: FontWeight.Medium);
            Text("what is said in here is for members — join below to read it",
                 fontSize: FontSize.Caption, color: Colors.TextSecondary, fontFamily: Font.Mono);
          }
        }

        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);
            }
          }
        }
      }

      if (mine.Count == 0) {
        Row(gap: Space.Beat, align: Align.Center, justify: Justify.SpaceBetween, px: Space.Rest, py: Space.Beat, borderTW: 1, border: Colors.Border, bg: Colors.Surface1) {
          Stack(gap: "2px") {
            Text("You are not in #" + channel.Name, fontSize: FontSize.Body, fontWeight: FontWeight.Medium);
            Text("join to read what is said here", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
          }
          Button("Join channel", onPress: Join, tone: Tone.Primary);
        }
      }

      if (mine.Count > 0) {
        Stack(gap: Space.Step, px: Space.Rest, py: Space.Beat, borderTW: 1, border: Colors.Border, bg: Colors.Surface1) {
          Row(gap: Space.Tick, align: Align.Center, wrap: Wrapping.Wrap) {
            Text("mention", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
            foreach (var person in people.Where(u => u.Id != me.Id)) {
              Button("@" + person.DisplayName, onPress: () => Mention(person), tone: Tone.Ghost, size: Size.Sm);
            }
          }
          Row(gap: Space.Step, align: Align.Center) {
            Box(grow: 1, minW: "0") {
              Field(label: "Message", value: draft, placeholder: "message #" + channel.Name + " — or /away, /busy, /back");
            }
            Button("Send", onPress: Send, tone: Tone.Primary);
          }
        }
      }
    }
  }
}

[Page("/")]
[Layout(EmberShell)]
[Render(CSR)]
[Title("Ember")]
component HomePage() {
  render {
    Stack(gap: Space.Step, align: Align.Center, justify: Justify.Center, minH: "100vh", p: Space.Span) {
      Text("Pick a channel", fontSize: FontSize.Title, fontWeight: FontWeight.Semibold);
      Text("or make one in the rail", fontSize: FontSize.Caption, color: Colors.TextSecondary, fontFamily: Font.Mono);
    }
  }
}
model/pages/direct.osy151 lines
using Osyrin.Ui;

[Page("/d/{conversationId}")]
[Layout(EmberShell)]
[Render(CSR)]
component DirectPage(Guid conversationId) {
  var me = Session.CurrentUser;

  live var conversation = Conversation.Where(c => c.Id == conversationId).FirstOrDefault();

  live var parties = Party.Where(p => p.Conversation.Id == conversationId).Include(p => p.Person).ToList();

  live var people = User.OrderBy(u => u.DisplayName).ToList();
  live var handles = people.Select(u => "@" + u.DisplayName).ToList();

  var history = Line
    .Where(l => l.Conversation.Id == conversationId)
    .Include(l => l.Author)
    .OrderBy(l => l.SentAt)
    .ToList();

  live var arriving = DirectFeed.For(conversationId).Listen();

  live var here = DirectPresence.For(conversationId).Here;
  string draft = "";
  string announced = "reading";
  string lastSeenDraft = "";
  int idleTicks = 0;

  on every (TimeSpan.FromMilliseconds(700)) {
    if (draft != lastSeenDraft) { lastSeenDraft = draft; idleTicks = 0; }
    else { idleTicks = idleTicks + 1; }

    var want = draft != "" && idleTicks < 4 ? "typing" : "reading";
    if (want != announced) {
      announced = want;
      DirectPresence.For(conversationId).Announce(new Seat { Activity = want });
    }
  }

  on mount {
    MarkConversationRead(conversationId);
    DirectPresence.For(conversationId).Announce(new Seat { Activity = "reading" });
  }

  action Send() {
    SendDirect(conversationId, draft);
    draft = "";
  }

  action Mention(User person) {
    draft = (draft.TrimEnd() + " @" + person.DisplayName + " ").TrimStart();
  }

  render {
    Stack(gap: 0, minH: "100vh", minW: "0") {

      if (conversation == null) {
        Stack(gap: Space.Tick, align: Align.Center, justify: Justify.Center, grow: 1, p: Space.Span) {
          Text("This conversation is not yours", fontSize: FontSize.Section, fontWeight: FontWeight.Medium);
          Text("a direct message is between the people in it", fontSize: FontSize.Caption, color: Colors.TextSecondary, fontFamily: Font.Mono);
        }
      }

      if (conversation != null) {
        Row(gap: Space.Beat, align: Align.Center, justify: Justify.SpaceBetween, px: Space.Rest, py: Space.Beat, borderBW: 1, border: Colors.Border, bg: Colors.Surface1) {
          Row(gap: Space.Step, align: Align.Center, minW: "0") {
            foreach (var them in parties.Where(p => p.Person != me)) {
              Row(gap: Space.Tick, align: Align.Center) {
                Avatar(initials: them.Person.DisplayName, size: Size.Sm);
                Text(them.Person.DisplayName, fontSize: FontSize.Section, fontWeight: FontWeight.Semibold);
                if (them.Person.Status != StatusKind.Available) {
                  Row(gap: Space.Tick, align: Align.Center, bg: Colors.Surface2, rounded: Radius.Pill, px: Space.Step, py: Space.Tick) {
                    Box(w: "6px", h: "6px", rounded: Radius.Pill,
                        bg: them.Person.Status == StatusKind.Busy ? Colors.FillDanger : Colors.FillMuted);
                    Text(them.Person.Status == StatusKind.Busy ? "busy" : "away",
                         fontSize: FontSize.Micro, fontFamily: Font.Mono, color: Colors.TextSecondary);
                    if (them.Person.StatusMessage != "") {
                      Text(them.Person.StatusMessage, fontSize: FontSize.Micro, fontFamily: Font.Mono);
                    }
                  }
                }
              }
            }
            if (conversation.Title != "") {
              Text(conversation.Title, fontSize: FontSize.Section, fontWeight: FontWeight.Semibold);
            }
          }
          Row(gap: Space.Step, align: Align.Center) {
            foreach (var seat in here.Where(p => p.Person.Id != me.Id && p.Activity == "typing")) {
              Text(seat.Person.DisplayName + " is typing…", fontSize: FontSize.Micro, color: Colors.FillAccent, fontFamily: Font.Mono);
            }
            Text("direct", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
          }
        }

        Stack(gap: Space.Beat, grow: 1, px: Space.Rest, py: Space.Rest, overflowY: Overflow.Auto) {

          if (history.Count == 0 && arriving.Count == 0) {
            Stack(gap: Space.Tick, py: Space.Span) {
              Text("Nothing said yet.", fontSize: FontSize.Section, fontWeight: FontWeight.Medium);
              Text("this thread is just the two of you", fontSize: FontSize.Caption, color: Colors.TextSecondary, fontFamily: Font.Mono);
            }
          }

          foreach (var l in history) {
            Row(gap: Space.Beat, align: Align.Start) {
              Avatar(initials: l.Author.DisplayName, size: Size.Sm);
              Stack(gap: "2px", minW: "0") {
                Row(gap: Space.Step, align: Align.Center) {
                  Text(l.Author.DisplayName, fontSize: FontSize.Caption, fontWeight: FontWeight.Semibold);
                  Text(l.SentAt.ToString("HH:mm"), fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
                }
                MessageBody(body: l.Body, handles: handles);
              }
            }
          }

          foreach (var l in arriving) {
            Row(gap: Space.Beat, align: Align.Start) {
              Avatar(initials: l.Author, size: Size.Sm);
              Stack(gap: "2px", minW: "0") {
                Row(gap: Space.Step, align: Align.Center) {
                  Text(l.Author, fontSize: FontSize.Caption, fontWeight: FontWeight.Semibold);
                  Text(l.SentAt.ToString("HH:mm"), fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
                }
                MessageBody(body: l.Body, handles: handles);
              }
            }
          }
        }

        Stack(gap: Space.Step, px: Space.Rest, py: Space.Beat, borderTW: 1, border: Colors.Border, bg: Colors.Surface1) {
          Row(gap: Space.Tick, align: Align.Center, wrap: Wrapping.Wrap) {
            Text("mention", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
            foreach (var person in people.Where(u => u != me)) {
              Button("@" + person.DisplayName, onPress: () => Mention(person), tone: Tone.Ghost, size: Size.Sm);
            }
          }
          Row(gap: Space.Step, align: Align.Center) {
            Box(grow: 1, minW: "0") {
              Field(label: "Message", value: draft, placeholder: "write a direct message — or /away, /busy, /back");
            }
            Button("Send", onPress: Send, tone: Tone.Primary);
          }
        }
      }
    }
  }
}
model/pages/login.osy58 lines
using Osyrin.Ui;

[Page("/login")]
[AllowAnonymous]
[Render(CSR)]
[Title("Ember")]
component LoginPage() {
  string email = "";
  string password = "";
  string problem = "";

  action SignIn() {
    var ticket = Login(email, password);
    if (ticket == "") { problem = "That email and password do not match an account."; return; }
    Session.SignIn(ticket);
  }

  action CreateAccount() {
    var ticket = Signup(email, password);
    if (ticket == "") { problem = "Pick a password of at least 8 characters, and an email nobody has used yet."; return; }
    Session.SignIn(ticket);
  }

  render {
    Stack(bg: Colors.Surface0, color: Colors.OnBg, minH: "100vh", align: Align.Center, justify: Justify.Center, p: Space.Rest) {
      Stack(gap: Space.Rest, w: "100%", maxW: "380px") {

        Stack(gap: Space.Tick) {
          Row(gap: Space.Step, align: Align.Center) {
            Box(w: "10px", h: "10px", bg: Colors.FillAccent, rounded: Radius.Pill);
            Text("ember", fontSize: FontSize.Display, fontWeight: FontWeight.Bold);
          }
          Text("a small room for a small team", fontSize: FontSize.Caption, color: Colors.TextSecondary, fontFamily: Font.Mono);
        }

        Stack(gap: Space.Beat, bg: Colors.Surface1, p: Space.Rest, rounded: Radius.Card, borderW: 1, border: Colors.Border) {
          Field(label: "Email", value: email, placeholder: "you@team.co", type: "email");
          Field(label: "Password", value: password, placeholder: "at least 8 characters", type: "password");

          if (problem != "") {
            Text(problem, fontSize: FontSize.Caption, color: Colors.FillDanger);
          }

          Stack(gap: Space.Step) {
            Button("Sign in", onPress: SignIn, tone: Tone.Primary);
            Button("Create account", onPress: CreateAccount, tone: Tone.Ghost);
          }
        }

        Row(justify: Justify.Center) {
          Text("Nothing here is real. Make an account and use it.",
               fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
        }
      }
    }
  }
}
model/pages/message-body.osy40 lines
using Osyrin.Ui;

class MessageRun {
  public string Text;
  public bool IsMention;
}

MessageRun[] MessageRuns(string body, string[] handles) {
  var runs = new List<MessageRun>();
  var plain = "";
  foreach (var word in body.Split(" ")) {
    if (handles.Any(h => h == word)) {
      if (plain != "") { runs.Add(new MessageRun { Text = plain, IsMention = false }); plain = ""; }
      runs.Add(new MessageRun { Text = word, IsMention = true });
    }
    else {
      if (plain == "") { plain = word; }
      else { plain = plain + " " + word; }
    }
  }
  if (plain != "") { runs.Add(new MessageRun { Text = plain, IsMention = false }); }
  return runs;
}

[Composable]
component MessageBody(string body, string[] handles) {
  render {
    Row(gap: Space.Tick, wrap: Wrapping.Wrap, align: Align.Baseline, minW: "0") {
      foreach (var run in MessageRuns(body, handles)) {
        if (run.IsMention) {
          Text(run.Text, fontSize: FontSize.Body, fontWeight: FontWeight.Semibold, color: Colors.FillAccent);
        }
        if (!run.IsMention) {
          Text(run.Text, fontSize: FontSize.Body);
        }
      }
    }
  }
}
model/pages/shell.osy203 lines
using Osyrin.Ui;

[Layout]
component EmberShell() {
  var me = Session.CurrentUser;

  live var mine = Membership.Include(m => m.Channel).ToList();

  live var visible = Post.Include(p => p.Channel).ToList();

  live var parties = Party.Include(p => p.Person).Include(p => p.Conversation).ToList();
  live var lines   = Line.Include(l => l.Conversation).ToList();

  live var people = User.OrderBy(u => u.DisplayName).ToList();

  live var nudges = Inbox.For(me.Id).Listen();

  string newName = "";

  int dismissed = 0;

  action Create() {
    var name = newName.Trim();
    if (name == "") { return; }
    var made = CreateChannel(name, "");
    newName = "";
    Navigation.Go("/c/" + made.Id);
  }

  action SignOut() { Session.SignOut(); }

  // See the note in `channel.osy`: `OpenDirect` takes an entity, so it runs in THIS page's unit of work and the
  // conversation it creates is only an overlay row until this commits.
  action Open(User person) {
    var conversation = OpenDirect(person);
    UnitOfWork.Commit();
    Navigation.Go("/d/" + conversation.Id);
  }

  action Dismiss() { dismissed = nudges.Count; }

  render {
    Row(gap: 0, minH: "100vh", bg: Colors.Surface0, color: Colors.OnBg, align: Align.Start) {

      Stack(gap: Space.Rest, w: "260px", minW: "260px", bg: Colors.Surface1, p: Space.Beat, minH: "100vh", borderRW: 1, border: Colors.Border) {

        Row(gap: Space.Step, align: Align.Center) {
          Box(w: "10px", h: "10px", bg: Colors.FillAccent, rounded: Radius.Pill);
          Text("ember", fontSize: FontSize.Title, fontWeight: FontWeight.Bold);
        }

        Stack(gap: Space.Step) {
          Text("CHANNELS", fontSize: FontSize.Micro, fontWeight: FontWeight.Semibold, color: Colors.TextSecondary, fontFamily: Font.Mono);

          Stack(gap: Space.Tick) {
            foreach (var m in mine) {
              Link(href: "/c/" + m.Channel.Id, textDecoration: TextDecoration.None, color: Colors.OnBg) {
                Row(gap: Space.Step, align: Align.Center, justify: Justify.SpaceBetween, px: Space.Step, py: Space.Tick, rounded: Radius.Control) {
                  Row(gap: Space.Tick, align: Align.Center, minW: "0") {
                    Text("#", color: Colors.TextSecondary, fontFamily: Font.Mono);
                    Text(m.Channel.Name, fontSize: FontSize.Body);
                  }
                  if (visible.Where(p => p.Channel.Id == m.Channel.Id && p.SentAt > m.LastReadAt).Count() > 0) {
                    Box(bg: Colors.FillAccent, color: Colors.Surface1, rounded: Radius.Pill, px: Space.Step, py: "1px") {
                      Text(visible.Where(p => p.Channel.Id == m.Channel.Id && p.SentAt > m.LastReadAt).Count(),
                           fontSize: FontSize.Micro, fontWeight: FontWeight.Bold, fontFamily: Font.Mono);
                    }
                  }
                }
              }
            }
          }

          if (mine.Count == 0) {
            Text("none yet — make one below", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
          }
        }

        Stack(gap: Space.Step) {
          Text("NEW CHANNEL", fontSize: FontSize.Micro, fontWeight: FontWeight.Semibold, color: Colors.TextSecondary, fontFamily: Font.Mono);
          Field(label: "Name", value: newName, placeholder: "design");
          // A blank name has nothing to create, so the button SAYS so rather than accepting the press and
          // doing nothing — `ui-inert-affordance`, which could not see this shape until the guard-after-a-local
          // case was covered.
          Button("Create", onPress: Create, tone: Tone.Ghost, size: Size.Sm, disabled: newName.Trim() == "");
        }

        Stack(gap: Space.Step) {
          Text("DIRECT MESSAGES", fontSize: FontSize.Micro, fontWeight: FontWeight.Semibold, color: Colors.TextSecondary, fontFamily: Font.Mono);

          Stack(gap: Space.Tick) {
            foreach (var mine in parties.Where(p => p.Person.Id == me.Id)) {
              Link(href: "/d/" + mine.Conversation.Id, textDecoration: TextDecoration.None, color: Colors.OnBg) {
                Row(gap: Space.Step, align: Align.Center, justify: Justify.SpaceBetween, px: Space.Step, py: Space.Tick, rounded: Radius.Control) {
                  Row(gap: Space.Tick, align: Align.Center, minW: "0") {
                    if (mine.Conversation.Title == "") {
                      foreach (var them in parties.Where(p => p.Conversation == mine.Conversation && p.Person.Id != me.Id)) {
                        Text(them.Person.DisplayName, fontSize: FontSize.Body);
                      }
                    }
                    if (mine.Conversation.Title != "") {
                      Text(mine.Conversation.Title, fontSize: FontSize.Body);
                    }
                  }
                  if (lines.Where(l => l.Conversation == mine.Conversation && l.SentAt > mine.LastReadAt).Count() > 0) {
                    Box(bg: Colors.FillAccent, color: Colors.Surface1, rounded: Radius.Pill, px: Space.Step, py: "1px") {
                      Text(lines.Where(l => l.Conversation == mine.Conversation && l.SentAt > mine.LastReadAt).Count(),
                           fontSize: FontSize.Micro, fontWeight: FontWeight.Bold, fontFamily: Font.Mono);
                    }
                  }
                }
              }
            }
          }

          if (parties.Where(p => p.Person.Id == me.Id).Count() == 0) {
            Text("none yet — pick somebody below", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
          }
        }

        Stack(gap: Space.Step) {
          Text("PEOPLE", fontSize: FontSize.Micro, fontWeight: FontWeight.Semibold, color: Colors.TextSecondary, fontFamily: Font.Mono);
          Stack(gap: Space.Tick) {
            foreach (var person in people.Where(u => u != me)) {
              Row(gap: Space.Step, align: Align.Center, minW: "0") {
                Button(person.DisplayName, onPress: () => Open(person), tone: Tone.Ghost, size: Size.Sm);
                if (person.Status != StatusKind.Available) {
                  Row(gap: Space.Tick, align: Align.Center, minW: "0") {
                    Box(w: "6px", h: "6px", rounded: Radius.Pill,
                        bg: person.Status == StatusKind.Busy ? Colors.FillDanger : Colors.FillMuted);
                    Text(person.StatusMessage != "" ? person.StatusMessage
                                                    : (person.Status == StatusKind.Busy ? "busy" : "away"),
                         fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
                  }
                }
              }
            }
          }
          if (people.Count < 2) {
            Text("nobody else has signed up yet", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
          }
        }

        Spacer();

        Row(gap: Space.Step, align: Align.Center, justify: Justify.SpaceBetween, borderTW: 1, border: Colors.Border, pt: Space.Beat) {
          Row(gap: Space.Step, align: Align.Center, minW: "0") {
            Avatar(initials: me.DisplayName, size: Size.Sm);
            Stack(gap: "0") {
              Text(me.DisplayName, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium);
              foreach (var self in people.Where(u => u.Id == me.Id)) {
                Row(gap: Space.Tick, align: Align.Center) {
                  Box(w: "6px", h: "6px", rounded: Radius.Pill,
                      bg: self.Status == StatusKind.Available ? Colors.FillSuccess
                        : (self.Status == StatusKind.Busy ? Colors.FillDanger : Colors.FillMuted));
                  Text(self.StatusMessage != "" ? self.StatusMessage
                     : (self.Status == StatusKind.Available ? "online"
                       : (self.Status == StatusKind.Busy ? "busy" : "away")),
                       fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
                }
              }
            }
          }
          Row(gap: Space.Tick, align: Align.Center) {
            ThemeToggle();
            IconButton("Sign out", SignOut) { Text("⏻"); }
          }
        }
      }

      Stack(gap: 0, grow: 1, minW: "0", minH: "100vh") {
        Outlet;
      }

      if (nudges.Count > dismissed) {
        Stack(gap: Space.Step, position: Position.Fixed, top: "18px", right: "18px", w: "320px", maxH: "70vh", overflowY: Overflow.Auto) {
          foreach (var n in nudges.Reverse().Take(nudges.Count - dismissed)) {
            Link(href: n.Href, textDecoration: TextDecoration.None, color: Colors.OnBg) {
              Stack(gap: Space.Tick, bg: Colors.Surface2, borderW: 1, border: Colors.Border, rounded: Radius.Card, px: Space.Beat, py: Space.Step,
                    shadow: "0 10px 30px rgba(0,0,0,0.35)") {
                Row(gap: Space.Step, align: Align.Center) {
                  Box(w: "6px", h: "6px", bg: Colors.FillAccent, rounded: Radius.Pill);
                  Text(n.From, fontSize: FontSize.Caption, fontWeight: FontWeight.Semibold);
                  if (n.Kind == NudgeKind.Mention) {
                    Text("mentioned you", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
                  }
                  if (n.Kind == NudgeKind.Direct) {
                    Text("messaged you", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono);
                  }
                }
                Text(n.Preview, fontSize: FontSize.Caption, color: Colors.TextSecondary);
              }
            }
          }
          Row(justify: Justify.End) {
            Button("Dismiss", onPress: Dismiss, tone: Tone.Ghost, size: Size.Sm);
          }
        }
      }
    }
  }
}