Curiosity

Node Renderers

A node renderer tells the workspace how to display nodes of a given type — the card in search results, the preview in side-panels, the full page when a user opens the node. Implement INodeRenderer, register it once, and every built-in component (search, neighbors, graph viewer) picks it up.

The contract

public interface INodeRenderer
{
    string  NodeType { get; }   // The type this renderer applies to (e.g. N.Device.Type).
    UIcons  Icon     { get; }   // Icon used in lists and breadcrumbs.
    string  Color    { get; }   // Hex color used in graph viewer and badges.

    CardContent       CompactView(Node node);                    // Used in search results.
    Task<IComponent>  PreviewAsync(Node node);                    // Used in side panels.
    Task<IComponent>  ViewAsync(Node node, Parameters state);     // Full page view.
}

CompactView is synchronous because it's rendered for every search hit — it must be fast. PreviewAsync and ViewAsync are async because they typically fetch related data.

Implementation example

public class DeviceRenderer : INodeRenderer
{
    public string NodeType => N.Device.Type;
    public UIcons Icon     => UIcons.Box;
    public string Color    => "#346eeb";

    public CardContent CompactView(Node node) =>
        CardContent(
            Header: Header(this, node),
            Body:   TextBlock(node.GetString(N.Device.Name)));

    public Task<IComponent> PreviewAsync(Node node)
    {
        IComponent panel = VStack().Children(
            TextBlock(node.GetString(N.Device.Name)).Size(18).SemiBold(),
            Neighbors(node.UID, N.Part.Type, E.HasPart).WithLimit(5));
        return Task.FromResult(panel);
    }

    public Task<IComponent> ViewAsync(Node node, Parameters state)
    {
        IComponent page = VStack().Children(
            TextBlock(node.GetString(N.Device.Name)).Size(28).SemiBold(),
            HStack().Children(
                Card("Parts",       Neighbors(node.UID, N.Part.Type, E.HasPart)),
                Card("Open cases",  Neighbors(node.UID, N.SupportCase.Type, E.HasSupportCase)
                                        .WithFacets())));
        return Task.FromResult(page);
    }
}

Registering

Register the renderer once at startup, before the first route fires:

NodeRendererRegistry.Register(new DeviceRenderer());
NodeRendererRegistry.Register(new SupportCaseRenderer());
NodeRendererRegistry.Register(new ManufacturerRenderer());

Tip: keep one registration call per renderer so you can grep for Register(new and audit the set.

What overrides what

Surface Picks up renderer through
SearchArea results CompactView
Neighbors items CompactView
Side-panel preview PreviewAsync
Default node URL (#/node?uid=…) ViewAsync
Graph explorer node tooltip CompactView (with Icon and Color)

If you register a renderer for a node type that already has a default, your renderer takes precedence — there's no chaining.

Common patterns

  • Header helper. Most renderers share Header(this, node) to render an icon + key consistently. Factor it once.
  • Lazy fetch. In ViewAsync, prefer Neighbors(...) over an await Q().Emit()Neighbors paginates and caches.
  • Compact-only renderers. If you only want to customize search-result rendering and keep the default page view, return null from ViewAsync — the workspace falls back to the default.
© 2026 Curiosity. All rights reserved.