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

Samples

wf-fanout-quorum

wf-fanout-quorum — the FAN-OUT + QUORUM workflow demo.

8 source files3 test files

The wf-fanout-quorum sample, running.
Running, signed in, compiled from the source below.

Get it

// not one of the apps the toolchain ships: this one lives in the
// repository. Clone it, then:
$ cd demo/wf-fanout-quorum
$ osy launch

The app

app.osy8 lines
// wf-fanout-quorum — the FAN-OUT + QUORUM workflow demo.
app WfFanoutQuorum {
  use Osyrin.Ui;
  model "model/**/*.osy";
  tests "tests/**/*.test.osy";
  data  "data/**/*.json";
}
model/actions.osy24 lines
// The boards' verbs, as ordinary app functions.

// ⚑ DESIGN REVIEW deposits by the slot's PER-INSTANCE alias. The fan-out is over a LITERAL enum list, so each
// instance has a static name — `.Architect`, `.Security`, `.Product` — and the caller says which hat they are voting
// as. Voting is not the same as being eligible: the deposit is refused unless the acting principal holds that grant.
void VoteAsArchitect(Design d, Decision decision, string comment) {
  DesignReview.For(d).Architect.Vote(decision, comment);
}
void VoteAsSecurity(Design d, Decision decision, string comment) {
  DesignReview.For(d).Security.Vote(decision, comment);
}
void VoteAsProduct(Design d, Decision decision, string comment) {
  DesignReview.For(d).Product.Vote(decision, comment);
}

void SubmitReview(Paper p, Decision decision) {
  PeerReview.For(p).Referees.Review(decision);
}

void RaiseDesign(string title) {
  new Design { Title = title };
  UnitOfWork.Commit();
}
model/design_review.osy56 lines
// DESIGN REVIEW — N-of-M rebuilt on `Requires` (the naked count is gone). Three symmetric voters; the state does not
// advance until a quorum of APPROVALS holds. Casting a vote always works — it just gets RECORDED as data.
// (Design reasoning: workflow_design/approvals.osy, the DesignReview block + #9.)
enum Decision     { Approve, Reject }
enum DesignStatus { Gathering, Accepted, Rejected }

entity Design {
  [Required, MaxLength(200)] string Title;
  DesignStatus Status;
  [ForeignKey(Design)] Vote[] Votes;
  security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; }
}

/// Votes are DATA — auditable, queryable, displayable. Not engine internals.
entity Vote {
  [Required] Design Design;
  [Required] User   Voter;
  Decision Decision = Decision.Approve;
  [MaxLength(500)] string Comment;
  security { allow read when IsAuthenticated; allow create when IsAuthenticated; }
}

workflow DesignReview {
  Tracks    = Design.Status;
  Autostart = true;
  Initial   = Gathering;

  event Vote(Decision decision, string comment);

  state Gathering {
    Expire = TimeSpan.FromDays(7);

    subscribe Vote(Decision decision, string comment) as Voters
      foreach (AppRole r in [AppRole.Architect, AppRole.Security, AppRole.Product]) {
        Candidates = u => RoleGrant.Any(g => g.Grantee == u && g.Level == r);
      }

    on Vote(Decision decision, string comment, Slot slot) {
      new Vote { Design = this.Item, Voter = slot.Assignee, Decision = decision, Comment = comment };

      when (this.Item.Votes.Count(v => v.Decision == Decision.Reject) >= 2) { goto Rejected; }
    }

    Requires {
      Quorum { Must    = this.Item.Votes.Count(v => v.Decision == Decision.Approve) >= 2;
               Message = "Two of three approvals are required."; }
    }

    on Complete { goto Accepted; }        // fires when the REQUIREMENTS hold — the third never has to vote
    on Expire   { goto Rejected; }
  }

  terminal success Accepted { }
  terminal error   Rejected { Message = "design rejected"; }
}
model/identity.osy57 lines
// The demo's identity model — WHO the people are, and on what authority.

[Role] enum AppRole { Authenticator, Architect, Security, Product }

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

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

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

[AuthMethod]
string Signup(string email, string password) {
  var u = new User { Name = email, Email = email, PasswordHash = Security.HashPassword(password) };
  return Security.IssueJwt(u.Id, u.Email);
}

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

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

app.AuthBootstrap = new AuthBootstrap {
  Role      = AppRole.Authenticator,
  Login     = Login,
  Signup    = Signup,
  LoginPage = LoginPage,
};
model/peer_review.osy71 lines
// PEER REVIEW — a DYNAMIC fan-out. Where DesignReview fans out over a LITERAL enum list (compile-time, one slot per
// member), PeerReview fans out over a RUNTIME entity collection (`this.Item.Topic.Referees`): one slot per referee,
// resolved at STATE ENTRY, each PRE-ASSIGNED to its own referee. Referees aren't known until run time, so a slot is
// addressed by the ACTING PRINCIPAL (its base alias `Referees`), not by a static per-instance name. The quorum is 3
// approving Reviews (state-level `Requires`, counting DATA rows — the naked count is gone).
// (Design reasoning: workflow_design/approvals.osy, the PeerReview block + W39.)
enum PaperStatus { Refereeing, Decided, Withdrawn }

entity Topic {
  [Required, MaxLength(200)] string Name;
  [ForeignKey(ReviewTopic)] User[] Referees;   // the users refereeing this topic — the dynamic fan-out collection
  [Required] User BackupReferee;               // the fallback pool member, admitted by every slot's Candidates
  security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; }
}

entity Paper {
  [Required, MaxLength(200)] string Title;
  [Required] Topic Topic;
  PaperStatus Status;
  [ForeignKey(Paper)] Review[] Reviews;
  security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; }
}

/// Reviews are DATA — auditable, queryable, counted by the quorum. Not engine internals.
entity Review {
  [Required] Paper Paper;
  [Required] User  Referee;
  Decision Decision = Decision.Approve;
  security { allow read when IsAuthenticated; allow create when IsAuthenticated; }
}

void Notify(User u) { Log.Information($"peer-review reminder — nudging {u.Id}"); }

workflow PeerReview {
  Tracks    = Paper.Status;
  Autostart = true;
  Initial   = Refereeing;

  event Review(Decision decision);

  state Refereeing {
    Expire = TimeSpan.FromDays(60);

    subscribe Review(Decision decision) as Referees foreach (User u in this.Item.Topic.Referees) {
      Assignee   = u;
      Candidates = c => c == u || c == this.Item.Topic.BackupReferee;   // the fallback pool

      Finished {
        Within = TimeSpan.FromDays(14);
        Remind Nudge(After = TimeSpan.FromDays(7)) { Notify(slot.Assignee); }
        Unfinished(Slot slot) { slot.Assign(this.Item.Topic.BackupReferee); }   // no goto → keep waiting
      }
    }

    on Review(Decision decision, Slot slot) {
      new Review { Paper = this.Item, Referee = slot.Assignee, Decision = decision };
    }

    Requires {
      Quorum { Must    = this.Item.Reviews.Count(r => r.Decision == Decision.Approve) >= 3;
               Message = "Three approving reviews are required."; }
    }

    on Complete { goto Decided; }        // fires when the REQUIREMENTS hold — the rest never have to review
    on Expire   { goto Withdrawn; }
  }

  terminal success Decided   { }
  terminal cancel  Withdrawn { Message = "not enough referees responded"; }
}
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/board.osy161 lines
// THE BOARD — and what it has to show is a COUNT, not a gate. Both workflows here advance on a quorum: two of three
// design votes, three approving peer reviews.
using Osyrin.Ui;

[Page("/")]
[Render(CSR)]
[Title("Design & peer review")]
component Board() {
  live var designs = Design.Include(d => d.Votes).OrderByDescending(d => d.CreatedAt).ToList();
  live var papers  = Paper.Include(p => p.Reviews).Include(p => p.Topic).OrderByDescending(p => p.CreatedAt).ToList();

  live var mineDesigns = Workflow.Inbox<Design>().Include(r => r.Item);
  live var minePapers  = Workflow.Inbox<Paper>().Include(r => r.Item);

  var me = Session.CurrentUser;

  string title = "Sharding the ledger";

  action VoteArchitect(Design d) { VoteAsArchitect(d, Decision.Approve, "lgtm"); }
  action VoteSecurity(Design d)  { VoteAsSecurity(d, Decision.Approve, "ok"); }
  action VoteProduct(Design d)   { VoteAsProduct(d, Decision.Approve, "ship it"); }
  action RejectArchitect(Design d) { VoteAsArchitect(d, Decision.Reject, "needs work"); }
  action RejectSecurity(Design d)  { VoteAsSecurity(d, Decision.Reject, "unsafe"); }
  action RejectProduct(Design d)   { VoteAsProduct(d, Decision.Reject, "off-roadmap"); }
  action Review(Paper p)         { SubmitReview(p, Decision.Approve); }
  action RejectPaper(Paper p)    { SubmitReview(p, Decision.Reject); }
  action Raise()   { RaiseDesign(title); }
  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: "980px", px: 5, h: "60px") {
          Text("Design & peer review", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold);
          Row(align: Align.Center, gap: 3) {
            Text(me.Name, 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: "980px", p: 5) {

          Stack(gap: 2) {
            Text("Designs", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold);
            Text("Two of three approvals accept a design. The third never has to vote.",
                 fontSize: FontSize.Caption, color: Colors.TextSecondary);

            foreach (var d in designs) {
              Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
                Stack(gap: 3) {
                  Row(justify: Justify.SpaceBetween, align: Align.Center) {
                    Stack(gap: 1) {
                      Text(d.Title, fontSize: FontSize.Body, fontWeight: FontWeight.Semibold);
                      Text(d.Status, fontSize: FontSize.Caption, color: Colors.TextSecondary);
                    }
                    if (d.Votes.Count(v => v.Decision == Decision.Approve) >= 2) {
                      Text(d.Votes.Count(v => v.Decision == Decision.Approve) + " of 3 approved",
                           fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Success);
                    } else {
                      Text(d.Votes.Count(v => v.Decision == Decision.Approve) + " of 3 approved — need 2",
                           fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Warning);
                    }
                  }

                  if (d.Votes.Count() > 0) {
                    Stack(gap: 1) {
                      foreach (var v in d.Votes) {
                        Text(v.Voter.Name + " — " + v.Decision + (v.Comment != "" ? " · " + v.Comment : ""),
                             fontSize: FontSize.Caption, color: Colors.TextSecondary);
                      }
                    }
                  }

                  foreach (var r in mineDesigns) {
                    if (r.Item == d) {
                      Row(gap: 2, align: Align.Center) {
                        Text("vote as " + r.SlotAlias, fontSize: FontSize.Caption, color: Colors.TextSecondary);
                        if (r.SlotAlias == "Architect") {
                          Button("Approve", onPress: () => VoteArchitect(d), tone: Tone.Success);
                          Button("Reject", onPress: () => RejectArchitect(d), tone: Tone.Danger);
                        }
                        if (r.SlotAlias == "Security") {
                          Button("Approve", onPress: () => VoteSecurity(d), tone: Tone.Success);
                          Button("Reject", onPress: () => RejectSecurity(d), tone: Tone.Danger);
                        }
                        if (r.SlotAlias == "Product") {
                          Button("Approve", onPress: () => VoteProduct(d), tone: Tone.Success);
                          Button("Reject", onPress: () => RejectProduct(d), tone: Tone.Danger);
                        }
                      }
                    }
                  }
                }
              }
            }
          }

          Stack(gap: 2) {
            Text("Papers", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold);
            Text("One slot per referee on the topic, resolved at run time. Three approvals decide it.",
                 fontSize: FontSize.Caption, color: Colors.TextSecondary);

            foreach (var p in papers) {
              Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
                Stack(gap: 3) {
                  Row(justify: Justify.SpaceBetween, align: Align.Center) {
                    Stack(gap: 1) {
                      Text(p.Title, fontSize: FontSize.Body, fontWeight: FontWeight.Semibold);
                      Text(p.Topic.Name + " · " + p.Status, fontSize: FontSize.Caption, color: Colors.TextSecondary);
                    }
                    if (p.Reviews.Count(x => x.Decision == Decision.Approve) >= 3) {
                      Text(p.Reviews.Count(x => x.Decision == Decision.Approve) + " of 3 approved",
                           fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Success);
                    } else {
                      Text(p.Reviews.Count(x => x.Decision == Decision.Approve) + " of 3 approved — need 3",
                           fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Warning);
                    }
                  }

                  if (p.Reviews.Count() > 0) {
                    Stack(gap: 1) {
                      foreach (var rv in p.Reviews) {
                        Text(rv.Referee.Name + " — " + rv.Decision, fontSize: FontSize.Caption, color: Colors.TextSecondary);
                      }
                    }
                  }

                  foreach (var r in minePapers) {
                    if (r.Item == p) {
                      Row(gap: 2) {
                        Button("Approve", onPress: () => Review(p), tone: Tone.Success);
                        Button("Reject", onPress: () => RejectPaper(p), tone: Tone.Danger);
                      }
                    }
                  }
                }
              }
            }
          }

          Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
            Stack(gap: 3) {
              Text("Raise a design", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold);
              Row(gap: 3, align: Align.End) {
                Stack(gap: 1, grow: 1) {
                  Text("Title", fontSize: FontSize.Caption, color: Colors.TextSecondary);
                  Input(value: title, label: "Title", placeholder: "What is being reviewed");
                }
                Button("Raise", onPress: Raise, tone: Tone.Primary);
              }
            }
          }
        }
      }
    }
  }
}
model/pages/login.osy63 lines
// Sign-in. Two ways of being eligible sit side by side here — a GRANTED reviewer hat (design review) and a RELATION
// to a topic (peer review) — so switching people quickly is the page's only job.
using Osyrin.Ui;

[Page("/login")]
[AllowAnonymous]
[Render(CSR)]
[Title("Reviews — sign in")]
component LoginPage() {
  string email = "ada@acme.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); }
  }

  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("Design & peer review", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold, letterSpacing: "-0.02em");
          Text("Two of three, and three of many. Sign in as each to see whose vote is still missing.",
               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);
          }
        }

        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@acme.test — the Architect GRANT (a design-review hat)", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("sam@acme.test — the Security GRANT", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("pat@acme.test — the Product GRANT", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("ravi@acme.test — referees the topic (a RELATION, not a hat)", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("bo@acme.test — the backup referee, in every referee slot's pool", fontSize: FontSize.Caption, color: Colors.TextSecondary);
          }
        }
      }
    }
  }
}