The Supplied Navigation Rail
Core's default left-hand index is the tree of the page's Space — its first path segment
(Infrastructure for every page under it, {viewer} inside a home). Every page of the Space shows
the same index: the Space's children in their declared order, an entry with children as a
collapsible group, the groups on the reader's path open, and the page being read marked as current
(DefaultNodeNavigation).
It used to be the current node's own children, which was right on the root of a document tree
and wrong one level down: a sub-page has no children, so the index vanished the moment the reader
clicked into it, and nothing told them where they were (reported 2026-09-14 on
Infrastructure/Inference). A first fix rooted the index one level below the Space, which left the
Space's own pages — Infrastructure/Options beside Infrastructure/Inference — outside every index
again (reported 2026-09-15: "Infra has one; show it, with where we are"). The root is the Space:
a Space is the document and its overview is the title page, so every page under it shows the one
index of the Space, with the page being read marked and only the groups on the reader's path open.
That default is also wrong for a course: a learner standing in lesson 2 must see the whole course,
not the document tree the lesson happens to sit in, with the course's own notion of what a page is.
INodeNavigationProvider is the seam that fixes it without core having to learn what a lesson is. A
module that OWNS a family of pages hands core a whole index; core renders it. The division of
labour is the contract: the module decides WHAT is in the index and in WHAT ORDER; core decides how
it looks. Either way the same rail renders it, so a course and a document tree read alike.
The seam
public interface INodeNavigationProvider
{
IObservable<NodeNavigation?>? GetNavigation(LayoutAreaHost host);
}
Returning null — or a stream that emits null or no entries — declines the page, and core's
default child list stands unchanged. That is the normal answer for nodes the module does not own,
and it must be cheap (a path-shape check, never a query) and must never throw. A provider that
throws is logged and skipped: a module's navigation is a nicety, the page is not.
What comes back is a heading plus a flat list of entries, each of which may have children:
public record NodeNavigation(string Title, IReadOnlyList<NodeNavigationEntry> Entries)
{
public string? TitlePath { get; init; } // what the heading links to; null for a plain heading
public string? Icon { get; init; }
}
public record NodeNavigationEntry(string Label, string Path, bool IsCurrent = false, string? Icon = null)
{
public IReadOnlyList<NodeNavigationEntry> Children { get; init; } = [];
}
The seam is reactive by construction. A whole-course index is a query, and a query is an
IObservable<T>, so the provider hands back a stream rather than a materialised list — which is what
lets it walk up to the course root and read the subtree without blocking the render (nothing on a hub
may await; see Asynchronous Calls). The stream feeds straight into the
area's CombineLatest, so an added, renamed or re-ordered page re-renders the index live. It must
emit promptly: one that stays silent holds the whole page back.
What core promises
SuppliedNavigationRail turns the supplied navigation into the rail in two steps — a pure
Plan, then Render. Five properties are deliberate, and each of them is a defect that was
reported on a live page.
1 · The title is a link, never the collapsible root
Nesting the whole index under one NavGroupControl made its heading both a link and a toggle, so
clicking the course name collapsed the entire index. Groups toggle, links navigate — never both on
one control. The heading is a sibling of the entries, not their parent.
The one case where the heading is not a link: when TitlePath is null, because the index root does
not exist. Linking a node that is not there does not render "not found" — path resolution matches the
longest existing prefix and reads the trailing segment as an AREA, so the reader gets "no renderer
is registered for area ", a rendering error for what is really a missing node.
2 · An entry with children is a group AND its own first link
The heading expands; the link directly beneath it opens the page. The self-link is what carries the position marker, because a group heading has no active state to carry one.
3 · The current entry stays in the tree, as an active link
It used to be swapped for bare body text — no icon, no indentation — so the line the reader was
standing on jumped to the far-left margin and read as belonging to nothing. It is a normal
NavLinkControl with IsActive set, which gives it the accent bar, background and weight the nav
menu already styles. None of those cues is colour-only.
Only the group the reader is inside is expanded, so a long index stays a scannable list of chevrons and every other group visibly offers its expander.
4 · The supplier's order is the rail's order
Having children decides WHAT an entry becomes, never WHERE it goes.
The plan used to hold two buckets — Pages for entries with no children, Groups for entries with
them — and render every page before every group. The supplied order survived inside each bucket and
was lost between them, so an entry with children could never precede one without:
supplied: Read me (0) · Lesson 1 (1) · Lesson 2 (2) · Lesson 3 (3) · Exercises (55) · Video (90)
rendered: Read me · Exercises · Video · Lesson 1 · Lesson 2 · Lesson 3
Measured on a live course (2026-09-06, MeshWeaver#3406): four lessons carrying orders 1–4 rendered
tenth to thirteenth, behind every leaf page, because each lesson had an exercise, a solution, a quiz
and a documents folder beneath it. No Order value could fix it — the numbers were already
right. A course author's only lever over the rail is Order, and it silently could not express a
reading order that mixes leaf pages with lesson folders, which is every course.
The plan therefore carries one ordered sequence, Rail.Items, whose element is a RailLink or a
RailGroup, and Render walks it once:
public abstract record RailItem; // RailLink | RailGroup, and nothing else
public sealed record Rail(RailLink Home, IReadOnlyList<RailItem> Items);
Both container renderers — Blazor's NavMenuView and the React NavMenu skin — emit a container's
areas in declaration order, and WithNavLink and WithNavGroup both append to that one ordered
list. So preserving the order in the plan is the whole fix; there was never a second place that
re-sorted.
The alternative a module might reach for — stop declaring children, so every entry is a leaf and the order comes out right — is not a fix. It buys the ordering by giving up the collapsible lessons, which is the thing that makes a long index scannable at all.
5 · A group nests
An entry's child that has children of its own is a group inside the group. RailGroup carries
Items — the same ordered sequence of RailLink | RailGroup as the rail itself, its own link first —
and Render walks it recursively. The plan used to flatten a group to its links, so a page three
levels down a documentation tree had no line to stand on and the page above it showed a link where
a folder was. It surfaced the day the default index became a tree (2026-09-14): a two-level rail was
enough for a course (module → lesson pages) and not for a document tree of any depth. Only the groups
on the reader's path are open, at every level. RailGroup.Links remains as a filter — the group's
direct links — never the render order.
The rail's chrome — resize and collapse
The rail is one pane of a horizontal SplitterControl beside the content, so the divider is a real
drag handle (180–480px). Core stamps the pane with MarkdownOverviewLayoutArea.NavigationPaneClass
(nav-rail-pane), and a portal shell that knows the class hosts the pane's collapse toggle in its
top bar — beside the logo, where a browser keeps its sidebar button — and keeps the collapsed state
across pages and reloads; the button shows only on pages that have a rail, and while the rail is
hidden it turns into the expand button. A shell that does not know the class leaves the splitter
bar's own collapse chevron in charge. (The Blazor portal's half lives in MeshWeaver.Plugins:
NavRailStateService, NavRailPanePresentation, and the toggle in PortalLayoutBase.)
One sidebar, one button. The index rail is not the only left-hand sidebar a page can have: the
Threads app (/{user}/Chat) and every full-page thread show their thread list in the same place. It
joins the same toggle and the same state (2026-09-16, the maintainer: "when side menu (on left) is
collapsed … the expand button is still to the left consuming real estate … integrate the open button
… in the top menu bar, similar to how safari is doing it"). The rule for any left sidebar is
therefore:
- Collapsed takes zero width. Nothing stays behind on the left edge — no strip, no reveal button, no splitter bar. The header button is the only way back, which is why it is always there while the page has a sidebar.
- The button never moves. It sits at the left end of the top bar in both states and only swaps
its icon (
PanelLeftContract↔PanelLeftExpand) and its localized name (menu.hideSidebar↔menu.showSidebar). A sidebar carries no collapse control of its own while a shell hosts this one — two controls for one state is how the residual strip came about. - The state is the reader's, not the page's. Hiding the thread list also hides a document's index, the way Safari's sidebar button hides whichever sidebar the window shows; the choice follows the reader across pages and reloads.
- A sidebar reports its presence (
NavRailStateService.Attach/Detach, reconciled after every render) so the button shows only while there is something to toggle, and it resolves the service OPTIONALLY: in a host with no shell toggle it keeps its own collapse and reveal controls rather than becoming impossible to reopen. The thread list's fold isThreadNavPresentationinMeshWeaver.Blazor.Chat.
Why the plan is a pure record
A ContainerControl's child views are protected. A rail built straight into controls can be
asserted on for its area count and its skin, and nothing else — and every defect above was
invisible to exactly that kind of test. The plan is an ordinary record, so what the rail contains —
which entry is current, which group is open, what each line links to, and in what order — is pinned
by unit tests instead of by opening a page and looking. SuppliedNavigationRailTest is where those
assertions live.
Where the rail is rendered
| Surface | How |
|---|---|
| A markdown page's Overview | automatic — MarkdownOverviewLayoutArea asks every registered provider, and falls back to the default index (the tree under the page's index root, DefaultNodeNavigation) when none claims the page |
| A layout that composes its own page | embed the standalone area by name: new LayoutAreaControl(address, new LayoutAreaReference(MarkdownOverviewLayoutArea.SuppliedNavArea)) |
The standalone area renders nothing when no provider claims the page, so embedding it on a non-course page costs an empty area rather than an error box.
An @@ embed never gets a side menu at all — its providers are not even asked, so no query is opened
for a page that could not show the result.
Registering a provider
A provider is an ordinary DI singleton. Registering it in a node type's configuration puts it on the per-node hubs of pages of that type, which is how a module claims its own pages without core knowing anything about them:
config.WithServices(services =>
services.AddSingleton<INodeNavigationProvider, MyCourseNavigationProvider>());
When several providers are registered, the first one that returns entries wins.
Related
- User Interface — layout areas and controls generally
- UI Extensibility — the full table of extension seams a module can implement
- Asynchronous Calls — why the seam hands back a stream and never a task