Osy#betaRun it on Osyrin: osyrin.com

Samples

wf-signup-invite

An invite-and-accept signup — a small reference for Osysharp.Invitations (D-DEMOSTOKITS1: what this demo proved is the kit now; the demo shows how an app invokes it). An admin invites an address with a role; the invitee answers the mailed link — or, if they already have an account, signs in and accepts on the board.

4 source files1 test file

The wf-signup-invite sample, running: Three invitations, and their budgets
Three invitations, and their budgets

Get it

$ osy init wf-signup-invite
$ osy launch

The app

app.osy17 lines
// An invite-and-accept signup — a small reference for Osysharp.Invitations (D-DEMOSTOKITS1: what this demo proved is the
// kit now; the demo shows how an app invokes it). An admin invites an address with a role; the invitee answers the
// mailed link — or, if they already have an account, signs in and accepts on the board.
app WfSignupInvite {
  use Osysharp.Ui;
  use Osysharp.Workflow;
  use Osysharp.Identity@0;
  use Osysharp.Mail@0;
  use Osysharp.EmailTemplates@0;
  use Osysharp.UserAccounts@1;
  use Osysharp.Organizations@0;
  use Osysharp.Invitations@2;
  model "model/**/*.osy";
  tests "tests/**/*.test.osy";
}

model/identity.osy23 lines
// Everything this app writes about its people: its roles, who may invite, and how its roles read. Signing in is
// Osysharp.UserAccounts'; inviting is Osysharp.Invitations' — this file only answers their questions.
using Osysharp.Identity;
using Osysharp.UserAccounts;
using Osysharp.Invitations;

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

policy IsAdmin => RoleGrant.Any(g => g.Member == user && g.Role == AppRole.Admin);
/// The Invitations kit's question: who invites people (and revokes and resends invitations).
policy MayInvite => IsAdmin;
/// The Organizations kit's question (it comes with Invitations): who looks after organisations — nobody here uses any.
policy ManagesOrganizations => IsAdmin;

// Closed to sign-up: an invitation is the way in. Whoever sets the app up (`osy user add`) is its admin.
app.Accounts = new AccountsSetup { AppName = "Invitations", SignupOpen = false, FirstAccountRole = AppRole.Admin };

// The roles an invitation may carry, by the names people read.
app.Roles = new RolesSetup { Offered = [
  new RoleChoice { Role = AppRole.Member, Label = "Member", Description = "Works here." },
  new RoleChoice { Role = AppRole.Admin, Label = "Admin", Description = "Invites people, and runs the place." },
] };
model/theme.osy73 lines
// THE SHARED DEMO THEME — canonical copy. Every demo listed in `DemoSharedThemeTests` holds a
// byte-identical copy as its theme (`model/look/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 `Osysharp.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: "#636B75", dark: "#8A94A0");   // the quietest text on the page — still WCAG AA (4.5:1) on Bg, Surface and Muted: #7C848E read 3.3–3.8:1, and the kit draws every field label in it (the UX sweep, test:visual)

    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/board.osy57 lines
// THE BOARD — an admin invites and watches who has not answered; anybody signed in sees the invitations waiting for
// THEM and accepts. The mailed link goes to the kit's own `/invite/{token}` page.
using Osysharp.Identity;
using Osysharp.UserAccounts;
using Osysharp.Invitations;

[Route("/")]
[Render(CSR)]
[Title("Invitations")]
component Board() {
  live var me = Session.CurrentUser;
  List<InvitationView> mine = [];
  string accepted = "";
  string problem = "";

  on mount { mine = MyInvitationViews(); }
  action Accept(Guid id) {
    problem = "";
    try { accepted = AcceptInvitationById(id); mine = MyInvitationViews(); } catch (Exception ex) { problem = ex.Message; }
  }
  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, borderBW: 1, border: Colors.Border) {
        Row(align: Align.Center, justify: Justify.Between, w: "100%", maxW: "820px", px: 5, h: "60px") {
          Text("Invitations", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold);
          Row(align: Align.Center, gap: 3) {
            Text(me == null ? "" : me.Email, fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Button("Sign out", onPress: SignOut, tone: Tone.Ghost, size: Size.Sm);
          }
        }
      }
      Row(justify: Justify.Center, align: Align.Start, grow: 1, minW: "0") {
        Stack(gap: 5, w: "100%", maxW: "820px", p: 5) {
          if (accepted != "") { Alert($"Welcome to {accepted}.", tone: Tone.Success); }
          if (problem != "") { Alert(problem, tone: Tone.Danger); }
          if (mine.Count > 0) {
            Card("Waiting for you") {
              foreach (var i in mine) {
                Row(gap: 3, align: Align.Center, label: i.Into) {
                  Text($"{i.Into}, from {i.From}" + (i.RoleName != "" ? $", as {i.RoleName}" : ""), grow: 1);
                  Button("Accept", onPress: () => Accept(i.Id), tone: Tone.Primary);
                }
              }
            }
          }
          Stack(canSee: MayInvite, gap: 5) {
            Card("Invite someone", subtitle: "They get a link by mail, and join with the role you choose.") { InviteForm(canSend: MayInvite); }
            Card("Not answered yet") { InvitationList(canChange: MayInvite); }
          }
        }
      }
    }
  }
}