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

INodeRenderer extends INodeStyle with view-producing methods. INodeStyle carries the lightweight presentation metadata; INodeRenderer adds the actual UI rendering.

public interface INodeStyle
{
    string  NodeType    { get; }   // Schema type, e.g. N.Device.Type
    string  DisplayName { get; }   // Human-readable name (e.g. "Device")
    string  LabelField  { get; }   // Field name or label definition (see below)
    string  Color       { get; }   // Default accent color, e.g. "#346eeb"
    UIcons  Icon        { get; }   // Default icon
}

public interface INodeRenderer : INodeStyle
{
    CardContent       CompactView(Node node);                          // Search results, neighbors lists
    Task<CardContent> PreviewAsync(Node node, Parameters parameters);  // Side-panel preview
    Task<IComponent>  ViewAsync(Node node, Parameters parameters);     // 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.

The Node object

Node is the deserialized form of a graph node on the front end. It carries UID, Type, Timestamp, edges, and any field values written by the server. Access fields through the typed getters on the base NodeOrEdge class:

Method Returns
node.GetString(field) Raw string value (or the number/bool coerced to string).
node.GetBool(field) Boolean.
node.GetInt(field) Int32.
node.GetLong(field) Int64.
node.GetFloat(field) Single.
node.GetDouble(field) Double.
node.GetDecimal(field) Decimal.
node.GetDateTime(field) DateTime (defaults to 1970-01-01 on parse failure).
node.GetTime(field) Time value.
node.GetTimeSpan(field) TimeSpan.
node.GetUID128(field) UID128.
node.GetGeoPoint(field) GeoPoint.
node.HasOwnProperty(name) True when the field is present on the node.

The accessors throw ArgumentException when the field is missing. Use HasOwnProperty to guard optional fields, or wrap optional lookups in try/catch.

Search-result nodes also carry Pinned, Highlighter, and ChildHits. These are populated only when the node is part of a search response.

Sibling interfaces

A renderer can opt into extra workspace integrations by implementing any of these alongside INodeRenderer:

Interface What it adds
INodeStyleWithShortcuts string[] Shortcuts - field names promoted as quick actions.
INodeStyleWithChat string[] ChatFields - field names included when nodes are pulled into chat.
INodeCustomStyle GetColor(node) / GetIcon(node) - per-node overrides of Color and Icon.
INodeImageStyle Resolves a node-specific image used as the card thumbnail.
INodeTableRenderer TableColumns + FillTableRow(node, columns) - column projection for tables.

LabelField — picking the node's label

LabelField is the one piece of metadata most renderers need to think about. It tells generic UI (search hits, breadcrumbs, mention chips, graph tooltips) which field on the node should be used as the node's display label. Two syntaxes are supported:

1. Fallback list (legacy)

A list of field names separated by |, ,, ;, or spaces. The first non-empty field wins.

public string LabelField => "Title|Subject|Name";

If Title is empty on the node, the renderer falls back to Subject, then Name. This is the right choice when the label should always be the value of a single field and you just want a graceful fallback.

2. Interpolated label (`$...

A label definition that starts with the escape character $ and uses {Field} placeholders, in the same shape as a C# interpolated string. Use this when the label needs literal text, multiple fields, or formatting.

public string LabelField => "$Order {OrderId} for {Customer}";

Format specifiers follow : inside the placeholder and apply standard .NET formatting:

Placeholder Renders
{Field} Raw string value of the field.
{Amount:N2} Numeric with thousand separator and 2 decimals.
{Price:C} Currency.
{Ratio:P1} Percent with 1 decimal.
{Score:0.000} Custom numeric mask (3 decimals, forced zeros).
{Timestamp:yyyy-MM-dd} ISO date.
{When:MMM yyyy} Month name + year.
{Status:U} String to upper case.
{Name:L} String to lower case.
{Name:T} String trimmed of surrounding whitespace.
{{ and }} Literal { and }.

Examples:

public string LabelField => "$Created {Timestamp:yyyy-MM-dd}";
public string LabelField => "$Invoice {Number} ({Status:U}) — {Amount:C}";
public string LabelField => "${FirstName} {LastName}";

The label definition is parsed once and cached by Mosaik.Shared.Labels.NodeLabelInterpolator, so the renderer cost is paid only on the first node of its type.

Reserved characters

Interpolated labels are stored verbatim in the schema and shipped over HTTP. The wire format uses |, ;, and / as separators between UIDs and field names, so those characters must not appear inside an interpolated label definition. Stick to letters, digits, spaces, punctuation, and the { / } / : placeholder syntax.

Where the label is used

Surface How the label is built
Search result headers Server-side aggregation pre-fetches the field values per hit.
Mention chips, breadcrumbs Labels.Get(node, renderer.LabelField) on the client.
Graph viewer node tooltips Same, via the renderer's LabelField.
Server-side highlight extraction NodeLabelInterpolator.ExtractReferencedFields(labelField).

Interpolated labels are rendered server-side by the schema controller when a client batch-fetches labels, so the client receives the final string and does not need to know the field names involved. For local rendering, the Labels.Get(node, labelField) helper takes the same definition and applies it against the in-memory Node.

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.