A catalog app is the page shape for "several lists a reader perceives as different places": the clients, the partners, the deals, what is waiting on us. It is nothing but a menu of categories, each delegating to Controls.Catalog (MeshSearch) with its hidden queries, and one query bar shared by all of them.

public static IObservable<UiControl?> Portfolio(LayoutAreaHost host, RenderingContext _) =>
    Observable.Return<UiControl?>(new CatalogApp("Portfolio")
        .WithCategory("reply", "Reply needed",
            CatalogSearch.Computed(replyNeededFilter, itemArea: "Card", whenEmpty: host.Localize(NothingWaitingKey)))
        .WithCategory("clients", "Clients",
            CatalogSearch.Of("nodeType:Crm/Counterparty scope:subtree content.type:Crm/CounterpartyType/Client partitions:all",
                itemArea: "Card"))
        .WithCategory("deals", "Deals",
            CatalogSearch.Of(openDeals, "Open", "Strip"),
            CatalogSearch.Of(wonDeals, "Won", "Strip"))
        .Render(host));

The contract

Part What it is
CatalogApp(title) A PageTitle (when a title is given), the shared bar, and the menu. Render(host) renders it into the host's area.
Category — WithCategory(id, label, params CatalogSearch[]) One menu entry. id is stable and URL-safe: it is the menu AREA id and what a deep link names; label is the visible text (localise it before passing it). A category shows its searches one under the other. A category with ONE search whose title is absent or equals the label shows no section title: the tab already says it (see The page header).
CatalogCategory { When = IObservable<bool> } A conditional category: in the menu only while When emits true (the AI catalogs' "This space" tab). Until it first answers it counts as false, so it never holds back the other categories.
CatalogCategory { Header = control } A control above the category's searches — e.g. the legend of what the hits' badges mean.
Search — CatalogSearch.Of(filter, title?, itemArea?, sort?) One hidden query: a filter WITHOUT sort: plus its default CatalogSort (Last activity unless stated; CatalogSort.Name is cheaper over partitions:all, because last activity costs a read per hit). Rendered as Controls.Catalog with the search box hidden.
CatalogSearch.Computed(IObservable<string?> filter, …, whenEmpty?) A COMPUTED hidden query. Each distinct emission re-aims the section; a null emission is the empty state and renders whenEmpty (localised by the caller, shown as a plain label — never parsed as markup) instead of running a query. Until the filter FIRST answers, the section shows the platform's indeterminate progress bar — never nothing — carrying whileLoading when the six-parameter overload passes one (localised by the caller).
.WithCreate(nodeType, namespace, when?) The section's "+" (CreateNodeType / CreateNamespace). Always state the namespace for a subtree query. when (an IObservable<bool>) hides it while false — e.g. "only once the set has a member"; until it answers, the results render without the "+".
.WithMaxColumns(n) · .WithPageSize(n) A card grid instead of one hit per row; hits per page before "Load more".
.WithConfigure(c => …) The last-mile hook over the composed MeshSearchControl — grouping (WithGroupBy(…).WithSectionCounts(true)), render mode, anything. Applied last.
WithoutQueryBar() No shared bar — an embedded start tab carries none.
WithPlaceholder(text) The bar's placeholder (localise before passing); otherwise a language-neutral sort:name, sort:lastModified-desc (the bar carries its own search icon).

The page header

Title, bar and menu form one tight block (CatalogApp.HeaderGap, 8px between them). The shared bar is a text field with a start search icon, full width up to CatalogApp.QueryBarMaxWidth (40rem) so it keeps a readable measure on a wide screen. The menu is the platform tab strip. Under the active tab, CatalogSearch.ShownTitle(label, sole) decides the section title: for a sole search whose Title is absent or equals the category label (case and surrounding blanks ignored) it is empty and MeshSearch omits the heading and its rule — no "Clients" under the "Clients" tab. A different title, or any title when the category holds several searches, is kept (it tells the sections apart). The Render/Section overloads without sole keep the old behaviour for callers that compose a search themselves.

Hover actions on a hit

A hit's delete and drill-down buttons share one mesh-search-hover-actions container in the hit's top-right corner (vertically centred on a list row). It appears on hover, on keyboard focus within it and on keyboard selection, hides instantly so moving to the next hit never shows two, and stays up while the delete confirm is armed. It never sits on the hit's title or icon.

The shared query bar

The menu's area ids are the category ids. Render(host, selected) opens on selected, else on the area reference's id without its URL query (…/area/{App}/{categoryId}?x=1 → categoryId), else on the first category; an unknown id falls back to the first. The selection is carried as the tabs' ActiveTabId.

CatalogApp.CategoryIdOf (the reference id without its URL query) is applied only inside Render(host): a caller that composes the app itself with Compose(typed, selected) must call CategoryIdOf on its own reference id before passing selected. In an AppTabs page a pack tab id and a category id share the ONE reference id, so the host decides which wins — the Plugins host gives the pack tab precedence, compared case-insensitively (MeshWeaver.Plugins#4056).

What is NOT in yet

Adopters

Where Shape
CRM portfolio board (MeshWeaver.Crm) Reply needed / Waiting on them (computed) · one category per counterparty kind · Deals (legend + pipeline).
AI catalogs — src/MeshWeaver.AI/AiCatalogLayoutAreas.cs (Plugins) Agents / Skills / Providers / Models / Tiers × This space · User · Global (or one category per source); a "+" per tab into that tab's namespace; "This space" conditional.
AI Settings — MyAi/Panel/Source/AiSettingsAreas.cs (Plugins) Per kind (Skill / Agent / Model) one category per ACTIVE source row, name order, four columns; one bar per kind (QueryDataId per kind, because the apps share the page's area).
Submissions book — ReinsuranceDemo/Book (MeshWeaver.Reinsurance) One category per tab of BookQueries.Tabs under the node's bound header; embedded with Render(host) inside a template. The last caller of WithMeshSearch.
Examples start tab — Developer/DeveloperApp Categories per extension slot, sort:name, 3 columns, no bar.
AI onboarding wizard Chapters as categories, one search each, a non-default per-viewer itemArea.

Surveyed and deliberately NOT on CatalogApp

An inventory of every Controls.MeshSearch / Controls.Catalog / WithMeshSearch site on the five repos' main (2026-10-24) found most of them are not a catalog PAGE at all — a catalog app is a menu of several lists a reader perceives as different places, with one shared bar. These stay as they are:

Where Why it stays
Threads app — AiThreadsApplication.cs, ThreadGroups.cs (Plugins) Not on WithMeshSearch either: its groups partition ONE projected query IN MEMORY by content facts (declared group, legacy agent/path placement, "mine"), delegated work is folded into its root, the group list is whatever the readable threads declare, and the menu carries lazy Count/Active badges. It adopts when groups are query-expressible (or a rows variant of CatalogSearch exists) and the menu carries counts.
Home catalog — UserActivityLayoutAreas (core) ONE MeshSearchControl whose scope tabs (MeshSearchScopeTab) are its tabs, with a sort dropdown, view options, a create href, per-viewer privacy and a drag-and-drop apps band above it (Architecture/AppsHome). The search options themselves are reachable through WithConfigure; what it would change is the composition (scope tabs inside one control vs. categories of sections) and the sibling apps band, which is why it was not moved without a design of its own.
Inbox (core InboxLayoutArea) and Mailbox (Essentials/Mailbox) The Inbox is one selectable DataGrid list with bulk Delete/Undo that navigates to a reading view per message - the reason it is not a MeshSearchControl (Architecture/CommunicationHub, "why it is not MeshSearchControl"). Mailbox's pages (Inbox / Drafts / Sent) are routes with their own hints, not tabs of one app.
Social feed — SocialMedia/PostsHub One feed sliced by author (a FeedCard per row, one column, MaxRows), whose tab set is the live set of authors; it is a filter of one list rather than a menu of different places. Not evaluated beyond that.
Underwriting Workbench, Claims Register (WithReach partition tabs) A list inside a dashboard of KPIs and a board, scoped by partition.
Playroom, Chess history, Store sources / extension shelf / exercise grids, Edu module, Expenses, Approvals, DataModelling, Fleet console, settings tabs of the connector apps A single list embedded in a detail page, a settings tab or a dashboard section (often with a summary above it). There is no menu of categories to make.

CatalogExtensions.WithMeshSearch is superseded by this and will be marked [Obsolete] once its callers have moved (consumers build with TreatWarningsAsErrors, so the attribute would break them on the day they took the platform).

See also