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

Samples

wf-signup-invite

An invite-and-accept signup — send an invitation, then wait for the person to accept it.

9 source files2 test files

The wf-signup-invite sample, running.
Running, signed in, compiled from the source below.

Get it

$ osy init wf-signup-invite
$ osy launch

The app

app.osy9 lines
// An invite-and-accept signup — send an invitation, then wait for the person to accept it.
// The ONBOARDING / invitation workflow demo.
app WfSignupInvite {
  use Osyrin.Ui;
  model "model/**/*.osy";
  tests "tests/**/*.test.osy";
  data  "data/**/*.json";
}
model/actions.osy32 lines
// The board's verbs, as ordinary app functions.

// Gated by `[Authorize(u => u.Email == this.Item.Email)]` — an IDENTITY comparison. Nobody may accept on anyone
// else's behalf, admin included, and that is deliberate: an invitation accepted for you is not one you accepted.
// The IN-APP door, for an invitee who is already signed in and therefore already HAS an account.
void AcceptInvite(Invitation i) { Onboarding.RaiseAccept(i, null); }

void RevokeInvite(Invitation i) { Onboarding.RaiseRevoke(i); }

void InviteAddress(string email) {
  var me = Session.CurrentUser;
  new Invitation { Email = email, InvitedBy = me };
  UnitOfWork.Commit();
}

// THE ANONYMOUS DOOR — cf. `AcceptInvite(Invitation)` above, which is the in-app one. Whoever opens the emailed
// link has no account yet and cannot sign in, so `[AllowAnonymous]` is not a relaxation here: without it the server
// refuses the hand-off from the public accept page, the redeem never runs, and the visitor sees nothing at all.
[AllowAnonymous]
string AcceptInvitation(string token, string password) {
  try {
    Workflow.Redeem(token, "{\"passwordHash\": \"" + Security.HashPassword(password) + "\"}");
    return "";
  }
  catch (NotFoundException e) {
    return "This invitation link is not valid, or it has already been used.";
  }
  catch (ConflictException e) {
    return "This invitation is no longer open — it may have been withdrawn or it may have expired.";
  }
}
model/desk.osy61 lines
// THE DESK — who has not accepted, for how long, how often they have been chased, and whether the invitation ever
// missed its promise.

/// One invitation as the desk reads it. A `class`, so it is an in-memory projection with no table behind it.
class DeskRow {
  public Guid InvitationId;
  public string Email;
  public string InvitedByName;
  public InviteStatus Status;
  /// An invitation still waiting has a live clock; an accepted, revoked or expired one does not. Absence, not a zero
  /// that would draw as a fully-consumed budget and sort the calmest rows where the worst ones belong.
  public bool HasPromise;
  public TimeSpan Budget;
  public TimeSpan Remaining;
  public DateTime BreachesAt;
  /// A promise that was MISSED stays missed. When the deadline runs out the engine stamps the breach and RETIRES the
  /// clock, so a desk ordered on the live figures alone drops the one row everybody needs to see at the exact moment
  /// it starts mattering. `EverBreached` is read from the audit trail, so it survives the clock being retired.
  public bool EverBreached;
  /// HOW MANY TIMES THIS PERSON HAS BEEN CHASED — counted off `AuditKind.Reminded` rows on the run's own timeline.
  /// A reminder changes no state, so the trail is the only place it is observable, and it is the same artifact ops
  /// would open to answer the same question.
  public int Nudges;
  public DateTime InvitedAt;
}

/// The desk, on the server. `Workflow.WorkByItem<Invitation>()` is ONE ROW PER INVITATION — the per-slot read would
/// be right here too (this workflow waits on one thing at a time), but a board is per item and writing it per slot is
/// how a board starts showing an item once per open wait the day a second slot is added.
List<DeskRow> Desk() {
  var rows = new List<DeskRow>();
  var work = Workflow.WorkByItem<Invitation>().Include(r => r.Item).ToList();

  foreach (var i in Invitation.Include(x => x.InvitedBy).OrderByDescending(x => x.CreatedAt)) {
    var row = new DeskRow {
      InvitationId = i.Id, Email = i.Email, InvitedByName = i.InvitedBy.Name, Status = i.Status,
      HasPromise = false, Budget = TimeSpan.Zero, Remaining = TimeSpan.Zero,
      BreachesAt = i.CreatedAt,       // a zero countdown; every reader gates on HasPromise, never on this date
      EverBreached = false, Nudges = 0, InvitedAt = i.CreatedAt,
    };

    foreach (var w in work) {
      if (w.Item.Id != i.Id) { continue; }
      if (w.BreachesAt != null) {
        row.HasPromise = true;
        row.Budget     = w.Budget ?? TimeSpan.Zero;
        row.Remaining  = w.Remaining ?? TimeSpan.Zero;
        row.BreachesAt = w.BreachesAt;
      }
    }

    foreach (var a in Onboarding.For(i).Audit) {
      if (a.Kind == AuditKind.Reminded) { row.Nudges = row.Nudges + 1; }
      if (a.Kind == AuditKind.Breached) { row.EverBreached = true; }
    }

    rows.Add(row);
  }
  return rows;
}
model/identity.osy43 lines
// The demo's identity model — WHO the accounts are, and on what authority.

[Role] enum AppRole { Authenticator, Member, Admin }

entity RoleGrant {
  [Required] Account Grantee;
  [Required] AppRole Level = AppRole.Member;
  security {
    allow read when IsAuthenticated;
    allow create when IsAuthenticator;               // …the signup, for the first grant
    allow create, update, delete when IsAdmin;       // all three verbs, or the weakest is the way in
  }
}

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

[AuthMethod]
string Signup(string email, string password) {
  if (Account.Any(a => a.Email == email)) { return ""; }     // taken — sign in, or use your invitation link
  var a = new Account { Name = email, Email = email, PasswordHash = Security.HashPassword(password) };
  var grant = new RoleGrant { Grantee = a, Level = AppRole.Member };
  return Security.IssueJwt(a.Id, a.Email);
}

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

app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash };

app.AuthBootstrap = new AuthBootstrap {
  Role      = AppRole.Authenticator,
  Login     = Login,
  Signup    = Signup,
  LoginPage = LoginPage,
};
model/onboarding.osy78 lines
// ONBOARDING — an invitation desk. An admin invites an address; the invitee accepts, or the invitation chases them,
// and eventually dies. The role tier and the auth methods live in `identity.osy`, beside every other demo's.
enum InviteStatus { Pending, Active, Revoked, Expired }

[Principal]
entity Account {
  [Required, MaxLength(100)] string Name;
  [Required, MaxLength(200), Unique] string Email;
  [MaxLength(200)] string? PasswordHash;
  security {
    allow read when IsAuthenticated;
    allow read, create, update when IsAuthenticator;
    allow create when IsAdmin;
    allow update when IsAdmin;
    deny read PasswordHash when !IsAuthenticator;   // nobody but the auth flow ever sees the hash
  }
}

entity Invitation {
  [Required, MaxLength(200)] string Email;
  InviteStatus Status;
  [Required] Account InvitedBy;
  [MaxLength(500)] string? AcceptLink;
  security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; }
}

workflow Onboarding {
  Tracks    = Invitation.Status;
  Autostart = true;
  Initial   = Pending;

  [Authorize(u => u.Email == this.Item.Email)]
  event Accept(string? passwordHash);

  [Authorize(u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Admin))]
  event Revoke();

  on Revoke { goto Revoked; }

  state Pending {
    subscribe Accept() as Acceptance {
      Finished {
        Within = TimeSpan.FromDays(7);
        Remind Chase(After = TimeSpan.FromDays(3), ThenEvery = TimeSpan.FromDays(2)) {
          Nudge(this.Item);
        }
        Unfinished { goto Expired; }
      }
    }

    enter {
      this.Item.AcceptLink = Acceptance.CallbackUrl();
      Log.Information("invitation for {Email} — accept link {Link}", this.Item.Email, this.Item.AcceptLink);
    }

    on Acceptance(string? passwordHash) {
      var existing = Account.Where(a => a.Email == this.Item.Email).FirstOrDefault();
      if (existing == null) {
        if (passwordHash == null) {
          throw new ValidationException("accepting an invitation for an address with no account must set a password");
        }
        var minted = new Account { Name = this.Item.Email, Email = this.Item.Email, PasswordHash = passwordHash };
        new RoleGrant { Grantee = minted, Level = AppRole.Member };
      }
      this.Item.AcceptLink = null;
      goto Active;
    }
  }

  terminal success Active   { }
  terminal cancel  Revoked  { Message = "invitation revoked"; }
  terminal error   Expired  { Message = "invitation expired"; }
}

void Nudge(Invitation i) {
  Log.Information("invitation reminder — {Email} has not accepted yet", i.Email);
}
model/theme.osy73 lines
// THE SHARED DEMO THEME — canonical copy. Every demo listed in `DemoSharedThemeTests` holds a
// byte-identical `model/theme.osy`, and that test is what keeps them identical.
//
// ⚑ EVERY COLOUR HERE SHADOWS A TOKEN THE KIT ALREADY DECLARES, and that is the whole point.
//   The themes this replaced declared a PARALLEL palette — `Surface0/1/2`, `TextPrimary`,
//   `FillAccent/Success/Warning/Danger` — names `Osyrin.Ui` has never heard of. So the kit's own
//   `Primary`, `Success`, `Warning` and `Danger` stayed at their defaults in six demos: the moment
//   one of them reached for a kit control, that control painted itself indigo next to the demo's
//   blue. `FillAccent` was declared in all six and referenced by NONE — an accent colour that
//   existed only in the theme file.
//
// So the rule for a demo theme is: SHADOW a kit token, never invent a second name for it. A name
// the kit does not have (`Radius.Card`) is a real extension and fine; a second spelling of one it
// does have (`TextPrimary` for `OnBg`) is how an app ends up with two of everything.
//
// Excluded by design — these five own their look and must NOT adopt this file: arcade, ember,
// motion, gestures (each demonstrates a visual world) and Apps/recall-osy.

theme Demo {

  Colors {
    // A deep petrol blue, deliberately not the kit's indigo (#4F46E5) — a demo should look like a
    // considered app rather than an unstyled one, and the two should be distinguishable on sight.
    // It is the one saturated colour on the page; everything else is warm neutral, which is what
    // keeps "distinct" from turning into "loud".
    Primary   = Modes.Of(light: Palette.From("#125E7A"), dark: Palette.From("#57B8D6"));
    OnPrimary = Modes.Of(light: "#FFFFFF", dark: "#06181F");

    Bg      = Modes.Of(light: "#FAF9F7", dark: "#101317");   // the page — a touch warm, so cards read as lifted
    Surface = Modes.Of(light: "#FFFFFF", dark: "#171C22");   // a card, a row, a lane
    Muted   = Modes.Of(light: "#EFEEEA", dark: "#1E242B");   // an inline notice, a disabled field
    Border  = Modes.Of(light: "#E4E2DC", dark: "#28303A");

    OnBg          = Modes.Of(light: "#14181D", dark: "#E7EBF0");
    OnSurface     = Modes.Of(light: "#14181D", dark: "#E7EBF0");
    TextSecondary = Modes.Of(light: "#59626D", dark: "#9BA6B3");   // labels, timestamps, captions
    TextMuted     = Modes.Of(light: "#7C848E", dark: "#7E8894");   // the quietest text on the page

    Success = Modes.Of(light: Palette.From("#1D7A4C"), dark: Palette.From("#4EC98A"));
    Warning = Modes.Of(light: Palette.From("#B26A00"), dark: Palette.From("#E0A343"));
    Danger  = Modes.Of(light: Palette.From("#C1352B"), dark: Palette.From("#F0736A"));
  }

  // Names the kit does not have, so these are extensions rather than second spellings.
  Radius { Control = "8px"; Card = "12px"; }

  // ⚑ THE FONTS ARE WHAT MAKE THIS READ AS A DESIGNED APP RATHER THAN A DEFAULT ONE, and `Sans`
  //   shadows the kit's own token — so every kit control picks it up with no page edit at all.
  //   Both faces are vendored per app under `model/fonts/` and pinned in `osyrin.lock` (`osy font
  //   add`), because a webfont a page merely NAMES is a webfont that silently falls back.
    //   Only the faces the app SHIPS are named here — Mono is left at the kit default because no
  //   demo renders code, and naming an unshipped family is a silent fallback.
  //   Geist for the UI: a neutral grotesque with real character at small sizes, which is where a
  //   dense LOB screen lives. Fraunces for display: a warm variable serif, used ONLY on a page
  //   title. The pairing is the whole look — one voice for reading, one for announcing.
  Font {
    Sans    = "Geist, ui-sans-serif, -apple-system, \"Segoe UI\", Roboto, system-ui, sans-serif";
    Serif   = "Fraunces, \"Iowan Old Style\", Palatino, Georgia, ui-serif, serif";
  }

  FontSize   { Caption = "12px"; Body = "14px"; Subhead = "17px"; Title = "26px"; }
  FontWeight { Regular = 400; Medium = 500; Semibold = 600; }

  // ── APP EXTENSIONS ─────────────────────────────────────────────────────────────────────────────
  // Everything ABOVE this line is the shared theme, held byte-identical across the demos by
  // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may
  // NOT add a second spelling of one the kit already has; that is the defect this file replaced.
  // ── APP EXTENSIONS ─────────────────────────────────────────────────────────────────────────────
  // Everything ABOVE this line is the shared theme, held byte-identical across the demos by
  // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may
  // NOT add a second spelling of one the kit already has; that is the defect this file replaced.
}
model/pages/accept.osy59 lines
// THE LANDING PAGE — where the emailed link actually goes, and where an invitee with no account gets one.
using Osyrin.Ui;

[Page("/accept/{token}")]
[Render(CSR)]
[Title("Accept your invitation")]
[AllowAnonymous]                 // …the entire point: whoever opens this has no account and cannot sign in
component AcceptInvite(string token) {
  string password = "";
  string error = "";
  bool done = false;

  action Finish() {
    error = "";
    if (password.Length < 8) {
      error = "Choose a password of at least 8 characters.";
      return;
    }
    error = AcceptInvitation(token, password);
    if (error == "") { done = true; }
  }

  action GoSignIn() { Navigation.Go("/"); }

  render {
    Stack(gap: 0, minH: "100vh", bg: Colors.Bg, color: Colors.OnBg) {
      Row(justify: Justify.Center, align: Align.Center, grow: 1) {
        Box(bg: Colors.Surface, rounded: Radius.Card, p: 6, borderW: 1, border: Colors.Border, w: "100%", maxW: "440px") {

          if (done) {
            Stack(gap: 3) {
              Text("You're in.", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold);
              Text("Your account is ready and your password is set. Sign in to get started.",
                   fontSize: FontSize.Body, color: Colors.TextSecondary);
              Button("Go to sign in", onPress: GoSignIn);
            }
          } else {
            Stack(gap: 4) {
              Stack(gap: 2) {
                Text("Accept your invitation", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold);
                Text("Choose a password to finish setting up your account.",
                     fontSize: FontSize.Body, color: Colors.TextSecondary);
              }

              // `Field` IS the label + the input + the error, and it ties them together: the message is
              // announced as the input's own, not as unrelated red text somewhere below it. Hand-rolling the
              // three parts loses that association and leaves COLOUR as the only signal a field is invalid.
              Field("Password", value: password, placeholder: "at least 8 characters",
                    type: "password", error: error);

              Button("Accept and set my password", onPress: Finish, tone: Tone.Primary);
            }
          }
        }
      }
    }
  }
}
model/pages/board.osy128 lines
// THE INVITE DESK — the screen an admin runs an invitation flow from, and the screen an invitee accepts on.
using Osyrin.Ui;

[Page("/")]
[Render(CSR)]
[Title("Invitations")]
component Board() {
  live var invitations = Invitation.Include(i => i.InvitedBy).OrderByDescending(i => i.CreatedAt).ToList();

  DeskRow[] rows;
  on mount { rows = Desk(); }

  var me = Session.CurrentUser;

  string invitee = "dana@corp.test";

  action Accept(Invitation i) { AcceptInvite(i); rows = Desk(); }
  action Revoke(Invitation i) { RevokeInvite(i); rows = Desk(); }
  action Invite()  { InviteAddress(invitee); rows = Desk(); }
  action SignOut() { Session.SignOut(); }

  render {
    Stack(gap: 0, minH: "100vh", bg: Colors.Bg, color: Colors.OnBg) {

      Row(justify: Justify.Center, w: "100%", bg: Colors.Surface, borderW: 1, border: Colors.Border) {
        Row(align: Align.Center, justify: Justify.SpaceBetween, w: "100%", maxW: "820px", px: 5, h: "60px") {
          Text("Invitations", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold);
          Row(align: Align.Center, gap: 3) {
            Text(me.Email, fontSize: FontSize.Caption, color: Colors.TextSecondary);
            if (IsAdmin) { Text("Admin", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Success); }
            else         { Text("Member", fontSize: FontSize.Caption, color: Colors.TextSecondary); }
            Pressable(onClick: SignOut) { Text("Sign out", fontSize: FontSize.Caption, color: Colors.TextSecondary); }
          }
        }
      }

      Row(justify: Justify.Center, align: Align.Start, grow: 1, minW: "0") {
        Stack(gap: 5, w: "100%", maxW: "820px", p: 5) {

          Stack(gap: 2) {
            Text("Outstanding invitations", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold);
            Text("An invitation chases itself and expires on its own — a reminder and a deadline on the run, not a "
                 + "sweep and not a column.",
                 fontSize: FontSize.Caption, color: Colors.TextSecondary);

            foreach (var i in invitations) {
              Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
                Stack(gap: 3) {
                  Row(justify: Justify.SpaceBetween, align: Align.Center, gap: 4) {

                    Stack(gap: 1) {
                      Text(i.Email, fontSize: FontSize.Body, fontWeight: FontWeight.Semibold);
                      Text("invited by " + i.InvitedBy.Name, fontSize: FontSize.Caption, color: Colors.TextSecondary);
                    }

                    Row(gap: 3, align: Align.Center) {
                      if (i.Status == InviteStatus.Pending) {
                        Text("Pending", fontSize: FontSize.Caption, color: Colors.TextSecondary);
                      } else if (i.Status == InviteStatus.Active) {
                        Text("Accepted", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Success);
                      } else {
                        Text(i.Status, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Danger);
                      }

                      if (i.Status == InviteStatus.Pending) {
                        if (i.Email == me.Email) {
                          Button("Accept", onPress: () => Accept(i), tone: Tone.Primary);
                        }
                        if (IsAdmin) {
                          Button("Revoke", onPress: () => Revoke(i), tone: Tone.Danger);
                        }
                        if (i.Email != me.Email && !IsAdmin) {
                          Text("not yours", fontSize: FontSize.Caption, color: Colors.TextSecondary);
                        }
                      }
                    }
                  }

                  foreach (var r in rows) {
                    if (r.InvitationId == i.Id) {
                      Row(gap: 4, align: Align.Center, wrap: Wrapping.Wrap) {
                        if (r.HasPromise) {
                          Text("expires " + r.BreachesAt.ToString("D"), fontSize: FontSize.Caption, color: Colors.TextSecondary);
                        }
                        if (r.Nudges == 1) {
                          Text("chased once", fontSize: FontSize.Caption, color: Colors.TextSecondary);
                        } else if (r.Nudges > 1) {
                          Text("chased " + r.Nudges + " times", fontSize: FontSize.Caption, color: Colors.TextSecondary);
                        }
                        if (r.EverBreached) {
                          Text("deadline missed", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Danger);
                        }
                      }
                    }
                  }

                  if (i.Email == me.Email && i.AcceptLink != null) {
                    Stack(gap: 1, bg: Colors.Bg, rounded: Radius.Card, p: 3, borderW: 1, border: Colors.Border) {
                      Text("Your accept link — POST to it, no account needed", fontSize: FontSize.Caption, color: Colors.TextSecondary);
                      Text(i.AcceptLink, fontSize: FontSize.Caption, color: Colors.TextSecondary);
                    }
                  }
                }
              }
            }
          }

          Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
            Stack(gap: 3) {
              Text("Invite an address", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold);
              Text("The address does not need an account. The link mints one when it is answered — signing up "
                   + "afterwards sets its password rather than making a second account.",
                   fontSize: FontSize.Caption, color: Colors.TextSecondary);
              Row(gap: 3, align: Align.End) {
                Stack(gap: 1, grow: 1) {
                  Text("Email", fontSize: FontSize.Caption, color: Colors.TextSecondary);
                  Input(value: invitee, label: "Email", placeholder: "someone@corp.test");
                }
                Button("Invite", onPress: Invite, tone: Tone.Primary);
              }
            }
          }
        }
      }
    }
  }
}
model/pages/login.osy68 lines
// Sign-in — AND SIGN-UP, which in this demo is part of the flow rather than scaffolding: `Accept` is authorized
// over a principal, so an invitee has to become an account before they can accept their own invitation.
using Osyrin.Ui;

[Page("/login")]
[AllowAnonymous]
[Render(CSR)]
[Title("Invitations — sign in")]
component LoginPage() {
  string email = "ada@corp.test";
  string password = "demo1234";
  string problem = "";

  action SignIn() {
    var ticket = Login(email, password);
    if (ticket == "") { problem = "That email and password don't match anyone."; }
    else { Session.SignIn(ticket); }
  }

  action Register() {
    var ticket = Signup(email, password);
    if (ticket == "") { problem = "Couldn't create that account — it may already exist."; }
    else { Session.SignIn(ticket); }
  }

  render {
    Row(align: Align.Center, justify: Justify.Center, minH: "100vh", p: 4, bg: Colors.Bg, color: Colors.OnBg) {
      Stack(gap: 4, w: "100%", maxW: "420px") {

        Stack(gap: 1) {
          Text("Invitations", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold, letterSpacing: "-0.02em");
          Text("One rule is a role, the other is you. They look the same and are not.",
               fontSize: FontSize.Body, color: Colors.TextSecondary);
        }

        Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border) {
          Stack(gap: 3) {
            Stack(gap: 1) {
              Text("Email", labelFor: emailBox, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary);
              Input(value: email, id: emailBox, placeholder: "you@acme.test");
            }
            Stack(gap: 1) {
              Text("Password", labelFor: passwordBox, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary);
              Input(value: password, id: passwordBox, type: "password");
            }
            if (problem != "") {
              Box(bg: Colors.Muted, rounded: Radius.Control, px: 3, py: 2) {
                Text(problem, fontSize: FontSize.Caption, color: Colors.Danger);
              }
            }
            Button("Sign in", onPress: SignIn, tone: Tone.Primary);
            Button("Create this account", onPress: Register, tone: Tone.Primary);
          }
        }

        Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
          Stack(gap: 2) {
            Text("Seeded people — password demo1234", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary);
            Text("ada@corp.test — the Admin GRANT: may revoke ANY invite", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("alice@corp.test — a Member: may accept HER invite, and no other", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("mallory@corp.test — a Member with no invite: may accept nothing", fontSize: FontSize.Caption, color: Colors.TextSecondary);
          }
        }
      }
    }
  }
}