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#
| shell | pinned and recent | app switcher | pin button |
|---|---|---|---|
SidebarShell | sections above the nav; glyphs between hairlines when the rail is an icon strip | the brand | beside the page title |
RailShell | glyphs above the nav in the strip, labelled in the drawer and the expanded panel | the brand at the rail's head | beside the page title |
TabbedShell | in the switcher behind the brand, under the apps — a bar has no rail for them | the brand | beside the page's actions |
FocusedShell | not drawn — a focused task is not a place to wander off from | the brand stays a link home | not 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#
- App shells — the
AppChromerecord and the shells that draw it - on change — the reaction that records a visit
- Menus, the command menu, and keyboard shortcuts — the connected menu the switcher is
- animationKey — play an animation again when a value changes; a line that draws itself — the nudge a count or the pin gives when it changes