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

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