Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

Pinned and recent pages, the app switcher, and presence in a shell

new AppChrome { Pinned = …, Recent = …, Apps = [new AppLink { … }], TogglePin = TogglePin, User = new ShellUser { Presence = ShellPresence.Online } }

A work shell draws the reader's own shortcuts — the pages they PINNED and the ones they opened RECENTLY — above its nav, turns the brand into an app switcher when the app lists other applications, puts a pin beside the page title, and shows the signed-in person's presence. The shell keeps no list of its own: pins and visits are rows the app keeps per user and hands in.

preview1 example compiled by CIuikitshellnavigation

Summary#

An all-day tool is used the same few ways every day, so its shell carries the reader's own shortcuts: the pages they pinned and the pages they opened last. A shell built on AppChrome draws both as sections above its nav (Sidebar, Rail) or in the switcher behind its brand (Tabbed), draws a pin beside the page title that pins the page you are on, lists the other applications the reader can open behind the brand, and lights a presence lamp on their avatar. The kit only DRAWS: which pages are pinned is a fact about a person, so the app keeps it as rows and hands it in.

Signature#

new AppChrome {
  Nav = …,
  Pinned = pins.Select(p => new NavItem { Label = p.Label, To = p.Route, Icon = p.Icon }).ToArray(),
  Recent = visits.Select(v => new NavItem { Label = v.Label, To = v.Route, Icon = v.Icon }).ToArray(),
  TogglePin = TogglePin,                       // an action: pin or unpin Navigation.CurrentPath
  Apps = [ new AppLink { Label = "…", To = "/", Current = true }, new AppLink { Label = "…", To = "https://…" } ],
  User = new ShellUser { Name = …, Presence = ShellPresence.Online },
}

Description#

The state is the app's, per person#

Pinned and Recent are plain NavItem[] the shell is handed. The kit keeps no list and no browser storage, on purpose: a pin made on a laptop has to be there on the phone, so it is a ROW with an owner, and the layout reads the signed-in person's rows live — a pin added on this page appears in the rail at once. AppChrome.ItemFor(route) names a route from the nav (its label and icon), which is what a new pin or visit should be called; it answers null for a route the nav does not cover (a record page), where the app names the row itself.

Recording a visit is an on change in the layout that reads only Navigation.CurrentPath and calls a server function. Do not read the live chrome there: it reads the visits, so recording one would wake the block again — name the route from the nav alone (below). Recent is drawn without the page you are on (it is already the lit nav row) and without pinned pages, so a page is never listed twice; five or six rows are plenty.

Where each shell draws them#

shellpinned and recentapp switcherpin button
SidebarShellsections above the nav; glyphs between hairlines when the rail is an icon stripthe brandbeside the page title
RailShellglyphs above the nav in the strip, labelled in the drawer and the expanded panelthe brand at the rail's headbeside the page title
TabbedShellin the switcher behind the brand, under the apps — a bar has no rail for themthe brandbeside the page's actions
FocusedShellnot drawn — a focused task is not a place to wander off fromthe brand stays a link homenot drawn

A pinned row for the page you are on reads as trail (weight and ink, no fill), so the nav's own row stays the one lit row on the screen.

The app switcher#

With Apps empty (the default) the brand is a plain link home. With one or more, the brand opens a connected menu: the product and its tagline, Home, then Apps with the one marked Current ticked. An AppLink.To is a route in this app or another app's full address, which opens in the same tab as switching apps does everywhere.

The pin#

Set TogglePin and the shell draws a pin beside the page title — a toggle button (pressed: when the page is in Pinned), named for what a press will do ("Pin this page" / "Unpin this page"), whose glyph nudges when the state flips. The action reads Navigation.CurrentPath, so it pins whatever page is open, record pages included.

Presence#

ShellUser.Presence — None (the default, draws nothing), Online, Away, Busy (labelled "Do not disturb") — puts a lamp on the avatar's corner (breathing when online) and the word under the name in the account menu, where a screen reader hears it. Map an app's own presence states onto these four.

Examples#

Pins and visits owned by the signed-in person, an app switcher, and presence — the whole floor in one layout:

using Osysharp.Ui;

[Principal] entity User {
  [Unique, MaxLength(200)] string Email;
  [MaxLength(120)] string Name;
  security { allow read when IsAuthenticated; }
}

// A pin belongs to a PERSON — it follows them to every device they sign in on.
entity Pin {
  User Owner;
  [MaxLength(300)] string Route;
  [MaxLength(120)] string Label;
  Icons Icon;
  DateTime PinnedAt;
  security { allow read, create, delete where Owner == user; }
}

entity Visit {
  User Owner;
  [MaxLength(300)] string Route;
  [MaxLength(120)] string Label;
  Icons Icon;
  DateTime At;
  security { allow read, create, update where Owner == user; }
}

void Remember(string route, string label, Icons icon) {
  var me = Session.CurrentUser;
  var seen = Visit.FirstOrDefault(v => v.Owner == me && v.Route == route);
  if (seen != null) { seen.At = DateTime.UtcNow; }
  else { new Visit { Owner = me, Route = route, Label = label, Icon = icon, At = DateTime.UtcNow }; }
}

void TogglePinFor(string route, string label, Icons icon) {
  var me = Session.CurrentUser;
  var pin = Pin.FirstOrDefault(p => p.Owner == me && p.Route == route);
  if (pin != null) { pin.Delete(); }
  else { new Pin { Owner = me, Route = route, Label = label, Icon = icon, PinnedAt = DateTime.UtcNow }; }
}

[Layout]
component Shell() {
  var me = Session.CurrentUser;
  live var pins = Pin.Where(p => p.Owner == me).OrderBy(p => p.PinnedAt).ToList();
  live var visits = Visit.Where(v => v.Owner == me).OrderByDescending(v => v.At).Take(6).ToList();

  NavItem[] Map() {
    return [
      new NavItem { Label = "Inbox", To = "/", Icon = Icons.Home },
      new NavItem { Label = "Reports", To = "/reports", Icon = Icons.Chart },
    ];
  }
  // Named from the NAV, never from the live chrome — which reads the visits the block below records.
  NavItem Named(string route) { return new AppChrome { Nav = Map() }.ItemFor(route); }

  on change {
    var path = Navigation.CurrentPath ?? "";
    var item = Named(path);
    if (item != null) { Remember(path, item.Label, item.Icon); }
  }
  action TogglePin() {
    var path = Navigation.CurrentPath ?? "";
    var item = Named(path);
    if (item != null) { TogglePinFor(path, item.Label, item.Icon); }
  }

  render {
    SidebarShell(new AppChrome {
      Product = "Harbourmaster",
      Nav = Map(),
      Pinned = pins.Select(p => new NavItem { Label = p.Label, To = p.Route, Icon = p.Icon }).ToArray(),
      Recent = visits.Select(v => new NavItem { Label = v.Label, To = v.Route, Icon = v.Icon }).ToArray(),
      TogglePin = TogglePin,
      Apps = [
        new AppLink { Label = "Harbourmaster", To = "/", Current = true },
        new AppLink { Label = "Tide tables", To = "https://tides.example.com/", Icon = Icons.Chart },
      ],
      User = new ShellUser { Name = me.Name, Initials = "HM", Presence = ShellPresence.Online },
    }) { Outlet(retain: 8); }
  }
}

[Route("/")] [Layout(Shell)] [Title("Inbox")]
component Inbox() { render { Text("Berth requests waiting"); } }

[Route("/reports")] [Layout(Shell)] [Title("Reports")]
component Reports() { render { Text("Quay occupancy"); } }

Testing them#

The pin is pressed by its name and the rail's sections are scoped by theirs: Ui.Click("Pin this page"); Assert.Visible("Reports", within: "Pinned"); · Assert.Visible("Inbox", within: "Recent"). The switcher opens from the brand: Ui.Click("Harbourmaster"); Assert.Visible("Tide tables", within: "Switch app"). demo/shell-showcase and demo/shell-arrangements carry these tests for every work shell.

See also#

Related

App shells

An app shell is the frame around every page: a brand, a navigation tree, the signed-in person and a menu behind them, a…

RailShell

An app shell built around a permanently narrow icon rail, in the shape Slack, Linear and Discord converge on. Every row…

TabbedShell

An app shell whose primary navigation is a horizontal strip of tabs under the brand row, and a bottom tab bar within…

on change

`on change { … }` is a reactive **side-effect**: the runtime re-runs it whenever a value it read changes, so it's how…

Menus, the command menu, and keyboard shortcuts

The kit's menus grow out of the control that opened them and keep a notch pointing back at it, carry icons, key hints…

animationKey — play an animation again when a value changes; a line that draws itself

`animationKey: value` on any element makes its animation play each time the value CHANGES — and not when the element…