Migrating to OmniResult

INodeRenderer no longer returns CardContent. A node is now drawn as a Tesserae OmniResult<Node>: one row object that carries the node, how it looks, how it is opened, its commands, its selection, and the full view behind it.

This is a breaking change for anyone building a front-end against the Curiosity.FrontEnd* NuGet packages. This page is the porting guide: what changed, what it maps to, and recipes for the things the old API was used for.

Who this affects

You wrote an INodeRenderer for a node type, or you customized search results with WithCardCustomizer / WithCustomizedRenderer / WithRenderer. If you only use SearchArea, Neighbors and NodePreview without customizing them, nothing changes beyond the package bump.

Everyone upgrading should also read Other breaking changes in this release at the end: Tesserae's Link component is gone, an OmniResult footer takes InlineLabels, and a few front-end helpers changed shape.

Package versions

Package What to do
Curiosity.FrontEnd, .Core, .API, .Admin, Curiosity.Components Bump together. They ship from one pipeline and share the INodeRenderer contract.
Tesserae Must be the build that ships OmniResult<T>, InlineLabel and Button(href:), i.e. the version the front-end packages were built against. Pin that exact version if you reference Tesserae directly.

Mixing an old Curiosity.FrontEnd with a new Tesserae (or the reverse) will not compile: the INodeRenderer signature refers to OmniResult<Node>, and the front-end packages call APIs (Button(href:), SidebarComponent, InlineLabel) that only exist in the newer Tesserae.

The 60-second version

Before.cs
public CardContent CompactView(Node node)
    => CardContent(Header(this, node, customTitle: CreateTitle(node)), CreateBody(node));

public async Task<CardContent> PreviewAsync(Node node, Parameters state)
    => CardContent(Header(this, node), CreateView(node)).PreviewWidth(80.vw()).PreviewHeight(80.vh());

public async Task<IComponent> ViewAsync(Node node, Parameters state)
    => (await PreviewAsync(node, state)).Merge();
After.cs
public OmniResult<Node> CompactView(Node node)
    => NodeResult.For(this, node).SetContent(CreateBody(node));

public Task<OmniResult<Node>> PreviewAsync(Node node, Parameters state)
    => Task.FromResult(NodeResult.For(this, node).SetModalContent(CreateView(node)).ModalSize(80.vw(), 80.vh()));

public Task<IComponent> ViewAsync(Node node, Parameters state)
    => NodeResult.PageFromPreviewAsync(this, node, state);

NodeResult.For fills in everything every node row shares: the label as the title, the type's glyph and color on the tile (or the node's own photo/logo when the style is an INodeImageStyle), the source it came from in the footer, its timestamp, and "open the node preview" as the click. Your renderer only says what is different about its node type.

The new INodeRenderer

public interface INodeRenderer : INodeStyle
{
    OmniResult<Node>       CompactView(Node node);                          // was CardContent
    Task<OmniResult<Node>> PreviewAsync(Node node, Parameters parameters);  // was Task<CardContent>
    Task<IComponent>       ViewAsync(Node node, Parameters parameters);     // unchanged
}

PreviewAsync returns the same kind of row as CompactView, with the modal's content hung on it. Returning null still means "I opened whatever I wanted to show myself, don't build a modal".

INodeTableRenderer is unchanged.

Where the old CardContent pieces went

Old New
Header(this, node) NodeResult.For(this, node) (title, tile, source and open action in one call)
Header(…, customTitle: c) .SetTitle(c, NodeResult.Label(this, node)), but prefer a plain string title plus the slots below
Header(…, onClick: a) / header.OnClick = a .OpenWith(a)
Header(…, href: url) .OpenWith(() => Router.Navigate(url))
Header(…, showTimestamp: true) nothing, the timestamp is already a footer entry
header.WithSubtitle(s) .SetFooterEntries(s)
card.Body = c .SetContent(c) (rich component) or .SetText(s) (plain excerpt)
card.ExtraCommands / .WithExtraCommands(…) .WithExtraCommands(params CommandDefinition[])
card.NoCommands = true .WithoutCommands()
card.PreviewWidth(w).PreviewHeight(h) .ModalSize(w, h)
card.UseMessageStyle() gone, compose the row instead (photo tile via SetIcon, the message via SetContent)
card.HideDefaultIcon gone, it was already unused
card.Merge() / NodeCard the row is an IComponent; for the modal body use await result.GetModalContentAsync()

The row's slots

Rather than building one custom title component, put each thing in the slot that means it. This is what makes rows line up with each other across node types, and what makes highlighting, selection and commands work without help.

NodeResult.For(this, node)
   .SetId(node.GetString("Id"))              // "JR-2214 › the title", with a chevron between
   .SetBadge("3 matches")                    // the quiet pill next to the title
   .SetText(node.GetString("Excerpt"))       // plain-text excerpt, two lines, highlighted
   .SetContent(BuildPreview(node))           // a rich preview under it
   .ContentMaxHeight(160.px())               // capped and faded out, not cut off
   .SetFooterEntries(path, size, owner)      // metadata, dots drawn by CSS
   .SetIcon(Image(photoUrl).Cover(), Color)  // a photo instead of the glyph
   .SetIconBadge(Image(logo));               // a marker on the tile's corner

SetTitle(IComponent, text) still exists for a title that genuinely isn't text, such as a header built from fields an administrator configured. Use it as an escape hatch, not as the default: Highlight does not reach inside a component title.

The preview modal and the full page

The row carries the full view of the node and knows how to open as a modal showing it:

public Task<OmniResult<Node>> PreviewAsync(Node node, Parameters state)
{
    return Task.FromResult(NodeResult.For(this, node)
       .SetModalContent(CreateView(node))            // or SetModalContent(async r => …) to build on open
       .ModalSize(80.vw(), 80.vh()));
}

NodePreview calls ToModal() on what you return, then adds the header commands, the previous/next buttons, the dismissal and the bounds. You do not build a Modal yourself.

For the full page, most node types show the preview's content in place:

public Task<IComponent> ViewAsync(Node node, Parameters state) => NodeResult.PageFromPreviewAsync(this, node, state);

If you need the content without a modal around it (a side panel, a pane, a page of your own):

var preview = await renderer.PreviewAsync(node, state);
var content = await preview.GetModalContentAsync();   // null when the row has no modal content

Customizing search results

Four overlapping hooks became two.

Removed Use instead
SearchRenderer.WithCardCustomizer(Action<Node, CardContent>) CustomizeResult(Func<OmniResult<Node>, OmniResult<Node>>)
SearchRenderer.WithCustomizedRenderer(Func<SearchHit, RenderedSearchResult, ReplacedResult>, bool keepOnTab) CustomizeResult(…), or OnResultRendered(Action<RenderedSearchResult>) when you only need the drawn result
SearchRenderer.WithRenderer(Func<SearchHit, RenderedSearchResultsTracker, IComponent>) CustomizeResult(…)
SearchRenderer.WithMaxContentHeight(int) .ContentMaxHeight(UnitSize) on the row
SearchAreaWithPreview.WithCustomizedRenderer(…) SearchAreaWithPreview.CustomizeResult(…)
ReplacedResult gone, return a row from CustomizeResult
SearchRenderer.IsFolderRenderer gone, the decision it carried now belongs to the renderer drawing the row

CustomizeResult

searchArea.Renderer(r => r.CustomizeResult(result =>
{
    var node = result.Result;         // the Node the row stands for

    // change it in place …
    result.SetText(null).Class("my-row");

    // … and return it (or return a different row entirely)
    return result;
}));

The row you receive is the one the node type built, with the search's own marks already on it: the query highlighted, the pinned badge, the AI flag. You are adding to a finished row, not rebuilding one.

Customizers stack: each runs on what the previous one returned, in registration order. Returning null keeps the row you were given.

If you return a different row, the node's open action and commands move across to it automatically, and the replacement is hooked for selection, keyboard activation and dragging exactly like any other result.

Recipe: a button beside the row

The old shape wrapped the result in an HStack. Now the button belongs in the row's own command area, which keeps a row a row:

Before.cs
.WithCustomizedRenderer((hit, rendered) =>
{
    var add = Button().SetIcon(UIcons.Plus).OnClick(() => Select(hit.Node));
    return new ReplacedResult(HStack().Children(add, rendered.Grow()), rendered);
})
After.cs
.CustomizeResult(result => result.InlineCommands(
    OmniResultCommandsVisibility.AlwaysVisible,
    Button().SetIcon(UIcons.Plus).OnClick(() => Select(result.Result))))

InlineCommands() without a visibility shows them on hover, which is usually what you want for a secondary action. The space they take is reserved either way, so revealing them never shifts the row.

Recipe: extra commands in the menu

.CustomizeResult(result => result.WithExtraCommands(
    new CommandDefinition("Edit".t(), "e", UIcons.Pencil, () => Edit(result.Result))))

These join the commands every node gets, and show wherever those show: the right-click menu, the [...] button, the preview's header.

Recipe: a row that is only something to pick

.CustomizeResult(result => result
   .SetText(null)
   .SetContent(null)
   .WithoutCommands()
   .OpenWith(() => Choose(result.Result)))

Recipe: keeping hold of the drawn results

When you needed the RenderedSearchResult itself (to open one from elsewhere, or to keep a side panel in step), that is now its own hook rather than a customizer that returns its input unchanged:

.OnResultRendered(rendered =>
{
    _byUid[rendered.SearchHit.Node.UID] = rendered;
})

Recipe: replacing the row wholesale

If a node type really is drawn as something else in your view, build the replacement out of the row so it keeps the row's affordances:

.CustomizeResult(result => result
   .SetContent(MyContactCard(result.Result))     // the card goes where the excerpt would
   .OpenWith(() => Router.Navigate(DefaultRoutes.Node(result.Result.UID))))

Returning a completely unrelated IComponent is no longer possible, by design: a search result has to be selectable, commandable and keyboard-reachable, and only a row can be.

What the row now owns

Three things you used to do by hand are the row's job. Remove your versions, because doubling up will fight the built-in behaviour.

Highlighting. The query is marked structurally in the title and the excerpt, from the pattern the search returned. Don't call SearchRenderer.HighlightComponent on a result row. (HighlightComponent / RecursiveHighlight still exist for HTML previews rendered inside an iframe, which need the DOM walk.)

Selection. The checkbox, ctrl-click and shift-click come from OmniResult. RenderedSearchResult only says what "between" means for a shift-click and keeps the list in step. Removed with it: RenderedSearchResult.HookSelectionOnIconClick and HookSelectionOnShiftOrCtrlClick.

Commands. Right-click and the [...] button are the row's, and both open the same command palette the keyboard's Tab does. Don't set oncontextmenu on a result element.

Renamed and removed types

Removed Replacement
CardContent OmniResult<Node>
Header (the class) NodeResult.For(…) for the row; NodeOpenIn for the IsEmail / GetOpenInText / GetOpenInIcon / IsOpenableAppSource statics
Header.CreateDefaultNodeTitle(style, node) NodeResult.Label(style, node) (the string)
NodeCard none, the row is the component; GrowBody() / RemoveHeader() / PromoteToHubStack() have no equivalent
ReplacedResult return a row from CustomizeResult
UI.CardContent(…), UI.Header(…) NodeResult.For(…)
App.Settings.Cards.CustomizeHeaders (Action<Header>) App.Settings.Cards.CustomizeResults (Action<OmniResult<Node>>), applied by NodeResult.For
App.Settings.UseLegacyPreviewModals none, every preview is a sheet in one ModalStack
NodePreview.ShouldOpenAsModal(component) and the forceModal: arguments none, drop them from the call
App.PushPreview / App.ClosePreview / App.CloseAllPreviews / App.TryCloseOpenPreview NodePreview.CloseAll() and NodePreview.TryCloseTop()
Modal.ShowModalOverContentArea(bool forceModal) ShowModalOverContentArea(), it always floats over the content area now

Signature changes on things you may call:

Before After
NodePreview.For(node, state, Action<Header> headerModifier, …) NodePreview.For(node, state, Action<OmniResult<Node>> previewModifier, …)
NodePreview.CreatePreviewModalForAsync(node, state, headerModifier) …(node, state, previewModifier, renderedHit, embedded), pass embedded: true when you show the result with ShowEmbedded() in a pane of your own, so it gets none of the sheet chrome
CommandManager.SetModalActiveHit(hit, Button previous, Button next) SetModalActiveHit(hit, Action onPrevious, Action onNext)
NodePreview.ShowModalAsPreviewFor(node, modal, hit, modifier, forceModal, extraCommands) …(node, modal, hit, previewModifier, replaceTop), the commands are wired when the modal is built
UI.Neighbor(uid, type, edge, bool keepHeader) UI.Neighbor(uid, type, edge)
SearchArea.RenderNodeAsResult(node, int maxContentHeight) SearchArea.RenderNodeAsResult(node)
SearchRenderer.RenderOneSearchHit(owner, hit, tracker, customizeCard, es5.RegExp, maxContentHeight, queryUID, isFolderRenderer, selection) RenderOneSearchHit(owner, hit, tracker, Func<OmniResult<Node>, OmniResult<Node>> customizeResult, Regex highlighter, UID128 searchQueryUID, bool selectionEnabled)
RenderedSearchResult.ReplaceContent(component, title, icon, hit, keepOnTab) no public replacement, redrawing a result in place is internal to SearchRenderer now (RedrawFrom still triggers it)
SimilarityResultRenderer.Render(…) returning IComponent returns (ContributionBar bar, IComponent commonObjects), the bar goes on SetContributionBar

A previewModifier receives the whole row, so a header change is now a header change:

NodePreview.For(node, previewModifier: p =>
    p.SetModalHeader(r => HStack().WS().AlignItemsCenter().Children(backButton, r.ModalTitle().W(10).Grow())));

Previews are a deck of sheets

A node preview is no longer a modal that each caller had to dress: it is the modal the row builds (OmniResult<T>.ToModal()), pushed onto Tesserae's ModalStack. What that changes:

  • The header is standard. It carries the node's tile, identifier and title, its source line, the named "open at its source" button (Open in Dropbox, Reveal in Finder, taken from the renderer's own open-in-source command, with any others behind the arrow beside it), the previous/next arrows with "3 of 27" between them, [...] for the node's commands, full screen and close. Nothing to assemble; previewModifier still replaces the title area with SetModalHeader, and the commands are untouched by it.
  • The bottom says which keys work: Esc closes, ←/→ step through the results, Ctrl+Enter opens at the source and Shift+Enter opens it in a new tab.
  • Opening a node from inside a preview stacks a sheet on it. The ones behind peek out above the one in front; clicking one goes back to it, Escape peels one off, and clicking the backdrop dismisses the chain. Four deep is the limit, after which the oldest is dropped.
  • Stepping through results replaces the sheet in front rather than stacking another one, so the chain that led there survives.
  • The URL names the whole chain (?preview=uid1,uid2), so a refresh or a shared link reopens it in order.
  • The properties block is a DetailsGrid (NodeRendererBase.RenderPropertiesGrid), so a renderer that overrode RenderPreviewProperties to build its own grid can return one instead.

Other breaking changes in this release

These are independent of the renderer contract. They affect any project that compiles against Tesserae or Curiosity.FrontEnd*, whether or not it has a renderer of its own.

The Link component, the three UI.Link(...) factories and Button.Link() / DefaultLink() / DangerLink() were all removed. Button takes an href instead, and renders as an <a class="tss-btn"> rather than a <button class="tss-btn"> when it has one, so it is middle-clickable, ctrl-clicks into a new tab and shows where it goes in the status bar, while looking exactly like any other button.

Before After
Link(url, Button("Open").Primary()) Button("Open", href: url).Primary()
Link(url, Button(...)).OpenInNewTab() Button(..., href: url).OpenInNewTab()
Link(url, someComponent, noUnderline: true) Button(href: url).ReplaceContent(someComponent)
Link(url, "text") Button("text", href: url)
Link(url, "text", icon) Button("text", href: url).SetIcon(icon)
Button("x").Link() / .DefaultLink() Button("x")
Button("x").DangerLink() Button("x").Danger()
link.OpenInNewTab() / link.URL / link.Target Button.OpenInNewTab() / Button.Href / set target yourself
Link.AsWindow(features) none, call window.open from an OnClick

Button.LinkOnHover() is unchanged, and IsLink still reports whether a button renders as a link.

Two consequences worth checking in your own code:

  • Button.InnerElement is now HTMLElement, not HTMLButtonElement. Anything that assigned it to an HTMLButtonElement or read button.InnerElement.disabled needs adjusting; use IsEnabled / Disabled(bool), which were always class-based.
  • A stylesheet that matched buttons by element misses an href'd button. Search your CSS for selectors like .my-toolbar > button and match .tss-btn instead. A sibling rule using :first-of-type should become :first-child, since of-type compares element kinds and a mixed row of <a> and <button> breaks it.

SetFooterEntries(params IComponent[]) became SetFooterEntries(params InlineLabel[]). The params string[] overload is unchanged, so plain-text footers need no edit; only callers passing their own components do.

Before.cs
result.SetFooterEntries(TextBlock(path), TextBlock(size));
After.cs
result.SetFooterEntries(InlineLabel(path).SetIcon(UIcons.Folder), InlineLabel(size));

An InlineLabel is a mark (glyph, image, or a square of colour) plus optional text. It sizes itself from where it is (a compact button on its own, small plain type inside a footer) and can be pressable (OnClick) or a real link (SetHref). Built from a task (InlineLabel(async label => ...)) it shows a skeleton while the task runs and removes itself, and the slot it stands in, if the task ends without giving it anything to say: the stack item, the footer entry (separator dot included), or the whole DetailsGrid row when the label was that row's only value. That is how a footer avoids keeping a gap for a lookup that came back empty.

If you passed (IComponent[])null to clear a footer, that is now (InlineLabel[])null.

Rows and labels no longer select their text

A result row is a click target, so OmniResult and InlineLabel set user-select: none: dragging across a list no longer leaves half an excerpt highlighted. In a preview the title is selectable and the rest of the header is not; the content you passed to SetModalContent is untouched, so a document, a transcript or a details grid still selects and copies normally. If you relied on users selecting text out of a row, put that text in the modal content instead.

New components you may want instead of your own

Component What it replaces
InlineLabel hand-built "icon + text" metadata chips
InlinePagination a hand-built ‹ 3 of 7 › control
SidebarComponent hosting a component of your own in a Sidebar (a chat history, a tree, a picker), previously impossible without an ISidebarItem of your own
DetailsGrid a hand-built label/value table in a preview
PagesStack.OnPageClick opening a document at a page from the row's page rail

Front-end helpers that changed shape

Before After
CurrentUser.Set(..., bool activateSupport, bool isImpersonating) CurrentUser.Set(..., bool isImpersonating), the parameter only ever started the support widget
SupportChat (the whole class) none, the in-app support chat and its "Chat with us" entry were removed
IconChip.CentreGlyphOnItsInk(component, icon) none, glyphs carry their optical centering in the icon font now, and each logo carries its own in the margins of its viewBox
Mosaik.UI.ImageLink(...) / Shortcut(..., string href) returning Link the same methods returning Button
AsLabels.DefaultRenderer(...) / DefaultRendererWithCustomStyle(...) unchanged signatures, but they now return an InlineLabel-based component rather than a button wrapped in a link, and the textSize argument no longer sets a type size (the label takes it from where it is drawn)
AsLabels.AsInlineLabel(uid, ...) is new: one node as a single self-resolving InlineLabel, for a footer entry or a details-grid value
_FolderRenderer.GetKnownNeighborLabels(node) is new: the parents (ParentFolder, ParentExtractedArchive, ParentExtractedOneNote, ParentFile) as InlineLabels for a footer, beside the existing GetKnownNeighborLinks for a row of links

Light, Dark and Auto are now Tesserae's own themes

The three built-in theme entries used to carry a palette of their own and inject it over Tesserae's CSS variables, so the "default" look was a copy of the toolkit's that had drifted from it. They now write no CSS at all: Tesserae's own light and dark themes stand, with your brand primary from Theme.SetPrimary(light, dark) layered on top. Expect the default light and dark surfaces to shift slightly (dark goes to Tesserae's #222222 / #333333 rather than the old #0D1117 / #131E2E).

A theme of your own is unaffected in kind, but two things about how it is applied changed:

Before After
Every theme wrote :root, .tss-dark-mode { … } A dark theme writes .tss-dark-mode, a light one :root, so it only replaces the half of the palette it stands in for, and picking a dark theme no longer rewrites the light one behind it
Picking a PreferredTheme.System entry remembered its dark half The entry is remembered whole and the half to use is decided each time it is painted, so following the system back to light no longer repaints the dark palette

ThemeDefinition takes a usesTesseraeDefaults flag for this, and exposes it as a property. A theme of your own leaves it alone; only an entry that means "the toolkit's own theme" sets it.

Where a search result's checkbox sits

App.Settings.Cards.SelectionMode is new: it says where the selection checkbox of a search result goes. OnHoverBeforeIcon and AlwaysBeforeIcon give it a column before the icon, OnHoverOverIcon lays it over the icon while the row is hovered, and ReplacingIcon drops the icon for it. It defaults to OnHoverOverIcon, which is what the renderers used to hard-code, so nothing changes until you set it.

The assistant page

The chat view now takes the app's sidebar over with its own chat history (Sidebar.ShiftTo) instead of opening a second sidebar beside the transcript, and the model and assistant selectors moved from the composer's footer to the hub header. A host that supplies its own HeaderCommands, or runs the view in CompactMode, keeps the selectors at the top of the empty state and its sidebar to itself, so there is nothing to change there.

Porting checklist

  1. Bump Curiosity.FrontEnd* and Tesserae together.
  2. For each INodeRenderer: change the two return types, start from NodeResult.For(this, node), and move each piece of your old custom title into the slot that means it (SetId, SetBadge, SetFooterEntries, SetText, SetContent).
  3. Replace PreviewWidth/PreviewHeight with ModalSize, and card.Body with SetModalContent.
  4. Replace (await PreviewAsync(…)).Merge() with NodeResult.PageFromPreviewAsync(this, node, state).
  5. Turn every WithCardCustomizer / WithCustomizedRenderer / WithRenderer into CustomizeResult, or OnResultRendered if you only wanted the drawn result.
  6. Delete your own highlighting, selection and context-menu wiring on result rows.
  7. Rename Header.* statics to NodeOpenIn.*.
  8. Drop forceModal: / ShouldOpenAsModal from every NodePreview.For call, and swap App.CloseAllPreviews() for NodePreview.CloseAll().
  9. Tag your renderer's "open where it came from" command with .ShowForMultiSelection("open-in-source") if it isn't already. That is what puts it on the preview's header as a named button.
  10. Replace every Link(...) with Button(text, href: url), and drop every Button.Link() / .DefaultLink() (.DangerLink() becomes .Danger()).
  11. Turn component footer entries into InlineLabels, and check any CSS of yours that matched buttons by element rather than by .tss-btn.
  12. Drop the activateSupport argument from CurrentUser.Set, and any SupportChat / IconChip.CentreGlyphOnItsInk calls.
  13. Re-check your app's colours against Tesserae's own light and dark themes, which the built-in Light / Dark / Auto entries now use unchanged, and set your brand colour with Theme.SetPrimary(light, dark) rather than through a theme of your own if that is all you were overriding.
© 2026 Curiosity. All rights reserved.