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.

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:

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.

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.