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

Samples

agent-expenses

agent-expenses — an expense system where agents do real work, and every cent is attributed to an outcome you can

17 source files1 test file

The agent-expenses sample, running.
Running, signed in, compiled from the source below.

Get it

$ osy init agent-expenses
$ osy launch

The app

app.osy10 lines
// agent-expenses — an expense system where agents do real work, and every cent is attributed to an outcome you can
// judge. The demo that drives the agent capability (plan: Docs/OsySharp/agent_expenses_demo_plan.md).
app AgentExpenses {
  use Osyrin.Storage;
  use Osyrin.Ui;
  use Osyrin.Agents;
  model "model/**/*.osy";
  tests "tests/**/*.test.osy";
}
model/auth.osy50 lines
// Auth — login/signup, adapted from new_admin with the OAuth flows removed (owner, 2026-07-28).

// Verify the password against the stored hash, then issue a signed ticket. No match → no ticket, so the client stores
// nothing and stays anonymous. The armed Authenticator may read PasswordHash (the field mask exempts it).
[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)) { StampLogin(u); return Security.IssueJwt(u.Id, u.Email); }
  return "";
}

void StampLogin(User u) { u.LastLoginAt = DateTime.UtcNow; }

[AuthMethod]
string Signup(string email, string password, string displayName) {
  bool isFirst = User.Count() == 0;   // BEFORE the create
  var u = new User {
    Email = email,
    PasswordHash = Security.HashPassword(password),
    DisplayName = displayName,
  };
  GrantSignupRoles(u, isFirst);
  StampLogin(u);                      // sign up == signed in → this counts as the first login
  return Security.IssueJwt(u.Id, u.Email);
}

void GrantSignupRoles(User u, bool isFirst) {
  var employee = new RoleGrant { User = u, Role = Role.Employee };
  if (isFirst) {
    var finance = new RoleGrant { User = u, Role = Role.Finance };
  }
}

app.AuthBootstrap = new AuthBootstrap {
  Role      = Role.Authenticator,   // the ephemeral principal login/signup run as
  Login     = Login,
  Signup    = Signup,
  LoginPage = LoginPage,            // where an unauthenticated visit to a protected page is redirected
};

app.Ui = new AppUi {
  ErrorSurface      = ErrorPage,        // a page failed to load for an unexpected reason
  ForbiddenSurface  = ForbiddenPage,    // signed in, but not yours to see
  NotFoundSurface   = NotFoundPage,     // no route matched
  ConnectionSurface = OfflineOverlay,   // the server dropped mid-session
  PendingDelayMs   = 200,
  PendingMinShowMs = 300,
};
model/expenses.osy120 lines
using Osyrin.Storage;   // FileAsset — a receipt image is a first-class file, uploaded the file-manager way

enum Category {
  [Label("Travel")]        Travel,
  [Label("Meals")]         Meals,
  [Label("Accommodation")] Accommodation,
  [Label("Software")]      Software,
  [Label("Other")]         Other,
}

enum Decision { Approve, Reject }

enum ReportStatus {
  /// Being assembled by the employee — lines added, receipts attached. Not yet anyone else's problem.
  [Label("Draft")] Draft,
  /// Submitted; the approval chain is running.
  [Label("In approval")] Approvals,
  [Label("Approved")] Approved,
  [Label("Rejected")] Rejected,
}

/// One expense report — the unit a human submits, and the unit whose cost-to-value the demo argues about.
entity ExpenseReport {
  [Required("Give the report a name."), MaxLength(140, "Keep the name under 140 characters.")] string Title;
  [Required("A report belongs to an employee.")] User Employee;
  ReportStatus Status = ReportStatus.Draft;
  /// Set when the employee submits — the clock the approval SLAs run against.
  DateTime? SubmittedAt;
  /// Who rejected it, when the chain ends that way.
  User RejectedBy;
  [ForeignKey(Report)] ExpenseLine[] Lines;
  [ForeignKey(Report)] Approval[] Approvals;
  security {
    allow read, create, update where Employee == user;                 // your own reports
    allow read where Employee.Manager == user;                          // your reports' manager
    allow read when IsFinance;                                          // the finance desk sees everything
    allow read, update when IsApprover;                                 // an approver acts on what is assigned to them
    allow create, update when IsFinance;
  }
}

/// One line on a report.
entity ExpenseLine {
  [Required] ExpenseReport Report;
  [Required, MaxLength(140)] string Merchant;
  decimal Amount = 0;
  Category Category = Category.Other;
  DateTime SpentOn;
  /// The receipt image or PDF. Null until one is attached — an employee can enter a line by hand and add it later.
  FileAsset Receipt;
  /// TRUE once the values above came from (or were confirmed against) the receipt rather than typed blind. This is
  /// the field the extraction agent writes, and the one an approver looks at to know how much to trust the row.
  bool FromReceipt = false;
  security {
    allow read, create, update, delete where Report.Employee == user;
    allow read where Report.Employee.Manager == user;
    allow read when IsFinance;
    allow read, update when IsApprover;
    allow create, update when IsFinance;   // the same on-behalf-of rule as the report itself
  }
}

/// One approval decision, appended as each slot is satisfied. Append-only by design: the audit is the history, and a
/// correction is a NEW fact, never a rewritten one.
entity Approval {
  [Required] ExpenseReport Report;
  [Required] User By;
  DateTime At;
  security {
    allow read where Report.Employee == user;
    allow read when IsFinance;
    allow read, create when IsApprover;
  }
}

workflow ReportApproval {
  Tracks    = ExpenseReport.Status;
  Autostart = this.Item.SubmittedAt != null;
  Initial   = Approvals;

  event Decide(Decision decision);

  state Approvals {
    subscribe Decide(Decision decision) as Manager {
      Assignee = this.Item.Employee.Manager;
      Finished { Within = TimeSpan.FromDays(2); Unfinished { goto Rejected; } }
    }

    subscribe Decide(Decision decision) as Finance {
      Candidates = u => RoleGrant.Any(g => g.User == u && g.Role == Role.Finance);
      Assigned   { Within = TimeSpan.FromHours(4); }
      Finished   { Within = TimeSpan.FromDays(2); Unfinished { goto Rejected; } }
    }

    subscribe Decide(Decision decision) as Cfo {
      When       = this.Item.Lines.Sum(l => l.Amount) > 10000;
      After      = [Manager, Finance];
      Candidates = u => RoleGrant.Any(g => g.User == u && g.Role == Role.Cfo);
      Finished   { Within = TimeSpan.FromDays(3); Unfinished { goto Rejected; } }
    }

    on Decide(Decision decision, Slot slot) {
      when (decision == Decision.Reject) {
        this.Item.RejectedBy = slot.Assignee;
        goto Rejected;                       // any rejection ends it — a later slot never opens
      }
      default {
        new Approval { Report = this.Item, By = slot.Assignee, At = DurableClock.Now };
      }
    }

    on Complete { goto Approved; }           // every slot that EXISTS is satisfied
  }

  terminal success Approved { }
  terminal error   Rejected { Message = "expense report rejected"; }
}

app.DefaultCulture = "en-US";
model/extraction.osy42 lines
// Reading the receipt — the first piece of work an agent does that a person was doing by hand.

using Osyrin.Storage;

app.Secrets = [
  new Secret("Anthropic"),   // value injected out-of-band; local dev: `.secrets`
];

app.DefaultModel = new LlmConfig {
  Provider = LlmProvider.Anthropic,
  Model    = "claude-haiku-4-5-20251001",
  ApiKey   = Secret.Anthropic,
};

/// What a receipt states. Every field is optional in practice: a model that cannot find a value leaves it unset rather
/// than inventing one, which is what lets the caller merge the result over whatever the row already had.
class ReceiptFields {
  /// The merchant as printed on the receipt — the trading name, not a legal entity.
  public string? Merchant;
  /// The TOTAL paid, including tax and tip. Not the subtotal, and not a single line of the receipt.
  public decimal? Amount;
  /// The date of the purchase as printed.
  public DateTime? SpentOn;
}

/// Read a line's receipt and fill in what the document actually says.
void ReadReceipt(ExpenseLine line) {
  if (line.Receipt == null) {
    throw new Exception("This line has no receipt to read. Attach one first.");
  }

  var read = Llm.Extract<ReceiptFields>(line.Receipt, attributeTo: line);

  var gotSomething = false;
  if (read.Merchant != null && read.Merchant != "") { line.Merchant = read.Merchant; gotSomething = true; }
  if (read.Amount != null) { line.Amount = read.Amount; gotSomething = true; }
  if (read.SpentOn != null) { line.SpentOn = read.SpentOn; gotSomething = true; }

  line.FromReceipt = gotSomething;
  UnitOfWork.Commit();
}
model/functions.osy48 lines
// `use Osyrin.Storage;` in app.osy is the REFERENCE (the version pin, the File.* path store); this is the IMPORT
// that brings `UploadedFile` / `FileAsset` into bare scope — the same split as a package reference and a using in C#.
using Osyrin.Storage;

/// Attach a just-uploaded receipt to a line.
void AttachReceipt(ExpenseLine line, UploadedFile f) {
  var bytes = File.ReadAllBytes(f.Path);
  var asset = new FileAsset {
    Name = f.FileName,
    MimeType = f.ContentType,
    StorageMode = "inline",
    InlineData = bytes,
  };
  line.Receipt = asset;
  UnitOfWork.Commit();
}

void SeedDemoData() {
  if (ExpenseReport.Any()) { return; }

  var pw = Security.HashPassword("Ledger123!");

  var mia = new User { Email = "mia@ledger.demo", DisplayName = "Mia Andersson", PasswordHash = pw };
  new RoleGrant { User = mia, Role = Role.Employee };
  new RoleGrant { User = mia, Role = Role.Manager };

  var otto = new User { Email = "otto@ledger.demo", DisplayName = "Otto Lindqvist", PasswordHash = pw };
  new RoleGrant { User = otto, Role = Role.Employee };
  new RoleGrant { User = otto, Role = Role.Finance };

  var cleo = new User { Email = "cleo@ledger.demo", DisplayName = "Cleo Nyberg", PasswordHash = pw };
  new RoleGrant { User = cleo, Role = Role.Employee };
  new RoleGrant { User = cleo, Role = Role.Cfo };

  var sam = new User { Email = "sam@ledger.demo", DisplayName = "Sam Okafor", PasswordHash = pw, Manager = mia };
  new RoleGrant { User = sam, Role = Role.Employee };

  var lisbon = new ExpenseReport { Title = "Lisbon client visit, March", Employee = sam };
  new ExpenseLine { Report = lisbon, Merchant = "TAP Air Portugal", Amount = 412.60m, Category = Category.Travel, SpentOn = DateTime.UtcNow };
  new ExpenseLine { Report = lisbon, Merchant = "Hotel Baixa", Amount = 268.00m, Category = Category.Accommodation, SpentOn = DateTime.UtcNow };
  new ExpenseLine { Report = lisbon, Merchant = "Cervejaria Ramiro", Amount = 84.50m, Category = Category.Meals, SpentOn = DateTime.UtcNow };

  var tools = new ExpenseReport { Title = "Design tooling, Q1", Employee = sam };
  new ExpenseLine { Report = tools, Merchant = "Figma", Amount = 180.00m, Category = Category.Software, SpentOn = DateTime.UtcNow };

  UnitOfWork.Commit();
}
model/identity.osy57 lines
// Identity — the principal, the role vocabulary, and the by-shape grant table.
[Role] enum Role {
  /// The ephemeral principal `Login`/`Signup` run as. Never granted to a person.
  Authenticator,
  /// Submits expenses. The floor every signup lands on — no account is ever role-less (a user with no grant is
  /// denied by RLS everywhere, and cannot even read its own row).
  Employee,
  /// Approves their reports' first stage. Managerial approval is by the `Manager` LINK on User, not by this role —
  /// the role says "may approve at all", the link says "whose".
  Manager,
  /// Sees every report and every cost. The finance desk, and the audience for the cost-to-value screens.
  Finance,
  /// The third approval stage, reached only for large amounts.
  Cfo,
}

entity RoleGrant {
  [Required] User User;
  [Required] Role Role = Role.Employee;
  security {
    allow read when IsFinance;                  // finance sees who may do what
    allow read where User == user;              // and you can see your own
    allow create, update, delete when IsFinance;
    allow create when IsAuthenticator;          // signup seeds the Employee floor (leashed by Signup's body)
  }
}

policy IsAuthenticator => RoleGrant.Any(g => g.User == user && g.Role == Role.Authenticator);
policy IsFinance       => RoleGrant.Any(g => g.User == user && g.Role == Role.Finance);
policy IsApprover      => RoleGrant.Any(g => g.User == user && (g.Role == Role.Manager || g.Role == Role.Cfo)) || IsFinance;

[Principal]
entity User {
  [Required, MaxLength(255), Unique("An account with that email already exists.")] string Email;
  /// Salted hash — never the plaintext. Readable ONLY by the armed authenticator (the field mask below).
  [MaxLength(200)] string? PasswordHash;
  [MaxLength(200)] string? DisplayName;
  /// Who approves this person's expenses at the first stage. The workflow's `Manager` slot assignee.
  User Manager;
  /// Stamped on every successful authentication. Null = never signed in.
  DateTime? LastLoginAt;
  [ForeignKey(User)] RoleGrant[] RoleGrants;
  security {
    allow read when IsFinance;                     // the finance desk sees the directory
    allow read when IsAuthenticator;               // login/signup verify a credential
    allow read where Id == user.Id;                // you read your own profile
    allow read where Manager == user;              // a manager reads their own reports' profiles
    deny read PasswordHash when !IsAuthenticator;  // ONLY the auth flow ever reads the hash (field mask)
    allow create, update, delete when IsFinance;
    allow create when IsAuthenticator;             // signup creates the credential
    allow update where Id == user.Id;              // you edit your own profile
    allow update LastLoginAt when IsAuthenticator;
  }
}

app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash };
model/review.osy183 lines
// THE AGENT THAT REVIEWS A SUBMITTED REPORT — and the loop that lets it ask the person who filed it.

using Osyrin.Agents;

/// Reviews a submitted expense report against the travel policy, and asks the employee when it cannot tell.
agent Auditor {
  Purpose = "Review a submitted expense report against the travel policy and recommend approve or send-back.";
  Prompt  = """
    You review expense reports for a small company.

    For each line, judge whether the claim is plausible and within policy:
      • travel and accommodation are reimbursable in full;
      • meals are reimbursable up to 60 per person per day;
      • software is reimbursable when it is a work tool.

    A line whose amount was TYPED rather than read from a receipt is not wrong — it is unverified, and you should
    say so. A line whose receipt was read and disagrees with the typed amount IS a finding.

    When a line genuinely cannot be judged without the employee — an amount far outside policy with no explanation,
    a merchant that could be personal, a missing receipt on a large claim — ask them, once, in plain words. Do not
    ask about anything you can decide yourself; you are spending their hours, not yours.

    Finish by delivering a short document: your recommendation (approve, or send back), and the lines behind it.
    """;
  Model  = "claude-haiku-4-5-20251001";
  Memory = Persistent;
  Principal = new User { Email = "auditor@ledger.demo", DisplayName = "Auditor" };
  Roles = [Role.Finance];
  Loop  = ReviewExpenses;

  security { allow read Transcript when IsFinance; }
}

partial entity AgentTask {
  security {
    allow read when IsAuthenticated;   // that agent work happened, and what it cost, is ordinary information here
  }
}

partial entity AgentDeliverable {
  security { allow read when IsAuthenticated; }   // what the agent HANDED BACK — the point of the task existing
}

partial entity Agent {
  security { allow read when IsAuthenticated; }   // a task names its agent, so a task list cannot render without it
}

partial entity AgentTemplate  { security { allow read when IsFinance; } }
partial entity AgentInstruction { security { allow read when IsFinance; } }
partial entity Skill   { security { allow read when IsFinance; } }
partial entity Runbook { security { allow read when IsFinance; } }

/// One review of one report — an `AgentTask` of this app's own type.
entity ReportReview : AgentTask {
  security {
    allow read where Agent != null;         // any signed-in user may see that a review happened…
    allow read when IsFinance;              // …and finance sees the estate
    allow update when IsApprover;
  }
}

/// A QUESTION THE AGENT ASKED, and the answer when it comes.
entity AssistanceRequest {
  [Required] ExpenseReport Report;
  /// The task whose run is parked on this question — what `Auditor.Answer(...)` is given when the reply arrives.
  [Required] ReportReview Task;
  [Required, MaxLength(2000)] string Question;
  /// Null until the employee replies. Non-null is what closes the item; the run resumes on the same gesture.
  [MaxLength(2000)] string? Answer;
  DateTime AskedAt = DateTime.UtcNow;
  DateTime? AnsweredAt;
  security {
    allow read, update where Report.Employee == user;
    allow read when IsFinance;
    allow read where Report.Employee.Manager == user;
  }
}

/// THE LOOP the agent's work runs inside.
workflow ReviewExpenses {
  Tracks = ReportReview.Status;
  Autostart = true;
  Initial   = Running;

  /// Raised when the employee answers the agent's question. The workflow SLOT is what records who answered and how
  /// long they took, which is what makes "it waited 15 hours for Sam" answerable without storing a duration.
  event Answered(string text);

  state Running {
    enter {
      var report = ExpenseReport.Where(r => r.Id == this.Item.EntityId).FirstOrDefault();
      if (report == null) { goto Failed; }

      var turns = new List<Turn>();
      turns.Add(Turn.Context(DescribeReport(report)));
      turns.Add(Turn.User("Review this expense report and recommend approve or send-back."));

      var reply = Auditor.Ask(turns);

      if (reply.Parked) {
        OpenQuestion(report, this.Item, reply.Question ?? "");
        goto Waiting;
      }
      goto Completed;
    }
  }

  /// PARKED ON A PERSON. Everything in this state is POLICY — who is asked, how they hear about it, how long they
  /// get — and it is all the app's. The platform's half was making the run parkable at all.
  state Waiting {
    enter {
      Log.Information("expense report {Report}: the auditor is waiting on an answer", this.Item.EntityId);
    }

    subscribe Answered(string text) as Submitter {
      Assignee = ExpenseReport.Where(r => r.Id == this.Item.EntityId).FirstOrDefault().Employee;
      Finished { Within = TimeSpan.FromDays(2); Unfinished { goto Failed; } }
    }

    on Answered(string text, Slot slot) {
      var reply = Auditor.Answer(this.Item, text);

      CloseQuestion(this.Item, text);

      if (reply.Parked) {
        var report = ExpenseReport.Where(r => r.Id == this.Item.EntityId).FirstOrDefault();
        if (report != null) { OpenQuestion(report, this.Item, reply.Question ?? ""); }
        goto Waiting;
      }
      goto Completed;
    }
  }

  terminal success Completed { }
  terminal error   Failed { Message = "the review could not be completed"; }
}

/// What the agent is shown about the report.
string DescribeReport(ExpenseReport report) {
  var text = "Expense report: " + report.Title + "\n";
  text = text + "Filed by: " + (report.Employee.DisplayName ?? report.Employee.Email) + "\n\nLines:\n";
  foreach (var l in report.Lines) {
    text = text + "- " + l.Merchant
         + " | " + l.Category.ToString()
         + " | " + l.Amount.ToString("C")
         + " | " + l.SpentOn.ToString("d")
         + (l.Receipt == null ? " | NO RECEIPT" : (l.FromReceipt ? " | amount read from the receipt" : " | receipt attached, amount TYPED"))
         + "\n";
  }
  return text;
}

/// Put the agent's question in front of the employee. Called from the loop, so it runs as the platform rather than as
/// anybody in particular — which is right: nobody asked for this row, the agent did.
void OpenQuestion(ExpenseReport report, ReportReview task, string question) {
  var req = new AssistanceRequest { Report = report, Task = task, Question = question };
  UnitOfWork.Commit();
}

/// Record the answer against the open question, so the page stops offering to ask it again.
void CloseQuestion(ReportReview task, string text) {
  var open = AssistanceRequest.Where(r => r.Task == task && r.Answer == null).FirstOrDefault();
  if (open != null) {
    open.Answer = text;
    open.AnsweredAt = DateTime.UtcNow;
    UnitOfWork.Commit();
  }
}

/// Hand a just-submitted report to the auditor. Called from the submit gesture, beside the approval chain's own
/// autostart — the two run in parallel, which is the point: a human approver and an agent reviewer are looking at the
/// same report at the same time, and the agent's recommendation is there when the approver opens it.
void StartReview(ExpenseReport report) {
  var task = Auditor.StartTask<ReportReview>(
    "expense report submitted: " + report.Title,
    about: report);
}

/// The employee's answer, on its way back to the parked run.
void AnswerAuditor(AssistanceRequest request, string text) {
  ReviewExpenses.For(request.Task).Submitter.Answered(text);
}
model/shell.osy125 lines
// The APP SHELL. Ordinary Osy#: the platform ships the MECHANISM (a [Layout] + Outlet, the Navigation read surface,
// the style vocabulary) and this file is the policy.

using Osyrin.Ui;

enum NavState { Idle, Current }

[Layout]
component AppShell() {
  action SignOut() { Session.SignOut(); }

  variants { base { Display = Display.Flex; MinH = "100vh"; Bg = Colors.Surface0; Color = Colors.OnBg; } }

  render {
    Stack(gap: 0, grow: 1, minW: "0") {
      TopBar(Navigation.CurrentPath, SignOut);
      Row(justify: Justify.Center, align: Align.Start, grow: 1, minW: "0") {
        Stack(gap: 0, grow: 1, minW: "0", maxW: 1120, w: "100%", p: 5) {
          Outlet(retain: 8);   // the routed section; retained so navigating back keeps its state
        }
      }
    }
  }
}

/// The bar. Surface1, sticky, separated from the canvas by a SHADOW rather than a hairline — the same elevation-over-
/// outline choice the login card makes, and the clearest single tell that this is not the other demo.
[Composable] component TopBar(string currentPath, Action onSignOut) {
  variants { base { Position = Position.Sticky; Top = "0"; Z = 30; Bg = Colors.Surface1;
                    Shadow = "0 1px 2px rgba(15, 23, 41, 0.06), 0 8px 24px rgba(15, 23, 41, 0.06)"; } }
  render {
    Row(justify: Justify.Center, w: "100%") {
      Row(align: Align.Center, gap: 5, w: "100%", maxW: 1120, px: 5, h: "64px") {
        Brand();
        Nav(currentPath);
        Spacer();
        Pressable(onClick: onSignOut) {
          Text("Sign out", fontSize: FontSize.Body, color: Colors.TextSecondary);
        }
      }
    }
  }
}

[Composable] component Brand() {
  render {
    Row(gap: 2, align: Align.Center) {
      Row(align: Align.Center, justify: Justify.Center, w: 28, h: 28, rounded: Radius.Md, bg: Colors.FillAccent) {
        Text("L", fontSize: FontSize.Body, fontWeight: FontWeight.Semibold, color: Colors.OnAccent);
      }
      Text("Ledger", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold, color: Colors.TextPrimary, letterSpacing: "-0.02em");
    }
  }
}

/// The sections, as horizontal pills.
[Composable] component Nav(string currentPath) {
  render {
    Row(gap: 1, align: Align.Center) {
      NavItem("Expenses",    "/",          currentPath == "/");
      NavItem("Approvals",   "/approvals", currentPath == "/approvals");
      NavItem("Agent tasks", "/tasks",     Text.StartsWith(currentPath, "/tasks"));
    }
  }
}

/// One pill. Current = accent tint + accent text; idle = muted text that warms on hover.
[Composable] component NavItem(string label, string href, bool current) {
  render {
    Link(href: href) {
      Row(align: Align.Center, h: "34px", px: 3, rounded: Radius.Pill, transition: Motion.Tint,
          bg: current ? Colors.BgAccent : "transparent",
          color: current ? Colors.TextAccent : Colors.TextSecondary,
          fontSize: FontSize.Body, fontWeight: FontWeight.Medium) {
        Text(label);
      }
    }
  }
}

/// A page heading block: the title, and an optional line of context under it.
[Composable] component PageHead(string title, string subtitle) {
  render {
    Stack(gap: 1) {
      Text(title, fontSize: FontSize.Title, fontWeight: FontWeight.Semibold, color: Colors.TextPrimary, letterSpacing: "-0.02em");
      if (subtitle != "") { Text(subtitle, fontSize: FontSize.Body, color: Colors.TextSecondary); }
    }
  }
}

/// A content card. Rounded past a control radius and lifted on a shadow, with no border — the app's signature.
[Composable] component Card() {
  variants { base { Bg = Colors.Surface1; Rounded = Radius.Lg; Overflow = Overflow.Hidden;
                    Shadow = "0 1px 2px rgba(15, 23, 41, 0.04), 0 6px 20px rgba(15, 23, 41, 0.06)"; } }
  render { Stack(gap: 0) { Slot; } }
}

/// A status chip. Takes a TONE and its content — a `variants` block keyed on the enum parameter, which is the
/// idiomatic form (a style prop wants a declared token, and a `string` colour parameter is not one). Which tone a
/// given status deserves is decided at the CALL SITE, in the app's UI layer, never on the domain enum (metadata must
/// not dictate UI).
[Composable] component Chip(Tone tone) {
  variants {
    base { Display = Display.InlineFlex; H = "24px"; Px = "10px"; Rounded = Radius.Pill; FontSize = FontSize.Caption; FontWeight = FontWeight.Medium; WhiteSpace = WhiteSpace.Nowrap; }
    tone {
      Default { Bg = Colors.Muted;     Color = Colors.TextSecondary; }
      Primary { Bg = Colors.BgAccent;  Color = Colors.TextAccent; }
      Success { Bg = Colors.BgSuccess; Color = Colors.TextSuccess; }
      Warning { Bg = Colors.BgWarning; Color = Colors.TextWarning; }
      Danger  { Bg = Colors.BgDanger;  Color = Colors.TextDanger; }
      Ghost   { Bg = Colors.Muted;     Color = Colors.TextSecondary; }
    }
  }
  render { Row(align: Align.Center) { Slot; } }
}

/// A quiet empty state — a sentence that says what would be here and how it gets here.
[Composable] component Empty(string line) {
  render {
    Row(align: Align.Center, justify: Justify.Center, p: 5) {
      Text(line, fontSize: FontSize.Body, color: Colors.TextMuted, textAlign: TextAlign.Center);
    }
  }
}
model/theme.osy68 lines
// agent-expenses — the design tokens.

theme Ledger {

  Colors {
    Surface0 = Modes.Of(light: "#f7f8fa", dark: "#0b0f1a");   // page canvas — cool paper / deep ink
    Surface1 = Modes.Of(light: "#ffffff", dark: "#111726");   // in-flow card / sidebar / rail
    Surface2 = Modes.Of(light: "#ffffff", dark: "#161d30");   // panel / working area / raised card
    Surface3 = Modes.Of(light: "#ffffff", dark: "#1d2639");   // popover / menu / dropdown

    TextPrimary   = Modes.Of(light: "#0f1729", dark: "#e8ecf7");
    TextSecondary = Modes.Of(light: "#5b6478", dark: "#a3adc4");
    TextMuted     = Modes.Of(light: "#8b93a7", dark: "#6f7a94");
    TextDisabled  = Modes.Of(light: "rgba(15, 23, 41, 0.32)", dark: "rgba(232, 236, 247, 0.32)");

    Border         = Modes.Of(light: "#e8eaf0", dark: "#232c42");
    BorderStrong   = Modes.Of(light: "#d3d8e3", dark: "#2e3950");
    BorderStronger = Modes.Of(light: "#aeb6c7", dark: "#3d4a66");

    BgAccent     = Modes.Of(light: "#eceafd", dark: "#1b1b46");
    TextAccent   = Modes.Of(light: "#4338ca", dark: "#b3aaff");
    FillAccent   = Modes.Of(light: "#5b4bdb", dark: "#7c6cf0");
    BorderAccent = Modes.Of(light: "#c9c2f8", dark: "#3a3573");
    OnAccent     = "#ffffff";

    BgSuccess     = Modes.Of(light: "#e6f6ee", dark: "#0d2419");
    TextSuccess   = Modes.Of(light: "#116945", dark: "#6fd7a4");
    FillSuccess   = Modes.Of(light: "#12855a", dark: "#12855a");
    BorderSuccess = Modes.Of(light: "#b3e3cb", dark: "#1d4733");

    BgWarning     = Modes.Of(light: "#fdf2e0", dark: "#2a1e08");
    TextWarning   = Modes.Of(light: "#95590a", dark: "#f0bd6b");
    FillWarning   = Modes.Of(light: "#c1780f", dark: "#b7720e");
    BorderWarning = Modes.Of(light: "#f6d9a6", dark: "#553d12");

    BgDanger     = Modes.Of(light: "#fdecec", dark: "#2b1214");
    TextDanger   = Modes.Of(light: "#a52834", dark: "#f39098");
    FillDanger   = Modes.Of(light: "#d9394a", dark: "#c93a45");
    BorderDanger = Modes.Of(light: "#f6c4c8", dark: "#5a2429");

    FillPrimary = Modes.Of(light: "#0f1729", dark: "#e8ecf7");
    OnPrimary   = Modes.Of(light: "#ffffff", dark: "#0f1729");

    Bg      = Colors.Surface0;
    OnBg    = Colors.TextPrimary;
    Surface = Colors.Surface2;
    OnSurface = Colors.TextPrimary;
    Primary = Palette.From("#5b4bdb");   // the accent, as a RAMP — see the note below
    Muted   = Modes.Of(light: "#eef0f6", dark: "#1d2639");

    Danger  = Palette.From("#d9394a");
    Success = Palette.From("#12855a");
    Warning = Palette.From("#c1780f");
  }

  Font { Sans = "Geist, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", sans-serif";
         Mono = "\"Geist Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace"; }
  FontSize   { Caption = "12px"; Label = "13px"; Body = "14px"; Subhead = "17px"; Heading = "20px"; Title = "26px"; }
  FontWeight { Light = 300; Regular = 400; Medium = 500; Semibold = 600; }

  Density { ControlHeight = "38px"; RowHeight = "44px"; NavHeight = "38px"; CellPadY = "11px"; }

  Breakpoints { Compact = 680; Cozy = 1020; }
  Touch       { Min = "44px"; Dense = "40px"; }

  Motion { Fade = "opacity 0.14s ease"; Tint = "background 0.14s ease, color 0.14s ease"; }
}
model/pages/approvals.osy49 lines
// Approvals — what is waiting on me.

using Osyrin.Ui;

[Page("/approvals")]
[Layout(AppShell)]
[Title("Approvals")]
[Render(CSR)]
component ApprovalsPage() {
  live var pending = ExpenseReport.Include(r => r.Employee).Include(r => r.Lines).Where(r => r.Status == ReportStatus.Approvals);

  render {
    Stack(gap: 5) {
      PageHead("Approvals", "Reports waiting on a decision from you.");
      Card {
        Stack(gap: 0) {
          if (pending.Count == 0) { Empty("Nothing waiting. When a report needs your decision it appears here."); }
          foreach (var r in pending) { ApprovalRow(r); }
        }
      }
    }
  }
}

/// One waiting report.
[Composable] component ApprovalRow(ExpenseReport report) {
  live var total = report.Lines.Sum(l => l.Amount);

  action Open() { Navigation.Go("/report/" + report.Id); }

  render {
    Row(gap: 3, align: Align.Center, px: 4, h: "64px") {
      Pressable(onClick: Open, grow: 1) {
        Stack(gap: 0, minW: "0") {
          Text(report.Title, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, color: Colors.TextPrimary);
          Text(report.Employee.DisplayName ?? report.Employee.Email, fontSize: FontSize.Caption, color: Colors.TextMuted);
        }
      }
      Text(total.ToString("C"), fontFamily: Font.Mono, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, color: Colors.TextWarning, w: 110, textAlign: TextAlign.Right);
      Pressable(onClick: Open) {
        Row(align: Align.Center, justify: Justify.Center, h: "34px", px: 3, rounded: Radius.Md,
            bg: Colors.Muted, color: Colors.TextSecondary, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium) {
          Text("Review");
        }
      }
    }
  }
}
model/pages/login.osy162 lines
// The login page — the one public route ([AllowAnonymous], outside the shell, so it renders full-viewport).

using Osyrin.Ui;

[Page("/login")]
[AllowAnonymous]
[Title("Sign in")]
[Render(CSR)]
component LoginPage() {
  string email = "";
  string password = "";
  string displayName = "";
  bool signingUp = false;
  string authError = "";

  action PrimaryAction() {
    authError = "";
    var ticket = signingUp ? Signup(email, password, displayName) : Login(email, password);
    if (ticket == "") {
      authError = signingUp
        ? "That email is already registered — try signing in instead."
        : "Incorrect email or password.";
    } else {
      Session.SignIn(ticket);
    }
  }

  action ShowSignIn() { signingUp = false; }
  action ShowSignUp() { signingUp = true; }

  variants { base { MinH = "100vh"; Bg = Colors.Surface0; Color = Colors.TextPrimary; P = 4; } }

  render {
    Row(align: Align.Center, justify: Justify.Center) {
      AuthCard {
        Stack(gap: 5, align: Align.Center) {
          Wordmark();
          AuthTabs(signingUp, ShowSignIn, ShowSignUp);
        }

        Stack(gap: 1, align: Align.Center, minH: 68) {
          Text(signingUp ? "Create your account" : "Sign in to Ledger",
               fontSize: FontSize.Heading, fontWeight: FontWeight.Semibold, color: Colors.TextPrimary, textAlign: TextAlign.Center);
          Text(signingUp
                 ? "Submit expenses, approve your team's, and see exactly what the agents cost."
                 : "Welcome back.",
               fontSize: FontSize.Body, color: Colors.TextSecondary, textAlign: TextAlign.Center);
        }

        Stack(gap: 4) {
          if (signingUp) {
            AuthField("Your name") {
              Input(value: displayName, placeholder: "Mia Andersson", w: "100%", onEnter: PrimaryAction);
            }
          }
          AuthField("Email") {
            Input(value: email, type: "email", placeholder: "you@company.com", w: "100%", onEnter: PrimaryAction);
          }
          AuthField("Password") {
            Input(value: password, type: "password", placeholder: "••••••••", w: "100%", onEnter: PrimaryAction);
          }
          if (signingUp) {
            Stack(gap: 1) {
              PasswordRule("At least 8 characters", password.Length >= 8);
              PasswordRule("A number",              Regex.IsMatch(password, "[0-9]"));
              PasswordRule("An uppercase letter",   Regex.IsMatch(password, "[A-Z]"));
            }
          }
        }

        if (authError != "") { Text(authError, fontSize: FontSize.Body, color: Colors.TextDanger, textAlign: TextAlign.Center); }

        AuthPrimary(signingUp ? "Create account" : "Sign in", PrimaryAction);
      }
    }
  }
}

/// The card itself — the piece that carries this app's look. Rounded well past a control radius, lifted off the canvas
/// on a soft shadow rather than outlined by a hairline, and generously padded. new_admin's equivalent is the exact
/// opposite choice (flat, bordered, tight), which is the comparison we want the two demos to make.
[Composable] component AuthCard() {
  render {
    Stack(gap: 5, w: "100%", maxW: 420, p: 5, bg: Colors.Surface1, rounded: Radius.Lg,
          shadow: "0 1px 2px rgba(15, 23, 41, 0.04), 0 12px 32px rgba(15, 23, 41, 0.10)") {
      Slot();
    }
  }
}

/// The wordmark.
[Composable] component Wordmark() {
  render {
    Row(gap: 2, align: Align.Center) {
      Row(align: Align.Center, justify: Justify.Center, w: 32, h: 32, rounded: Radius.Md, bg: Colors.FillAccent) {
        Text("L", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold, color: Colors.OnAccent);
      }
      Text("Ledger", fontSize: FontSize.Title, fontWeight: FontWeight.Semibold, color: Colors.TextPrimary, letterSpacing: "-0.02em");
    }
  }
}

/// The Sign in / Sign up segmented control — a track holding two equal segments, exactly one active.
[Composable] component AuthTabs(bool signingUp, Action onSignIn, Action onSignUp) {
  render {
    Row(gap: 1, p: "4px", bg: Colors.Muted, rounded: Radius.Md, w: "100%") {
      AuthTab("Sign in", !signingUp, onSignIn);
      AuthTab("Sign up", signingUp,  onSignUp);
    }
  }
}

/// One segment. Active/inactive is expressed with CONDITIONAL inline styles, so one component covers both states
/// without a variants dance. The active segment lifts onto the card surface with its own small shadow.
[Composable] component AuthTab(string label, bool active, Action onClick) {
  render {
    Pressable(onClick: onClick, grow: 1) {
      Row(align: Align.Center, justify: Justify.Center, h: "36px", rounded: Radius.Md, transition: Motion.Tint,
          bg: active ? Colors.Surface1 : "transparent",
          color: active ? Colors.TextPrimary : Colors.TextMuted,
          shadow: active ? "0 1px 2px rgba(15, 23, 41, 0.10)" : "none",
          fontSize: FontSize.Body, fontWeight: FontWeight.Medium) {
        Text(label);
      }
    }
  }
}

/// A labelled field. The caption is written ONCE (at the call site, as the parameter) and it is a real `<label
/// for>`, which is what buys the click: pressing the words focuses the field. Before this the caller had to repeat
/// the caption as a `label:` on its own Input — two copies with nothing keeping them in step, and no click target.
[Composable] component AuthField(string label) {
  render {
    Stack(gap: 1) {
      Text(label, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary, labelFor: input);
      Slot(id: input);
    }
  }
}

/// One live password requirement.
[Composable] component PasswordRule(string label, bool ok) {
  render {
    Row(gap: 2, align: Align.Center) {
      Row(w: 6, h: 6, rounded: Radius.Pill, bg: ok ? Colors.FillSuccess : Colors.BorderStrong);
      Text(label, fontSize: FontSize.Caption, color: ok ? Colors.TextSuccess : Colors.TextMuted);
    }
  }
}

/// The ONE indigo action on the page.
[Composable] component AuthPrimary(string label, Action onClick) {
  render {
    Pressable(onClick: onClick, w: "100%") {
      Row(align: Align.Center, justify: Justify.Center, h: Density.ControlHeight, w: "100%", rounded: Radius.Md,
          bg: Colors.FillAccent, color: Colors.OnAccent, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, transition: Motion.Tint) {
        Text(label);
      }
    }
  }
}
model/pages/report-detail.osy227 lines
using Osyrin.Ui;
using Osyrin.Storage;   // the IMPORT for `UploadedFile` (app.osy's `use` is the reference)

[Page("/report/{id}")]
[Layout(AppShell)]
[Title("Report")]
[Render(CSR)]
component ReportDetailPage(Guid id) {
  var report = ExpenseReport.Single(r => r.Id == id);
  live var lines = ExpenseLine.Where(l => l.Report.Id == id);
  live var approvals = Approval.Include(a => a.By).Where(a => a.Report.Id == id);

  live var title = report?.Title ?? "Report";
  live var total = lines.Sum(l => l.Amount);
  live var draft = report != null && report.SubmittedAt == null;

  live var asks = AssistanceRequest.Where(q => q.Report.Id == id && q.Answer == null);
  live var answered = AssistanceRequest.Where(q => q.Report.Id == id && q.Answer != null);

  string newMerchant = "";
  decimal newAmount = 0;

  action AddNewLine() {
    if (newMerchant != "") {
      new ExpenseLine {
        Report = report,
        Merchant = newMerchant,
        Amount = newAmount,
        Category = Category.Other,
        SpentOn = DateTime.UtcNow,
      };
      newMerchant = "";
      newAmount = 0;
    }
  }

  string saveError = "";
  action Save() {
    saveError = "";
    try { UnitOfWork.Commit(); }
    catch (ValidationException ex) { if (ex.Violations.Count == 0) { saveError = ex.Message; } }
  }

  action Submit() {
    saveError = "";
    try {
      report.SubmittedAt = DateTime.UtcNow;
      UnitOfWork.Commit();
      StartReview(report);
    }
    catch (ValidationException ex) { if (ex.Violations.Count == 0) { saveError = ex.Message; } }
  }

  render {
    Stack(gap: 5) {
      Row(align: Align.Center) {
        Stack(gap: 1) {
          Text(title, fontSize: FontSize.Title, fontWeight: FontWeight.Semibold, color: Colors.TextPrimary, letterSpacing: "-0.02em");
          Row(gap: 2, align: Align.Center) {
            Text(total.ToString("C"), fontFamily: Font.Mono, fontSize: FontSize.Heading, fontWeight: FontWeight.Semibold, color: Colors.TextWarning);
            Text(lines.Count.ToString() + " lines", fontSize: FontSize.Body, color: Colors.TextMuted);
          }
        }
        Spacer();
        if (draft) {
          Pressable(onClick: Save) {
            Row(align: Align.Center, justify: Justify.Center, h: Density.ControlHeight, px: 4, rounded: Radius.Md,
                bg: Colors.Muted, color: Colors.TextPrimary, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, transition: Motion.Tint) {
              Text("Save");
            }
          }
          Pressable(onClick: Submit) {
            Row(align: Align.Center, justify: Justify.Center, h: Density.ControlHeight, px: 4, rounded: Radius.Md,
                bg: Colors.FillAccent, color: Colors.OnAccent, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, transition: Motion.Tint) {
              Text("Submit for approval");
            }
          }
        }
      }

      if (saveError != "") { Text(saveError, fontSize: FontSize.Body, color: Colors.TextDanger); }

      foreach (var q in asks) { AgentQuestionCard(q); }

      Card {
        Stack(gap: 0) {
          if (lines.Count == 0) { Empty("No lines yet. Add one below, then attach its receipt."); }
          foreach (var l in lines) { LineRow(l); }
        }
      }

      if (draft) {
        Card {
          Row(gap: 3, align: Align.Center, p: 4) {
            Input(value: newMerchant, label: "Merchant", placeholder: "Merchant", grow: 1, onEnter: AddNewLine);
            Input(value: newAmount, label: "Amount", placeholder: "0.00", w: 140, onEnter: AddNewLine);
            Pressable(onClick: AddNewLine) {
              Row(align: Align.Center, justify: Justify.Center, h: Density.ControlHeight, px: 4, rounded: Radius.Md,
                  bg: Colors.Muted, color: Colors.TextPrimary, fontSize: FontSize.Body, fontWeight: FontWeight.Medium) {
                Text("Add line");
              }
            }
          }
        }
      }

      if (answered.Count > 0) {
        Stack(gap: 3) {
          Text("Answered", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold, color: Colors.TextPrimary);
          Card {
            Stack(gap: 0) {
              foreach (var q in answered) {
                Stack(gap: 1, px: 4, py: 3) {
                  Text(q.Question, fontSize: FontSize.Body, color: Colors.TextSecondary);
                  Row(gap: 2, align: Align.Center) {
                    Chip(Tone.Success) { Text("You said"); }
                    Text(q.Answer ?? "", fontSize: FontSize.Body, color: Colors.TextPrimary);
                  }
                }
              }
            }
          }
        }
      }

      if (approvals.Count > 0) {
        Stack(gap: 3) {
          Text("Approvals", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold, color: Colors.TextPrimary);
          Card {
            Stack(gap: 0) {
              foreach (var a in approvals) {
                Row(gap: 3, align: Align.Center, px: 4, h: Density.RowHeight) {
                  Chip(Tone.Success) { Text("Approved"); }
                  Text(a.By.DisplayName ?? a.By.Email, fontSize: FontSize.Body, color: Colors.TextPrimary);
                  Spacer();
                  Text(a.At.ToString("d"), fontSize: FontSize.Caption, color: Colors.TextMuted);
                }
              }
            }
          }
        }
      }
    }
  }
}

/// One expense line. The RECEIPT column is the interesting one: it is either an upload affordance or a quiet
/// confirmation, and `FromReceipt` is what says whether the numbers beside it were read or typed.
[Composable] component LineRow(ExpenseLine line) {
  action Attached(UploadedFile f) { AttachReceipt(line, f); }

  string readError = "";
  action Read() {
    readError = "";
    try { ReadReceipt(line); }
    catch (Exception ex) { readError = ex.Message; }
  }

  render {
    Stack(gap: 1) {
      Row(gap: 3, align: Align.Center, px: 4, h: Density.RowHeight) {
        Stack(gap: 0, grow: 1, minW: "0") {
          Text(line.Merchant, fontSize: FontSize.Body, color: Colors.TextPrimary);
          Text(line.Category, fontSize: FontSize.Caption, color: Colors.TextMuted);
        }
        if (line.Receipt == null) { Chip(Tone.Default) { Text("No receipt"); } }
        else if (line.FromReceipt) { Chip(Tone.Success) { Text("Read from receipt"); } }
        else { Chip(Tone.Warning) { Text("Typed"); } }
        Text(line.Amount.ToString("C"), fontFamily: Font.Mono, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, color: Colors.TextPrimary, w: 110, textAlign: TextAlign.Right);
        if (line.Receipt != null && !line.FromReceipt) {
          Pressable(onClick: Read) {
            Row(align: Align.Center, justify: Justify.Center, h: "32px", px: 3, rounded: Radius.Md, bg: Colors.FillAccent,
                color: Colors.OnAccent, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, transition: Motion.Tint) {
              Text(Read.Pending ? "Reading…" : "Read receipt");
            }
          }
        }
        Upload(onUploaded: Attached) {
          Row(align: Align.Center, justify: Justify.Center, h: "32px", px: 3, rounded: Radius.Md, bg: Colors.Muted,
              color: Colors.TextSecondary, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium) {
            Text(line.Receipt == null ? "Attach receipt" : "Replace");
          }
        }
      }
      if (readError != "") {
        Text(readError, fontSize: FontSize.Caption, color: Colors.TextDanger, px: 4);
      }
    }
  }
}

/// THE AGENT ASKED YOU SOMETHING — and this is where you answer it.
[Composable] component AgentQuestionCard(AssistanceRequest request) {
  string reply = "";
  string sendError = "";

  action Send() {
    sendError = "";
    if (reply == "") { return; }
    try { AnswerAuditor(request, reply); reply = ""; }
    catch (Exception ex) { sendError = ex.Message; }
  }

  render {
    Card {
      Stack(gap: 3, p: 4) {
        Row(gap: 2, align: Align.Center) {
          Chip(Tone.Warning) { Text("The auditor is waiting"); }
          Spacer();
          Text(request.AskedAt.ToString("g"), fontSize: FontSize.Caption, color: Colors.TextMuted);
        }
        Text(request.Question, fontSize: FontSize.Body, color: Colors.TextPrimary);
        Row(gap: 3, align: Align.Center) {
          Input(value: reply, label: "Your answer", placeholder: "Your answer", grow: 1, onEnter: Send);
          Pressable(onClick: Send) {
            Row(align: Align.Center, justify: Justify.Center, h: Density.ControlHeight, px: 4, rounded: Radius.Md,
                bg: Colors.FillAccent, color: Colors.OnAccent, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, transition: Motion.Tint) {
              Text(Send.Pending ? "Sending…" : "Answer");
            }
          }
        }
        if (sendError != "") { Text(sendError, fontSize: FontSize.Caption, color: Colors.TextDanger); }
      }
    }
  }
}
model/pages/report-new.osy65 lines
// The report CREATE page — opened from "New report" on the list.

using Osyrin.Ui;

[Page("/report/new")]
[Layout(AppShell)]
[Title("New report")]
[Render(CSR)]
component ReportNewPage() {
  ExpenseReport draft;   // entity-typed, null until mount — no initializer

  var me = Session.CurrentUser;

  on mount { draft = new ExpenseReport { Title = "", Employee = me }; }

  string saveError = "";
  string titleError = "";
  action Create() {
    saveError = "";
    titleError = "";
    try {
      UnitOfWork.Commit();
      Navigation.Go("/report/" + draft.Id);
    }
    catch (ValidationException ex) {
      foreach (var v in ex.Violations) {
        if (v.Field == "Title") { titleError = v.Message; }
        else { saveError = v.Message; }
      }
      if (ex.Violations.Count == 0) { saveError = ex.Message; }
    }
  }
  action Cancel() { Navigation.Go("/"); }

  render {
    Stack(gap: 5) {
      PageHead("New report", "Name it, then add the receipts.");
      Card {
        Stack(gap: 4, p: 5) {
          Stack(gap: 1) {
            Text("What is this for?", labelFor: titleBox, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary);
            Input(value: draft.Title, id: titleBox, placeholder: "Lisbon client visit, March", w: "100%", onEnter: Create);
            if (titleError != "") { Text(titleError, fontSize: FontSize.Caption, color: Colors.TextDanger); }
          }
          if (saveError != "") { Text(saveError, fontSize: FontSize.Body, color: Colors.TextDanger); }
          Row(gap: 2, align: Align.Center) {
            Pressable(onClick: Create) {
              Row(align: Align.Center, justify: Justify.Center, h: Density.ControlHeight, px: 4, rounded: Radius.Md,
                  bg: Colors.FillAccent, color: Colors.OnAccent, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, transition: Motion.Tint) {
                Text("Create");
              }
            }
            Pressable(onClick: Cancel) {
              Row(align: Align.Center, justify: Justify.Center, h: Density.ControlHeight, px: 4, rounded: Radius.Md,
                  color: Colors.TextSecondary, fontSize: FontSize.Body) {
                Text("Cancel");
              }
            }
          }
        }
      }
    }
  }
}
model/pages/reports.osy62 lines
// Expenses — the app HOME. "When you log in you see your reports."

using Osyrin.Ui;

[Page("/")]
[Layout(AppShell)]
[Title("Expenses")]
[Render(CSR)]
component ReportsPage() {
  live var reports = ExpenseReport.ToList();

  action NewReport() { Navigation.Go("/report/new"); }
  action Seed() { SeedDemoData(); }
  action OpenReport(ExpenseReport r) { Navigation.Go("/report/" + r.Id); }

  render {
    Stack(gap: 5) {
      Row(align: Align.Center) {
        PageHead("Expenses", "Your reports, and anything you approve.");
        Spacer();
        // Seeding is a once-only act — `SeedDemoData` returns immediately if there is already a report — so the
        // control says that rather than accepting a second press and doing nothing (`ui-inert-affordance`).
        Pressable(onClick: Seed, canSee: IsFinance, disabled: reports.Count > 0) {
          Row(align: Align.Center, justify: Justify.Center, h: Density.ControlHeight, px: 4, rounded: Radius.Md,
              bg: Colors.Muted, color: Colors.TextSecondary, fontSize: FontSize.Body, fontWeight: FontWeight.Medium) {
            Text("Seed sample data");
          }
        }
        Pressable(onClick: NewReport) {
          Row(align: Align.Center, justify: Justify.Center, h: Density.ControlHeight, px: 4, rounded: Radius.Md,
              bg: Colors.FillAccent, color: Colors.OnAccent, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, transition: Motion.Tint) {
            Text("New report");
          }
        }
      }

      Card {
        if (reports.Count == 0) {
          Empty("No reports yet. Start one, add a receipt, and submit it for approval.");
        }
        DataGrid(
            label: "Expense reports",
          rows: reports,
          columns: [
            new GridColumn<ExpenseReport> { Name = "Title", Label = "Report", Value = r => r.Title, Title = true, Width = 300, Fill = true },
            new GridColumn<ExpenseReport> { Name = "SubmittedAt", Label = "Submitted", Value = r => r.SubmittedAt.ToString("d"), Secondary = true, Width = 160 },
            new GridColumn<ExpenseReport> { Name = "Status", Label = "Status", Value = r => r.Status.ToString(), Width = 160 }
          ],
          rowSelected: OpenReport
        ) {
          slot Status { r =>
            if (r.Status == ReportStatus.Approved)  { Chip(Tone.Success) { Text(r.Status); } }
            else if (r.Status == ReportStatus.Rejected) { Chip(Tone.Danger) { Text(r.Status); } }
            else if (r.Status == ReportStatus.Approvals) { Chip(Tone.Primary) { Text(r.Status); } }
            else { Chip(Tone.Default) { Text(r.Status); } }
          }
        }
      }
    }
  }
}
model/pages/surfaces.osy82 lines
// The system surfaces Ledger owns — the four occasions the platform would otherwise render a bare fallback for.
// Nominated via `app.Ui = new AppUi { … }` in app-config.osy.

using Osyrin.Ui;

[AllowAnonymous]
component ErrorPage() {
  render {
    Row(align: Align.Center, justify: Justify.Center, minH: "100vh", p: 4, bg: Colors.Surface0, color: Colors.TextPrimary) {
      Box(bg: Colors.Surface1, borderW: 1, border: Colors.Border, rounded: Radius.Lg, p: 5, maxW: "460px", w: "100%",
          shadow: "0 1px 3px rgba(0,0,0,0.08)") {
        Stack(gap: 3, align: Align.Center) {
          Text("Something went wrong", fontSize: FontSize.Heading, fontWeight: FontWeight.Semibold, textAlign: TextAlign.Center);
          Text("This page couldn't load. Reload to try again — if it keeps happening, the server log has the detail.",
               fontSize: FontSize.Body, color: Colors.TextSecondary, textAlign: TextAlign.Center);
          Link(href: "/") {
            Row(align: Align.Center, justify: Justify.Center, h: Density.ControlHeight, px: 4, bg: Colors.FillAccent, color: Colors.OnAccent,
                rounded: Radius.Md, fontSize: FontSize.Body, fontWeight: FontWeight.Medium) { Text("Back to reports"); }
          }
        }
      }
    }
  }
}

[AllowAnonymous]
component ForbiddenPage() {
  render {
    Row(align: Align.Center, justify: Justify.Center, minH: "100vh", p: 4, bg: Colors.Surface0, color: Colors.TextPrimary) {
      Box(bg: Colors.Surface1, borderW: 1, border: Colors.Border, rounded: Radius.Lg, p: 5, maxW: "460px", w: "100%",
          shadow: "0 1px 3px rgba(0,0,0,0.08)") {
        Stack(gap: 3, align: Align.Center) {
          Text("Not your report", fontSize: FontSize.Heading, fontWeight: FontWeight.Semibold, textAlign: TextAlign.Center);
          Text("You're signed in, but this isn't yours to see. An expense report is visible to the person who filed "
               + "it, their manager, and the finance desk.",
               fontSize: FontSize.Body, color: Colors.TextSecondary, textAlign: TextAlign.Center);
          Link(href: "/") {
            Row(align: Align.Center, justify: Justify.Center, h: Density.ControlHeight, px: 4, bg: Colors.FillAccent, color: Colors.OnAccent,
                rounded: Radius.Md, fontSize: FontSize.Body, fontWeight: FontWeight.Medium) { Text("Back to reports"); }
          }
        }
      }
    }
  }
}

[AllowAnonymous]
component NotFoundPage() {
  render {
    Row(align: Align.Center, justify: Justify.Center, minH: "100vh", p: 4, bg: Colors.Surface0, color: Colors.TextPrimary) {
      Box(bg: Colors.Surface1, borderW: 1, border: Colors.Border, rounded: Radius.Lg, p: 5, maxW: "460px", w: "100%",
          shadow: "0 1px 3px rgba(0,0,0,0.08)") {
        Stack(gap: 3, align: Align.Center) {
          Text("No such page", fontSize: FontSize.Heading, fontWeight: FontWeight.Semibold, textAlign: TextAlign.Center);
          Text("That address doesn't lead anywhere in Ledger.", fontSize: FontSize.Body, color: Colors.TextSecondary,
               textAlign: TextAlign.Center);
          Link(href: "/") {
            Row(align: Align.Center, justify: Justify.Center, h: Density.ControlHeight, px: 4, bg: Colors.FillAccent, color: Colors.OnAccent,
                rounded: Radius.Md, fontSize: FontSize.Body, fontWeight: FontWeight.Medium) { Text("Back to reports"); }
          }
        }
      }
    }
  }
}

[AllowAnonymous]
component OfflineOverlay() {
  render {
    Row(align: Align.Center, justify: Justify.Center, position: Position.Fixed, inset: "0", p: 4, bg: "rgba(15,23,42,0.55)", z: 9999) {
      Box(bg: Colors.Surface1, borderW: 1, border: Colors.Border, rounded: Radius.Lg, p: 5, maxW: "420px", w: "100%",
          shadow: "0 8px 24px rgba(0,0,0,0.18)") {
        Stack(gap: 3, align: Align.Center) {
          Text("Lost the server", fontSize: FontSize.Heading, fontWeight: FontWeight.Semibold, textAlign: TextAlign.Center);
          Text("Your unsaved work is still here. This clears itself the moment the connection is back.",
               fontSize: FontSize.Body, color: Colors.TextSecondary, textAlign: TextAlign.Center);
        }
      }
    }
  }
}
model/pages/task-detail.osy100 lines
// One agent task, WHILE IT RUNS — the screen the task spine exists to make.

using Osyrin.Ui;
using Osyrin.Agents;

[Page("/task/{id}")]
[Layout(AppShell)]
[Title("Agent task")]
[Render(CSR)]
component TaskDetailPage(Guid id) {
  live var task = ReportReview.Where(t => t.Id == id).Include(t => t.Agent).FirstOrDefault();

  render {
    Stack(gap: 5) {
      if (task == null) {
        Empty("No such task.");
      } else {
        TaskHeader(task);
        TaskFeed(task);
      }
    }
  }
}

/// What the task IS — and the one control that changes it.
[Composable] component TaskHeader(ReportReview task) {
  decimal cost = 0;
  on mount { cost = TaskCost(task.Id); }

  action StopWork() { StopTask(task.Id); }

  action OpenReport() {
    if (task.EntityId != null) { Navigation.Go("/report/" + task.EntityId.ToString()); }
  }

  render {
    Stack(gap: 3) {
      Row(align: Align.Center, gap: 3) {
        Stack(gap: 0, grow: 1, minW: "0") {
          Text(task.Title ?? "Review", fontSize: FontSize.Title, fontWeight: FontWeight.Medium, color: Colors.TextPrimary);
          Text(task.Trigger ?? "", fontSize: FontSize.Caption, color: Colors.TextMuted);
        }
        StatusChip(task.Status);
      }

      Row(gap: 3, align: Align.Center) {
        Text(task.Agent.Name, fontSize: FontSize.Caption, color: Colors.TextMuted);
        Text(cost.ToString("C"), fontFamily: Font.Mono, fontSize: FontSize.Caption, color: Colors.TextWarning, canSee: IsFinance);
        Spacer();

        Button("Open report", onPress: OpenReport, tone: Tone.Default);

        if (task.Status == AgentTaskStatus.Running || task.Status == AgentTaskStatus.Waiting) {
          Button("Stop", onPress: StopWork, tone: Tone.Danger, canSee: IsApprover);
        }
      }
    }
  }
}

/// THE LIVE FEED.
[Composable] component TaskFeed(ReportReview task) {
  live var steps = Progress(task.Id);

  render {
    Card {
      Stack(gap: 0) {
        if (steps.Count == 0) {
          Empty("Nothing recorded yet. Steps appear here as the agent works.");
        }
        foreach (var line in steps) {
          Row(gap: 3, align: Align.Center, px: 4, h: "40px") {
            Text(line, fontSize: FontSize.Body, color: Colors.TextPrimary);
          }
        }
      }
    }
  }
}

/// The producer. One `foreach` — it catches up on what already happened and then keeps going, and it ends by itself
/// when the task finishes, is stopped, or fails.
decimal TaskCost(Guid taskId) {
  var task = ReportReview.Where(t => t.Id == taskId).FirstOrDefault();
  if (task == null) { return 0; }
  return task.AllCalls.Sum(c => c.Cost);
}

void StopTask(Guid taskId) {
  var task = ReportReview.Where(t => t.Id == taskId).FirstOrDefault();
  if (task != null) { task.Stop(); }
}

stream<string> Progress(Guid taskId) {
  var task = ReportReview.Where(t => t.Id == taskId).FirstOrDefault();
  foreach (var step in task.Watch()) {
    yield return step.Text ?? "";
  }
}
model/pages/tasks.osy102 lines
// Agent tasks — what the agents did, what it cost, where each one is now, and how long it has been going.

using Osyrin.Ui;
using Osyrin.Agents;

[Page("/tasks")]
[Layout(AppShell)]
[Title("Agent tasks")]
[Render(CSR)]
component TasksPage() {
  bool allScope = false;

  live var tasks = ReportReview.Include(t => t.Agent).Include(t => t.Deliverables);

  action ShowMine() { allScope = false; }
  action ShowAll() { allScope = true; }

  render {
    Stack(gap: 5) {
      Row(align: Align.Center) {
        PageHead("Agent tasks",
                 "Every piece of agent work, what it delivered, and whether it is waiting on somebody.");
        Spacer();
        Row(gap: 1, p: "4px", bg: Colors.Muted, rounded: Radius.Md, canSee: IsFinance) {
          ScopeTab("Mine", !allScope, ShowMine);
          ScopeTab("Everyone", allScope, ShowAll);
        }
      }

      Card {
        Stack(gap: 0) {
          if (tasks.Count == 0) {
            Empty("No agent tasks yet. Submit a report and the auditor starts one — it appears here with what it delivered and whether it is waiting on somebody.");
          }
          foreach (var t in tasks) { TaskRow(t); }
        }
      }
    }
  }
}

/// One task. The row answers the three questions the whole spine exists for, left to right: what was it, where is it
/// now, and what did it hand back.
[Composable] component TaskRow(ReportReview task) {
  decimal cost = 0;
  on mount { cost = TaskCost(task.Id); }

  action Open() {
    Navigation.Go("/task/" + task.Id.ToString());
  }

  render {
    Pressable(onClick: Open) {
      Row(gap: 3, align: Align.Center, px: 4, h: "64px") {
        Stack(gap: 0, grow: 1, minW: "0") {
          Text(task.Title ?? "Review", fontSize: FontSize.Body, fontWeight: FontWeight.Medium, color: Colors.TextPrimary);
          Text(task.Trigger ?? "", fontSize: FontSize.Caption, color: Colors.TextMuted);
        }

        if (task.Deliverables.Count > 0) {
          Text(task.Deliverables.Count.ToString() + " delivered", fontSize: FontSize.Caption, color: Colors.TextMuted);
        }

        Text(cost.ToString("C"), fontFamily: Font.Mono, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, color: Colors.TextWarning,
             w: 90, textAlign: TextAlign.Right, canSee: IsFinance);

        StatusChip(task.Status);

        Text((int)((task.CompletedAt ?? DateTime.UtcNow) - task.StartedAt).TotalMinutes + " min",
             fontFamily: Font.Mono, fontSize: FontSize.Caption, color: Colors.TextMuted, w: 80, textAlign: TextAlign.Right);
      }
    }
  }
}

/// The task's state, in the app's own vocabulary.
[Composable] component StatusChip(AgentTaskStatus status) {
  render {
    if (status == AgentTaskStatus.Waiting)        { Chip(Tone.Warning) { Text("Waiting on you"); } }
    else if (status == AgentTaskStatus.Running)   { Chip(Tone.Default) { Text("Working"); } }
    else if (status == AgentTaskStatus.Completed) { Chip(Tone.Success) { Text("Done"); } }
    else if (status == AgentTaskStatus.Cancelled) { Chip(Tone.Default) { Text("Stopped"); } }
    else                                          { Chip(Tone.Danger)  { Text("Failed"); } }
  }
}

/// One scope tab. Same segmented-control shape as the login page's Sign in / Sign up toggle — the app has one idiom
/// for "pick exactly one of these", and it is reused rather than reinvented per screen.
[Composable] component ScopeTab(string label, bool active, Action onClick) {
  render {
    Pressable(onClick: onClick) {
      Row(align: Align.Center, justify: Justify.Center, h: "32px", px: 3, rounded: Radius.Md, transition: Motion.Tint,
          bg: active ? Colors.Surface1 : "transparent",
          color: active ? Colors.TextPrimary : Colors.TextMuted,
          shadow: active ? "0 1px 2px rgba(15, 23, 41, 0.10)" : "none",
          fontSize: FontSize.Body, fontWeight: FontWeight.Medium) {
        Text(label);
      }
    }
  }
}