Skip to content

Surfaces

A surface is a named place in an app’s UI that plugins fill with components. The host asks for “the article for this object” and does not know which plugin will answer.

A role names a place: Article is the main content area, Section is an object inside a stack, CardContent is the body of a card, and StatusIndicator is the status bar. Each role declares the shape of the data it passes, such as { subject, attendableId } for an article.

The roles a plugin fills most often, such as Article for the main content area, come from AppSurface in @dxos/app-toolkit. App capabilities lists them.

Surface lists the rest and shows how to narrow a role’s subject to one type.

A surface contribution is a component plus a filter. The filter names one or more roles and a guard on the data, for example “the article role, when the subject is a Note”. Plugins contribute surfaces through the ReactSurface capability.

import * as
import Surface
Surface
from '@dxos/app-framework/Surface';
import * as
import AppSurface
AppSurface
from '@dxos/app-toolkit/AppSurface';
export const
const NoteArticleSurface: ReactDefinition<T>
NoteArticleSurface
=
import Surface
Surface
.
const create: <Omit<NonNullable<AppSurface.ArticleData<any, {}, unknown>>, "subject"> & {
subject: Note & OfKind<EntityKind.Object>;
}, {
role: string;
subject: Note & OfKind<EntityKind.Object>;
attendableId: string;
}, "article">(definition: Readonly<{
id: "article";
filter: Filter<Omit<NonNullable<AppSurface.ArticleData<any, {}, unknown>>, "subject"> & {
subject: Note & OfKind<EntityKind.Object>;
}>;
position?: Position;
component: React.ComponentType<...>;
props?: ((props: ComponentProps<...>) => {
...;
}) | undefined;
}>) => ReactDefinition<Omit<...> & {
subject: Note & OfKind<EntityKind.Object>;
}>

Creates a React surface definition from a typed filter.

create
({
id: "article"
id
: 'article',
// Matches the article role when the data's subject is a Note.
filter: Filter<Omit<NonNullable<AppSurface.ArticleData<any, {}, unknown>>, "subject"> & {
subject: Note & OfKind<EntityKind.Object>;
}>
filter
:
import AppSurface
AppSurface
.
const object: <Role<AppSurface.ArticleData<any, {}, unknown>>, typeof Note>(token: Role<AppSurface.ArticleData<any, {}, unknown>>, schema: typeof Note, predicate?: ((data: NonNullable<AppSurface.ArticleData<any, {}, unknown>>) => boolean) | undefined) => Surface.Filter<Omit<NonNullable<AppSurface.ArticleData<any, {}, unknown>>, "subject"> & {
subject: Note & OfKind<EntityKind.Object>;
}> (+1 overload)
object
(
import AppSurface
AppSurface
.
const Article: Role<AppSurface.ArticleData<any, {}, unknown>>

Role token for the article role. Subject defaults to any until narrowed via

object

,

literal

, or

subject

. Extra passthrough props are permitted.

Article
,
class Note
Note
),
component: React.ComponentType<{
role: string;
subject: Note & OfKind<EntityKind.Object>;
attendableId: string;
}>

Accepts any component type (not just a function) so a container re-exported through a lazy() barrel as ComponentType<any> can be registered without a cast.

component
:
const NoteArticle: ({ role, subject, }: AppSurface.ObjectArticleProps<Note>) => React.JSX.Element
NoteArticle
,
// Maps the surface's data onto the component's props; checked against the
// filter's data type.
props?: ((props: ComponentProps<Omit<NonNullable<AppSurface.ArticleData<any, {}, unknown>>, "subject"> & {
subject: Note & OfKind<EntityKind.Object>;
}>) => {
role: string;
subject: Note & OfKind<EntityKind.Object>;
attendableId: string;
}) | undefined

Maps the surface props onto the component's own props, so a plain container can be registered directly instead of being wrapped in an adapter that unpacks data.

Prefer this over an inline component: ({ data }) => <Container … />: the mapper's input type derives from the same filter that defines the surface's data shape, so the unpacking is checked against the filter rather than restated by hand.

@example Surface.create({ id: 'defaultPluginSettings', filter: AppSurface.settings(AppSurface.Article), component: DefaultSettings, props: ({ data: { subject } }) => ({ subject }), });

props
: ({
role: string

The resolved role NSID string (e.g. org.dxos.role.article). Present on matched components only — multi-role containers can branch on this value by comparing against a token's .role (e.g. role === AppSurface.Section.role).

role
,
data: Omit<NonNullable<AppSurface.ArticleData<any, {}, unknown>>, "subject"> & {
subject: Note & OfKind<EntityKind.Object>;
}
data
: {
subject: Note & OfKind<EntityKind.Object>
subject
,
attendableId: string
attendableId
} }) => ({
role: string
role
,
subject: Note & OfKind<EntityKind.Object>
subject
,
attendableId: string
attendableId
,
}),
});

Stash’s Surface module in the inspector renders its article with the sample link:

  • The host renders <Surface.Surface type={role} data={data} />. The framework collects every contribution whose filter names that role and whose guard accepts the data.
  • Contributions are ordered by their position, then by plugin order. The host renders the first one, or the first limit of them when it wants several, as a toolbar does.
  • A filter can match several roles at once with AppSurface.oneOf, or require two conditions with AppSurface.allOf.
  • A component can render another plugin’s surface by rendering Surface.Surface itself. This is how a stack shows each object with whatever plugin owns its type, and how a markdown document embeds a table.
import * as
import Surface
Surface
from '@dxos/app-framework/Surface';
import * as
import AppSurface
AppSurface
from '@dxos/app-toolkit/AppSurface';
// Renders whatever surface another plugin contributed for this object in the
// section role.
export const
const Embedded: ({ subject }: {
subject: Note;
}) => React.JSX.Element
Embedded
= ({
subject: Note
subject
}: {
subject: Note
subject
:
class Note
Note
}) => (
<
import Surface
Surface
.
const Surface: (<TToken extends Role<any>>(props: TypedProps<TToken>) => React.ReactNode) & React.NamedExoticComponent<TypedProps<Role<any>>>
Surface
type: Role<{
attendableId: string;
nodeId?: string;
subject: any;
extrinsic?: boolean;
}>
type
={
import AppSurface
AppSurface
.
const Section: Role<{
attendableId: string;
nodeId?: string;
subject: any;
extrinsic?: boolean;
}>

Role token for the section role.

Section
}
data?: {
attendableId: string;
nodeId?: string;
subject: any;
extrinsic?: boolean;
} | undefined
data
={{
subject: any
subject
,
attendableId: string
attendableId
:
subject: Note
subject
.
OfKind<EntityKind.Object>.id: string
id
}}
limit?: number | undefined
limit
={1}
/>
);
  • A surface module loads lazily. The manifest lists the roles the module serves, and the plugin manager imports the module the first time one of those roles is requested.
  • A surface component gets only the data its role passes. It must not assume which container it is in, how wide it is, or that it is the only surface on screen; the same article can show in a plank, a stack or a dialog.
  • The props mapper of a surface runs during the host’s render, so it cannot call hooks. A component that needs hooks is its own component.
  • A surface does not own the object it renders. Other surfaces, devices and agents edit the same object, so the component reads it reactively.