React components and hooks
The components, hooks, and registration API published by @nexia/sdk for App frontend code.
React components and hooks
Choose a task below, then copy the matching import and example. The package root supplies pure contracts and helpers; /host supplies host-bound runtime APIs; /testing supplies test helpers. A registered screen runs inside the configured NEXIA host.
Find an API
| Task | Start with |
|---|---|
| Register routes and inspectors | autoRegisterPackageSurfaces |
| Lay out a screen | WorkSurface, NxPageFrame, NxAdaptiveSplit |
| Build forms | NxFormField, NxTextInput, hierarchical choices |
| Query and render a list | ResourceTable, useResourceListParams |
| Show alternate Resource views | Board, calendar, scheduler |
| Import or migrate data | Data migration workspaces and operation status |
| Fetch and authorize | api, query hooks, permission helpers |
| Open related work | Inspector and work tabs |
| Report errors and format values | Messages and formatters |
| Connect business workflows | Approval, Signature, agent bindings |
| Use the test host | appTestServer, appTestI18n |
Task-specific host APIs
Import these APIs from @nexia/sdk/host. Core connects components, hooks, runtime functions and registries in resources/js/app-sdk/configure-host.ts. SDK exports and Core bindings do not establish tenant/user availability or authorization. Pure helpers need no binding; SDK-composed helpers such as useFeedbackAction and useDataMigrationBackgroundOperation use existing host APIs/hooks.
| Public API | Role |
|---|---|
NxCanonicalRoutePane, useWorkSurfaceLabels | Canonical route pane and shared title/breadcrumb labels |
NxLinkButton, NxIconLink, NxDropdownMenu, NxTooltip, NxHelpTooltip | Route links, action menus and contextual help |
NxInsightSurface, NxInsightFilterBar, NxOverviewBand, useInsightFilterState | Analytical page framing, filter state and summary cards |
NxOrgChart, OperatingUnitManagementSurface | Organization chart renderer and host Operating Unit management surface |
NxOrganizationTargetSelector, useOrganizationTargetsQuery, useOrganizationListScope | Read-authorized organization targets and ordinary list URL scope; put a page-owned selector in the Route Surface organizationScope slot |
useLegalEntityApplicabilityQuery, useLegalEntityMembersQuery, useOperatingUnitSelection | Applicability, member lookup and Operating Unit selection for the supplied permission/context |
NxMissingRequiredReferencesAlert, ResourceInspectorState, ResourceListToolbarMoreMenu | Missing-reference guidance, Inspector loading/error/empty presentation and list overflow actions |
useRegisterResourceCreateReceiver, useResourceCreateCompletion | Register a contextual create receiver and report completion back to its originating surface |
SignatureAuthenticationMethodSelector, SignatureInvitationChannelSelector, useTrustedAssetsQuery | Signature authentication/invitation choices and authorized trusted assets |
useApprovalSharedLinesQuery, processUserTaskFormRegistry | Shared Approval lines query and App Process user-task form registration |
Signature
Import pure contracts from the package root. Import host-bound components, hooks,
registries, and the HTTP client from the host subpath:
import type { ResourceListSchema } from "@nexia/sdk";
import {
WorkSurface,
ResourceTable,
NxSearchField,
useResourceListParams,
api,
can,
} from "@nexia/sdk/host";The package declares its runtime libraries as peer dependencies, so your App shares the host's copies rather than bundling its own:
{
"peerDependencies": {
"@nexia/sdk": "0.5.0",
"@tanstack/react-query": "^5.99.0",
"axios": "^1.11.0",
"react": "^19.1.0"
}
}Never add these to your App's dependencies. A second React or Query client in
the bundle breaks context.
App frontend boundary rules
Every external package imported by App frontend source must be declared in that
App's own package.json. A package supplied or hoisted by Core is not an implicit
dependency. For example, importing axios without declaring it is a phantom
dependency even when the current host installation happens to resolve it.
Runtime singletons belong in peerDependencies: react, react-dom,
@tanstack/react-query, and @nexia/sdk. Declare axios as a peer
with the repository convention ^1.11.0 when the App imports it. Test-only tools
may be devDependencies; runtime imports may not rely on them.
Cross-boundary interaction goes through SDK contracts only. Use the package root
for pure contracts and the /host subpath for host-bound APIs. #app/* resolves
inside resources/js; #lang/* resolves inside the fixed resources/lang
contract path. Both are App-internal package imports. Neither authorizes Core
aliases, cross-App imports, or imports into another package.
Because package imports targets are exact, include the source extension in
#app specifiers, for example #app/resources/orders/order-resource-contract.ts.
The host discovers an App only through these fixed shapes:
composer.json→extra.nexia.appresources/js/index.tsresources/js/resources/*/*-resource-contract.tsresources/lang
Do not move or rename those discovery surfaces. Their contents and every other module under the App remain App-private; discovery is not a general import API.
The SDK itself resolves through its exports conditions, never through a path
alias. The development condition serves TypeScript source directly, so the dev
server, Vitest, and typecheck need no built artifact; production builds resolve
the import condition against dist, which task frontend:build compiles
first. If a subpath fails to resolve, check the installed SDK version and its package exports before changing imports.
Minimal example
An App entry registers its surfaces and its Inspector adapters lazily. Two calls cover the conventional layout:
import {
autoRegisterPackageResourceInspectors,
autoRegisterPackageSurfaces,
} from "@nexia/sdk/host";
autoRegisterPackageResourceInspectors(
import.meta.glob("./resources/*/inspector/register-*-inspector.ts", {
eager: true,
})
);
autoRegisterPackageSurfaces({
appPrefix: "Workshop",
appKey: "workshop",
overview: () => import("./overview/WorkshopOverviewSurface"),
surfaces: import.meta.glob("./resources/*/surface/*Surface.tsx"),
});
appComponentRegistryis host-only. It exists in the platform atresources/js/shell/runtime/app-component-registry.ts, but@nexia/sdkdoes not export it — so an App Package can only reach it through a forbidden@shell/alias. Older documentation showsappComponentRegistry.register(...); useautoRegisterPackageSurfacesinstead, andregisterAgentComponentfor agent components.
Both registrations are filesystem-derived. autoRegisterPackageResourceInspectors
rejects a path outside ./resources/{resource}/inspector/register-{resource}-inspector.ts,
so a hand-maintained list of Inspector side-effect imports is never needed.
Parameters
AutoRegisterPackageSurfacesOptions:
| Field | Type | Required | Behavior |
|---|---|---|---|
appPrefix | string | Yes | PascalCase prefix for derived component names, e.g. Workshop |
appKey | string | No | App key; used to scope registrations |
overview | PackageSurfaceLoader | No | The App's required overview surface |
surfaces | Record<string, () => Promise<unknown>> | Yes | Path → lazy loader map, normally from import.meta.glob |
overrides | Record<string, PackageSurfaceOverride> | No | Explicit name → loader, or loader plus an eager route prefetcher, for surfaces off the derivable path |
Component names are derived from the surface file path. A surface that has moved
or been renamed needs an overrides entry, or the backend's published
pageElements() name will have no binding.
Route prefetch overrides
PackageSurfaceOverride accepts either the existing bare lazy loader or an
explicit registration with load and an optional routePrefetcher:
This Note detail example uses the generated endpoint and cache key. Put noteDetailQueryOptions in a Resource-local query module and import that same function in the entry and NoteShowSurface; remove the surface’s original local copy. The endpoint authorizes the stored owner. Do not infer a list target from Shell context to prefetch a Legal Entity list.
import { queryOptions } from "@tanstack/react-query";
import { api, autoRegisterPackageSurfaces } from "@nexia/sdk/host";
import {
type NoteRecord,
noteApiRoute,
} from "#app/resources/notes/note-resource-contract.ts";
function noteDetailQueryOptions(tenantId: string, id: string) {
return queryOptions({
queryKey: ["workshop-note", tenantId, tenantId, id],
queryFn: async ({ signal }) => {
const { data } = await api.get<{ note: NoteRecord }>(
noteApiRoute(id),
{ signal },
);
return data;
},
});
}
autoRegisterPackageSurfaces({
appPrefix: "Workshop",
appKey: "workshop",
overview: () => import("./overview/WorkshopOverviewSurface"),
surfaces: import.meta.glob("./resources/*/surface/*Surface.tsx"),
overrides: {
WorkshopNoteShowSurface: {
load: () => import("./resources/notes/surface/NoteShowSurface"),
routePrefetcher: ({ queryClient, tenantId, params }) => {
if (!tenantId || !params.id) return false;
void queryClient.prefetchQuery(
noteDetailQueryOptions(tenantId, params.id),
);
return true;
},
},
},
});The host starts this prefetcher on the same navigation tick as the surface chunk
request. Returning true marks the route as handled and suppresses the surface
module's later prefetch fallback. Returning false keeps that fallback, which
is appropriate when a permission or route parameter is missing. The context
also supplies Legal Entity, pathname, search parameters, route pattern, and
parsed route parameters.
Keep the prefetcher in a module that does not eagerly import the surface component. Importing that component defeats code splitting by pulling the surface into the App entry chunk.
Responsive layout parameters
NxResponsiveRegion accepts normal HTMLAttributes<HTMLDivElement> and makes
its allocated inline size available to descendant CSS container queries.
NxAdaptiveSplit establishes that region itself and adds these options:
| Prop | Type | Required | Default | Behavior |
|---|---|---|---|---|
children | ReactNode | Yes | — | Renders in source order; conventionally the primary region followed by an aside |
density | compact | default | No | default | Uses a 1rem or 1.5rem gap |
asideWidth | narrow | default | wide | No | default | Reserves 18rem, 22.5rem, or 26rem for the second column |
twoColumnAt | compact | default | wide | No | default | Enables two columns at an allocated width of 48rem, 56rem, or 64rem |
className | string | No | — | Adds classes to the adaptive grid |
containerClassName | string | No | — | Adds classes to the outer container-query boundary |
Output or return: Layout primitives
Import these instead of writing layout markup. They carry the Shell's spacing, density, and dark-mode behavior.
| Component | Use for |
|---|---|
WorkSurface, WorkSection | The standard App page frame and its sections |
NxPageFrame, AppRouteFrame | Page-level frames |
NxResponsiveRegion, NxAdaptiveSplit | Layouts that respond to allocated tab or pane width instead of viewport width |
NxTabNav | Underline tabs for switching sections inside one App workspace |
NxSectionStack, NxSectionCard, NxDetailRow, NxActionButton | Section rhythm, card sections, labeled values, and row actions |
NxFieldGroup, NxFieldset, NxFieldLegend, NxFormSection, NxFormField | Form structure |
NxResourceInformationView, NxResourceInformationForm | Read and write projections of one ordered Resource information schema |
NxEmptyView, NxLoadingBlock | Empty and loading states |
NxModalDialog, NxAlert | Overlays and inline notices |
In-place tab navigation
NxTabNav renders the host's shared underline-tab interaction while the App
owns the selected value and the content below it. It does not navigate, fetch,
or persist state. Keep URL synchronization in the App when a tab must survive a
reload or be shareable.
import type { NxTabNavItem } from "@nexia/sdk";
import { NxTabNav } from "@nexia/sdk/host";
const tabs: NxTabNavItem[] = [
{ id: "daily", label: t("attendance.tabs.daily") },
{
id: "issues",
label: t("attendance.tabs.issues"),
badge: openIssueCount,
},
];
<NxTabNav
aria-label={t("attendance.tabs.label")}
items={tabs}
value={activeTab}
onChange={setActiveTab}
scrollable
/>;items, value, and onChange are required. Each NxTabNavItem has an id
and label, with optional badge, disabled, and testId. Use scrollable
when the complete tab set may not fit the allocated pane width, and supply an
aria-label that names the group rather than repeating one tab label.
Section spacing ownership
AppRouteFrame owns the gap between its direct section children. A reusable
component that renders two or more complete sibling sections uses
NxSectionStack; do not reproduce that contract with space-y-section or a
local gap. NxFormSection requires an explicit spacing choice: use
spacing="stack" for fields within one section and spacing="section" when its
direct children are independent section cards.
Shared Resource information schema
An ordinary CRUD Resource declares its sections and fields once with
defineResourceInformationSchema. Each field owns one label key, help or
definition keys, requirement classification, responsive span, projection
modes, read value, and Edit control. NxResourceInformationView and
NxResourceInformationForm then preserve the same section and field order.
For unsaved form automation, pass binding={{ effect: 'draft', disabled: pending }}
to NxResourceInformationForm. A published Resource route supplies its resource
key, mode, record identity and route scope. Outside that route owner, supply
resourceKey, resourceId and scopeKey explicitly. Do not repeat schema fields
in a separate Agent map or add an Agent registration to every Resource.
Use the controls' public value writers, such as NxTextInput.onValueChange below;
an event-only onChange does not expose a safe draft setter. Existing field labels
and current selectable options describe the mounted action. Hidden, sensitive,
read-only, disabled and unsupported controls are excluded. Missing bindings and
effect: 'autosave' do not expose draft actions. Keep the page's permission gate.
The shared batch validates up to 32 supplied fields before writes, protects user
edits and reports an unsaved draft after render; it does not invoke Save.
Agent-visible screen callbacks declare effect: 'read' | 'draft'. Shared list,
option-search and form hosts provide this at their common registration point;
Resource authors do not duplicate it. Unclassified callbacks and generic DOM
event filling/clicking are not executable. An explicitly requested save uses an
existing server mutation tool with its operation policy and confirmation flow.
For remote single-choice controls, NxCombobox.searchOptions accepts a
scopeKey and search(query, signal?) returning authorized options. Reuse the
picker's existing lookup and cache; searching neither selects nor saves a value.
The draft setter accepts only an enabled option in the currently loaded list.
Multi-select is not exposed by this search contract.
Nested information schemas reuse their existing section and field keys for
identity. Repeated sections need stable row keys; array positions are not stable
when rows reorder. A bound NxOrgChart uses its existing selectable nodes and
controlled selectedId/onSelect for draft selection. This does not authorize
moving nodes, editing the hierarchy or automating read-only Inspector charts.
Composed forms without an information schema may declare the same binding on
NxFormSection and canonical NxFormField.fieldKey identities for ordinary fields.
Schema forms already supply field identity: do not repeat it. sensitive excludes
a composed field. Existing typed writers and choice lists remain authoritative.
Disable bindings when permission is absent, submission is pending, or an embedded
change callback can persist data. Repeated rows still need stable identities.
import {
defineResourceInformationSchema,
resourceInformationValue,
} from "@nexia/sdk";
import {
NxTextInput,
} from "@nexia/sdk/host";
interface DetailContext { item: { name: string } }
interface FormContext { name: string; setName: (value: string) => void }
const schema = defineResourceInformationSchema<DetailContext, FormContext>()({
sections: [
{
key: "basics",
titleKey: "orders.sections.basics",
fields: [
{
key: "name",
labelKey: "orders.name.label",
helpKey: "orders.name.help",
requirement: "save",
view: ({ item }) => resourceInformationValue(item.name),
edit: {
kind: "control",
render: ({ context }, slot) => (
<NxTextInput
id={slot.id}
aria-describedby={slot.describedBy}
value={context.name}
onValueChange={context.setName}
/>
),
},
},
],
},
],
});Use modes for static View/Create/Edit omissions; context-dependent
visibility.view, visibility.create, and visibility.edit run only after that
mode contract. Use span: "full" for wide fields. A section may set one or two
formColumns and an optional aggregate/projection source; View remains an
ordered detail list. visibleEmptyRequirements controls which empty requirement
classes remain visible in NxResourceInformationView. Use a custom Edit
projection for a compound control, while the field keeps its single View value
and canonical position. Route loading,
mutations, permission decisions, related-resource actions, and Inspector
density remain Surface-owned. Do not create separate Show and Form field lists
or surface-specific section translation keys for the same information.
Container-responsive layouts
Use NxAdaptiveSplit for the common primary-plus-supporting layout. It stays a
single column until the component itself receives enough width, so opening the
Activity Center or moving the tab into a narrow split pane moves the aside
below the primary content even when the browser remains wide.
import {
NxAdaptiveSplit,
NxSectionCard,
NxSectionStack,
} from "@nexia/sdk/host";
export function OrderDetail() {
return (
<NxAdaptiveSplit>
<NxSectionStack>
<NxSectionCard title="Order">Order fields</NxSectionCard>
</NxSectionStack>
<aside>
<NxSectionStack>
<NxSectionCard title="Actions">Order actions</NxSectionCard>
<NxSectionCard title="History">Order history</NxSectionCard>
</NxSectionStack>
</aside>
</NxAdaptiveSplit>
);
}Use NxResponsiveRegion when the layout is not a two-column split. Container
variants on descendants measure the nearest region, not window.innerWidth:
import { NxResponsiveRegion } from "@nexia/sdk/host";
<NxResponsiveRegion>
<div className="grid gap-4 @min-[36rem]:grid-cols-2">
<section>Summary</section>
<section>Evidence</section>
</div>
</NxResponsiveRegion>;Core binds both facades to the native CSS implementation. The live approval
detail at resources/js/core/approval/ApprovalCaseSurface.tsx is the source
example: its actions and history move below the document when the work region
falls below the split threshold. No resize listener or ResizeObserver is
involved.
Organization selection
The SDK already provides the generic NxCombobox; the organization controls
reuse shared choice primitives and add organization-specific presentation.
Use NxOrganizationTargetSelector for a page-owned list scope. Import it and
useOrganizationListScope from @nexia/sdk/host; pass the page's
read permission explicitly. The hook signature is
useOrganizationListScope(tenantId, permission, mode = "legal-entity-list", enabled = true).
| Selector mode | Input and selection |
|---|---|
legal-entity-list | Authorized options; value: string[] and onChange(ids) for multiple Legal Entities |
legal-entity-operating-unit-list | Authorized exact targets pairs; value: OrganizationTargetQuery and onChange(query) for Legal Entity and Operating Unit dimensions |
legal-entity-single | Authorized options; value: string | null and onChange(id) for one create or execution target |
With one authorized option, the selector displays a summary without setting
value or calling onChange. The Surface owns any single-target default;
create and recovery forms that require a selection use the form contracts below.
The hook supports the two list modes. Inside a Route Surface, connect its
selectorProps directly to the frame slot:
import {
NxOrganizationTargetSelector,
NxPageFrame,
useOrganizationListScope,
} from "@nexia/sdk/host";
const scope = useOrganizationListScope(tenantId, readPermission);
<NxPageFrame
organizationScope={<NxOrganizationTargetSelector {...scope.selectorProps} />}
>
{children}
</NxPageFrame>;tenantId, readPermission, and children above belong to the consuming Surface.
Use "legal-entity-operating-unit-list" as the third hook argument for an LE/OU
list. NxPageFrame places the selector after the breadcrumb and before actions;
AppRouteFrame also accepts organizationScope. The frame provides placement,
while the Surface owns the permission, supported axes, and list query. Do not
repeat the selector in actions, the table toolbar, or ordinary column filters.
Fetch the list only when scope.requestParams !== undefined; pass those
URLSearchParams to the request and include the selected scope in its cache key.
An empty parameter set means all authorized targets. An undefined set means the
scope is loading, invalid, unavailable, or disabled: do not replace it with an
empty set and fetch a broader list. The selector's status presents the
non-ready state. Canonical list keys are legal_entity_public_ids[] and
operating_unit_public_ids[].
The target catalog comes from useOrganizationTargetsQuery for the requested
permission. Preserve the returned exact LE/OU pairs; never form a Cartesian
product from independent lists. Backend authorization remains authoritative.
Custom relationship or multi-permission pages may keep their own URL adapter
and target loading while reusing the selector. Standard tenant common-data
pages have no selector; a tenant-owned custom relation or Operating Unit page
may explicitly declare organization read scope. Record ownership alone does
not determine list scope.
For forms, use the body rather than the header scope slot:
NxResourceInformationForm.legalEntityaccepts the root-exportedResourceFormLegalEntity: create uses{ kind: "select", value, options, onChange }with optionaldisabledanderror; edit uses{ kind: "stored", label }from the saved record or an authorized lookup. The host places it first inside the first schema section at full width. Omit it when unnecessary and remove a duplicate form-schema owner field; retain the View/Inspector owner field.- Custom forms use
NxResourceFormLegalEntityFieldfrom/hostinside their first input section, passingtargetandmode="create"ormode="edit". NxOperatingUnitSelectorfrom/hostis a single-choice, full-width OU form input with localized defaults. Supply authorizedoptions,value, andonChange; the App owns target loading and clearing dependent values when the Legal Entity changes. It does not change page or Shell scope.
The older OperatingUnitScopeField takes an OperatingUnitScopeResult through
its scope prop and renders a selected-OU scope panel. It is not the LE/OU
multi-select list contract or the form input above. Keep existing workflows
that need that contract explicit; use NxOrganizationTargetSelector for new
page-owned list scopes. Detail and edit display the saved owner, and no selector
creates a Shell-global active organization.
Form and input controls
| Component | Notes |
|---|---|
NxTextInput, NxTextArea | Text entry |
NxSelect, NxCombobox | Choice; NxComboboxOption / NxSelectOption type the options |
NxCheckbox | Boolean |
NxRadio, NxRadioGroup | One choice with vertical or horizontal group orientation |
NxDatePicker, NxDateRangePicker | Locale-aware single-date and bounded date-range selection |
NxFileDropzone | Uploads; pair with useUploadAttachment or useUploadMedia |
NxColumnBrowser | Embedded Miller-column navigation for hierarchical choices |
NxColumnPicker | Modal selection flow composed from NxColumnBrowser |
NxOperatingUnitSelector, NxResourceFormLegalEntityField | Authorized OU input and shared Legal Entity form field |
OperatingUnitScopeField | Operating Unit scope selection |
NxButton, NxIconButton, NxActionButton | Text, icon-only, and typed-intent actions; icon-only buttons require aria-label |
NxLinkButton, NxIconLink | Button-styled and icon-only route links; icon-only links require aria-label |
NxRefreshControl | Host-owned manual refresh for compact, section, or snapshot placement |
Use NxButton, NxIconButton, or NxActionButton for commands such as
submit, retry, open a dialog, or mutate data. Use NxLinkButton href or
NxIconLink href when activating the control changes the route. Their real
link destination preserves browser link behavior and adds the Shell's
right-click Open in new work tab action for internal routes. onNavigate
on NxLinkButton is an optional navigation side effect; it does not replace
href.
With NxCombobox multiple, the host adds a localized close action to the
fixed panel footer. Set showSelectedItems when selected values must remain
visible as removable chips below the field. Use selectedItemsAriaLabel and
removeSelectedItemLabel when the default accessible labels are not specific
enough for the field.
Radio cards
NxRadio accepts variant="default" | "card"; omitting it keeps the existing
circular radio. Use card for a single-choice option that needs a larger label
or explanation. Both variants use the same native radio and NxRadioGroup
value/name/onChange contract, including keyboard navigation and disabled state.
inputSize controls only the circular indicator in the default variant.
The label accepts React content, so put a short description inside it rather
than adding a separate description property. Do not nest buttons, links, or
other inputs inside the label. Give the group an accessible name and use a grid
inside the group for equal-width cards. Multi-choice fields still use checkboxes;
a button that immediately runs an action is not a radio.
import { NxRadio, NxRadioGroup } from "@nexia/sdk/host";
<NxRadioGroup value={mode} onChange={setMode} aria-label="Entry mode">
<div className="grid gap-3 md:grid-cols-2">
<NxRadio variant="card" value="new" label={
<>
New entry
<span className="mt-1 block font-normal">Enter the details for a new record.</span>
</>
} />
<NxRadio variant="card" value="existing" label="Use existing entry" />
</div>
</NxRadioGroup>Apps adopting this option require both the updated SDK and a Core host that implements the card variant. Existing callers require no changes.
Favorites
Use the host-bound FavoriteButton when an App presents a durable target that
Core has already declared favoriteable. Import the component and its target
type from the package root:
import { FavoriteButton, type FavoriteTarget } from "@nexia/sdk";
const target: FavoriteTarget = {
kind: "record",
resourceKey: "workshop.note",
resourceId: note.public_id,
};
<FavoriteButton target={target} size="xs" testId="note-favorite" />;FavoriteTarget is either a record identity (resourceKey and resourceId)
or a server-declared page identity (key).
The only button props are target, optional size ("xs", "sm", or
"md"), and optional testId. The host owns state, requests, labels, and
the server binding. An App must not send or store a title, href, permission,
or arbitrary URL with this control; favoriting never grants access.
For a detail header, place the compact control in NxPageFrame's
breadcrumbActions slot. For a list primary cell, pass server metadata as
favoriteTarget, or pass the owner record identity as favoriteCandidate so
the host can authorize it first. Set favoriteable={false} for a cell whose
link identifies a related record rather than its row owner. These inputs do
not create another App-owned favorites state or a custom paging contract.
Hierarchical choices
Use NxColumnBrowser when the hierarchy is part of the page. Use
NxColumnPicker when choosing one leaf or several leaves is a blocking modal
step. Both consume the same NxColumnNode[] and search the same leaf labels and
keywords. The browser owns no overlay chrome; the picker composes
the browser inside the host's centered NxModalDialog and commits the pending
leaf through its Select action.
Import the portable node and props types from the package root and the
host-bound renderers from /host:
import type { NxColumnNode } from "@nexia/sdk";
import { NxColumnBrowser, NxColumnPicker } from "@nexia/sdk/host";
const roots: NxColumnNode[] = [
{
value: "platform",
label: t("apps.platform"),
children: [{ value: "tenant.user", label: t("resources.users") }],
},
];
<NxColumnBrowser
roots={roots}
value={resourceKey}
onSelect={setResourceKey}
ariaLabel={t("resources.choose")}
/>;
<NxColumnPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
title={t("resources.choose")}
roots={roots}
value={resourceKey}
onSelect={setResourceKey}
/>;The App owns translated labels, node values, disabled state, the selected value,
and any data loading or mutation triggered by selection. onSelect on the
embedded browser fires when a leaf is chosen; branch rows only navigate the
columns. onActivate can add a separate Enter or double-click action. Use
renderPreview for the final column and onClearSelection when changing a
branch should clear the current leaf.
For multi-selection, set multiple, pass values, and receive the committed
leaf list through onSelectMany(values, nodes). Use renderSelectionPreview
for a review pane whose onRemove(value) callback removes a pending choice;
single mode instead uses value, onSelect, and renderPreview.
Table and list
| Symbol | Kind | Purpose |
|---|---|---|
ResourceTable | component | The standard resource list table |
ResourceTableColumn, ResourceTableSort | type | Column and sort declarations |
ResourcePrimaryCell, ResourceTextCell | component | Conventional cell renderers |
ResourceStatusBadge, NxStatusBadge | component | Status display |
ResourceRowActionsMenu | component | Per-row action menu |
NxPagination | component | Pagination control |
useResourceListParams | hook | Tenant-, context-, and URL-scoped list state |
readResourceListParams, serializeResourceTableSort | function | URL serialization |
keepPreviousResourceListData | function | Query option for smooth pagination |
Column sorting and visibility
ResourceTable is also the canonical table for dashboard, dialog, and detail
rows. Omit toolbar and footer for that chrome-free form. Add a stable
tableId only when the logical view should persist per-table display or column
overrides. The host combines it with the current tenant and user, so do not
derive it from translated text:
Surface excerpt: use the generated Note list’s data, search state, refetch, translation function t, and formatDateTime from useDateTimeFormatter(). Add these columns after the loading/error guards.
import type { ResourceTableColumn } from "@nexia/sdk";
import { ResourceTable, ResourcePrimaryCell, NxSearchField } from "@nexia/sdk/host";
import { type NoteRecord, noteResourceRef } from "#app/resources/notes/note-resource-contract.ts";
const columns: ResourceTableColumn<NoteRecord>[] = [
{
key: "name",
header: t("workshop.note.name.label"),
size: "primary",
cell: (row) => (
<ResourcePrimaryCell
resource={noteResourceRef(row)}
label={row.name}
/>
),
},
{
key: "created",
sortKey: "created_at",
header: t("workshop.note.created_at.label"),
cell: (row) => formatDateTime(row.created_at),
},
{
key: "updated_at",
defaultVisible: false,
header: t("workshop.note.updated_at.label"),
cell: (row) => formatDateTime(row.updated_at),
},
];
<ResourceTable
tableId="workshop.notes"
label={t("workshop.note.list.title")}
columns={columns}
rows={data.data}
schema={data.meta.list_schema}
toolbar={{ leading: <NxSearchField value={search} onChange={setSearch} /> }}
footer
onRetry={() => void refetch()}
getKey={(row) => row.public_id}
renderCard={(row, index) => (
<article className="space-y-2">
{columns[0].cell(row, index)}
<p>{columns[1].cell(row, index)}</p>
</article>
)}
/>;Toolbar and footer chrome are opt-in. An enabled toolbar derives the standard
Filter, scoped Refresh, and final More utilities; onRetry is reused as the
owning-query refresh callback unless an explicit refresh config overrides it.
The toolbar leading slot accepts any query control, including search and date
pickers. showLabel renders the semantic caption visibly, while labelTooltip
adds contextual help beside it. Per-table style and density overrides win over
the optional UserContext.table_style and UserContext.table_density global
defaults and can be reset to “use global”. Use summaryRow.cells for an
aggregate aligned to declared column keys; its optional label supplies the
accessible row label. A table-specific refresh callback overrides the
onRetry fallback without changing error retry behavior.
The backend schema.sortable list is the positive sorting authority. Do not
repeat sortable: true in a visual column. Use sortKey only when the visual
key differs from the backend query key, and sortable: false only to suppress
sorting that the schema would otherwise allow.
For a resource that has a complete compact representation, add optional
renderCard to the preceding ResourceTable example. Enable toolbar (an
empty object is sufficient) to expose the standard More menu, which
then offers Table and Cards, with Table as the default. The host retains the
same query, filters, paging, sorting, refresh, selection, and activation in
either mode. View and Auto/2/3/4 card-column preferences are scoped by
tableId, tenant, and user. Sorting remains the caller's existing URL or query
state; card sort only invokes the same onSortChange contract. renderCard
owns layout: reuse existing column cells or shared permission-filtered action
renderers so table and card callbacks and authorization stay defined once.
Do not opt into cards for tree or summaryRow
lists; those semantics are table-only. Keep nested buttons and links inside the
card body so they do not activate the card row.
Set defaultView="cards" alongside renderCard to start a list in card mode.
A saved view preference takes priority; tables without renderCard and lists
with summaryRow retain table rendering. The Resource table live example shows
both starting views and reuses the same cells and actions.
Every non-structural column appears in More > Configure columns. A
size: "primary" identity column is always visible; use hideable: false for
another essential presentation-only column. defaultVisible: false starts a
hideable column unchecked until the user chooses otherwise. The stored choice
is scoped by tenant, user, and tableId; newly added columns adopt their
declared default. Hiding the currently sorted visual column clears that sort so
the table never remains ordered by an invisible column. These visibility flags
are frontend-only and never belong in meta.list_schema.
Resource action semantics
When one operation appears in both a row menu and a detail or Inspector action rail, derive both presentations from one action set after effective permission filtering. Keep the visible resource actions in the same order: View, Edit, management actions, send or reset actions, lifecycle actions, then Delete. Host-owned chrome such as Open in tab and Close may surround that sequence, but must not reorder it. Frontend permission checks control visibility only; the backend still authorizes every operation.
Each ResourceRowActionsMenu item keeps behavior and presentation separate:
idis a stable, non-localized operation identifier. The host uses known IDs such asshow,view,edit,archive,retire,deactivate, anddeleteto select the canonical leading icon.labelis the localized text visible to the operator.onSelectremains App-owned and performs the actual navigation or command. The host never infers behavior from the ID or label.destructivedeclares destructive treatment;disabledpreserves an unavailable or pending command.
An App does not need an SDK iconKey or an icon-library dependency for a new
operation. ResourceRowActionsMenu supplies a neutral leading icon when it does
not recognize an ID. A recurring platform operation can later receive a
canonical host mapping without changing the App contract. An explicitly passed
icon remains authoritative.
destructive and dangerGhost describe different layers:
NxDropdownMenuItem.destructive: trueis the semantic fact that a menu item performs a destructive operation.ResourceRowActionsMenuuses it to apply destructive menu treatment.NxButton variant="dangerGhost"is the visual projection of that same fact when the operation is rendered as a secondary button in an Inspector or detail action rail.
Do not store a button variant in a shared domain action or infer destructive
intent from a localized label. Store the operation's meaning once, then map it
to destructive: true for menu items and dangerGhost for buttons. Ordinary
View, Edit, and management actions remain neutral.
useResourceListParams is the one to reach for first: it keeps filters, sort,
search, and page in the URL, so a list view is shareable and survives reload.
Resource projections
Board, Calendar, Resource Scheduler, and Schedule are host-bound React components. Import their
pure projection and owner-action types from the package root, and import the
renderers from /host:
import type {
NxCalendarOccurrence,
NxCalendarSelection,
NxResourceSchedulerOccurrence,
NxResourceSchedulerSelection,
ResourceBoardProjection,
ResourceScheduleProjection,
} from "@nexia/sdk";
import {
NxCalendar,
NxResourceScheduler,
ResourceBoard,
ResourceSchedule,
} from "@nexia/sdk/host";The host binds each facade lazily, so the browser loads the EventCalendar, drag-and-drop, or Gantt chunk only when that view renders.
| Component | Use for | App supplies |
|---|---|---|
ResourceBoard | Kanban lanes and cards | ResourceBoardProjection, localized labels, and an optional typed onMove action |
NxCalendar | Month, week, day, and list occurrences | NxCalendarOccurrence[], timezone, locale, state labels, optional reschedule/resize actions, and optional empty-range selection |
NxResourceScheduler | Resource timeline and resource time-grid occurrences | Resource lanes, NxResourceSchedulerOccurrence[], timezone, locale, state labels, optional reschedule/resize/reassign actions, and optional empty-range selection |
ResourceSchedule | Gantt or schedule-timeline work | ResourceScheduleProjection, selected view, optional typed owner actions, and an optional caller-owned creation callback |
NxCalendar can render filled bands or text-only occurrences through
eventAppearance, keep the grid without a separate notice through
showEmptyState: false, and use onOpenOccurrence for an occurrence that has
no Resource link. Calendar and Scheduler onOpenOccurrence callbacks open the
caller-owned detail flow and do not grant record access.
Creation and selection contracts:
| Symbol | Exact contract | Ownership behavior |
|---|---|---|
NxCalendarSelection | NxCalendarTemporalRange: start, end, all_day, and timezone | Carries the empty calendar range selected by the user; it does not represent a persisted occurrence |
NxResourceSchedulerSelection | NxCalendarSelection plus resource_ids: string[] | Carries the empty range and selected resource lane or lanes; it does not create an App record |
NxCalendarProps.onSelectRange | (selection: NxCalendarSelection) => void | Opens or seeds the caller-owned occurrence creation flow |
NxResourceSchedulerProps.onSelectRange | (selection: NxResourceSchedulerSelection) => void | Opens or seeds the caller-owned creation flow with the selected resource context |
ResourceScheduleProps.onCreateResource | (parent?: ResourceProjectionReference) => void | Opens the caller-owned resource creation flow, optionally under the selected parent |
These components do not fetch or mutate App records by themselves. The App loads an actor-authorized projection from its own API and implements the typed callback against an App-owned business action. Empty-range selection and native schedule add affordances only invoke the callbacks above; the shared renderers never create or persist domain records. Core supplies rendering and reversible optimistic feedback. See Resource projections for the complete ownership boundary.
useResourceProjectionChange({ tenantId, resource, onChanged, enabled })
subscribes to the exact app_key / resource_key / resource_id identity. The
owning App emits the PII-free ResourceProjectionChanged event only after its
projection commits; the callback is a refetch hint, not data or authorization.
Import and Export
Import components from /host and the following props types from the package root.
| Component / props | Required input and behavior |
|---|---|
NxResourceTransferActions / NxResourceTransferActionsProps | transferMeta, onImported; navigates to available transfer screens. Current Core handles navigation and does not invoke onImported from this component |
NxResourceImportDialog / NxResourceImportDialogProps | open, onClose, previewUrl, importUrl, templateUrl, onImported; generic Resource upload, preview, execution and polling |
NxDatasetImportWorkspace / NxDatasetImportWorkspaceProps | resourceKey; optional onCompleted, onQueued and reference-create actions. Reads organization context from the host; it accepts no organization ID props |
NxDatasetExportWorkspace / NxDatasetExportWorkspaceProps | resourceKey; optional legalEntityPublicId, operatingUnitPublicId, asOf, triggerVariant, overflowItems |
Core supplies common UI and file handling; the App defines schema, permissions, scope filtering, export rows, validation, business writes and retry behavior. See Resource Transfer to connect export sources and import recipes/pipelines.
Data migration
Register host-composed migration entries through dataMigrationRegistry:
import { dataMigrationRegistry } from "@nexia/sdk/host";
dataMigrationRegistry.registerProvider(provider);
dataMigrationRegistry.registerTarget(target);
dataMigrationRegistry.registerStage({
...stage,
loader: () => import("./migration/OrdersStage"),
});An App can register providers, product targets, and lazy App-owned stages but
cannot inspect or compose the host registry. A stage declares its provider and
target, optional requiredPermissions with permissionMode, optional
prerequisiteStageIds, and reports the public id of a server-owned migration
run through onCompleted({ runPublicId }). The callback only asks the host to
refetch GET /api/data-migrations/runs; browser state is never completion
evidence. For a stage backed by NxDatasetImportWorkspace, the App backend
declares DataMigrationStageIdentity on its ResourceImportPipelineDefinition.
The host attaches that server-owned identity to the queued execution and records
the actual terminal import result; the browser cannot choose or spoof it.
Migration stages use useDataMigrationBackgroundOperation() for cancellable
polling of the standard accepted operation (ticket, status, optional
status_url) and dataMigrationUploadAccept(formats) to derive the file-picker
contract from server metadata. createDataMigrationSessionCheckpointStore() is
a bounded, memory-only checkpoint for a stage that temporarily leaves to create
a referenced Resource; it is not durable execution evidence. Resolve that create
route by stable Resource Key through resourceCreateDestinationRegistry instead
of importing or hard-coding another App's route. Identical registry declarations
may be repeated during boot; a conflicting duplicate fails immediately.
Data access
| Symbol | Purpose |
|---|---|
api | Configured HTTP client; carries tenant and Legal Entity context |
can | Permission check in frontend code |
canForPopulation, canForAnyPopulation | Permission checks against one or several SubjectPopulation values |
useContextQuery | Current tenant, user, Legal Entity, and workspace context |
usePermissionsQuery | The actor's permissions for tenant, optional Legal Entity, and optional Operating Unit scope |
useLegalEntitiesQuery, useOperatingUnitsQuery | Organization lookups |
useApprovalRoutePoliciesQuery | Core-owned Approval route-policy metadata for an App reference picker |
useOperatingUnitScope, useAssignableOperatingUnits, useAffiliatedOperatingUnits | Operating Unit scoping |
useParties | Party lookups |
useAttachments, useUploadAttachment, useDeleteAttachment | Attachment lifecycle |
useUploadFile | Transport-neutral host-issued upload intent; returns a cancellable and retryable UploadTask |
useUploadMedia | Standalone Media upload; returns a MediaItem mutation result |
sendUserInvitation | Host-owned login invitation; accepts UserInvitationPayload and returns UserInvitationResponse |
Context types are published for typing your own code: TenantContext,
UserContext, LegalEntityContext, WorkspaceContext, PermissionsResponse.
UserInvitationPayload accepts an email or Person Party public id plus optional
Legal Entity and Operating Unit role assignments. Core remains responsible for
validating and authorizing the invitation request.
usePermissionsQuery(tenantId, legalEntityPublicId, operatingUnitPublicId?, enabled?) falls back to the current context when the Legal Entity argument is
nullish, includes the optional Operating Unit in both the cache key and request,
and runs only when enabled is true and a tenant id exists. It is still a UI
affordance snapshot; the backend authorizes every protected request again.
useApprovalRoutePoliciesQuery(tenantId, legalEntityPublicId, enabled?) reads
the active-context policy catalog only when it is enabled and both identifiers
are present. ApprovalRoutePoliciesQueryResult returns items with key,
version, name, status, scope, and legal_entity_public_id. An App may
show that metadata in a reference picker and store the stable policy key, but
the backend still validates the selected policy when the App command runs.
useUploadFile(): UploadFileClient starts a host-issued upload intent without
exposing storage or transport policy to the App. Call uploadFile() with
UploadFileInput: a required purpose and browser File, plus optional
checksum and onProgress({ loaded, total, percent }). It returns an
UploadTask with a result promise, cancel(), and retry().
The promise resolves to UploadIntentResponse, whose upload is the public
UploadIntent projection: id, purpose, state, optional transport
(direct or proxy), nullable expires_at, and optional opaque media.
Core chooses direct object-storage transfer or proxy transfer; App code never
selects the transport and must not depend on host-only upload URLs or storage
fields. The related public types are UploadFileInput, UploadProgress,
UploadIntent, UploadIntentResponse, UploadTask, and UploadFileClient.
candecides what to render, never what is allowed. Every request it guards is authorized again in the backend. A frontend permission check is a UX affordance.
Inspector and work tabs
| Symbol | Purpose |
|---|---|
ResourceInspectorPanel, ResourceInspectorStackHost, ResourceInspectorStackProvider | Inspector shell |
resourceInspectorAdapterRegistry | Register a Resource's inspector adapter |
autoRegisterPackageResourceInspectors | Declare the App's filesystem-owned inspector registration set in its entry |
ResourceInspectorAdapter, ResourceInspectorAdapterProps | Adapter types |
InspectorAction | The single permission-aware Inspector action contract for edit, delete, lifecycle, and App-owned commands |
ResourceActionDescriptor, InspectorResourceActions | One open-ended action set shared by a row menu and its Inspector projection |
ResourceLink | Open a referenced Resource in the active Inspector stack, with route fallback and a work-tab context menu |
useOpenResourceWorkTab | Open a record in a work tab |
useMarkTabDirty, useWorkTabLabel, useRegisterTabActions | Work tab state and actions |
useActiveWorkTabId | Stable ID of the Work Tab that owns the surrounding App surface |
useIsActiveWorkTabVisible | true only while the surrounding Work Tab is the visible pane; gate steady polling in custom renderers |
useRegisterListLocateAction | Bind the backend locate action to a list |
Use useActiveWorkTabId() to key session-only drafts that must survive a route-tree
remount inside one Work Tab. A surface outside a Work Tab receives null. Do not use
the ID as a tenant, actor, authorization, or durable browser-storage key.
ResourceInspectorStackHost owns View and Close. An adapter adds Edit, Delete,
or activate/deactivate only when the serialized backend action decision permits
that operation, then supplies the same route or command used by the row menu via
InspectorAction. Delete remains destructive and lifecycle labels describe the
current transition; the host supplies canonical icons and header placement.
The stable action id follows the same convention as ResourceRowActionsMenu.
Known IDs receive canonical icons; an App may use any new resource-specific ID
without changing the SDK or host, and the host renders the neutral fallback
unless the App supplies an explicit icon.
Define one resource-local factory that returns ResourceActionDescriptor[],
then pass its result to the row menu and InspectorResourceActions. The factory
owns membership, order, labels, permissions, destructive state, and pending
state; each caller supplies the callbacks appropriate to its context. The
Inspector projection omits view and show because the host already owns the
Inspector View action. This is data synchronization, not a closed action enum:
a one-off ID remains valid without an SDK change. Use singular InspectorAction
only when an action genuinely has no matching row presentation.
Wrap the Primary Section and Inspector host in one
ResourceInspectorStackProvider. Keep ResourceInspectorStackHost mounted
even when the provider's root is null: a ResourceLink in the Primary
Section can then create the first stack entry. In a clickable table row, the
row background and its own identity select the row Resource, while a different
referenced value uses ResourceLink without an onActivate override so it
pushes that referenced Resource. More menus, links, buttons, and form controls
do not also activate the row. When resource.route is an internal route,
right-clicking the ResourceLink also offers Open in new work tab.
An Inspector adapter exposed as a ResourceLink target must support an id-only
ResourceRef. Treat embedded resource.data as an immediate optimization,
then fetch the Resource detail by resource.id when that data is absent. This
keeps cross-Resource drill-in correct instead of rendering an empty Inspector.
Dashboard renderer hooks
Dashboard renderers must follow the board's shared lifecycle instead of creating their own viewport or transport policy:
| Symbol | Purpose |
|---|---|
useAgentToolDataFreshness({ enabled, refreshMs, preview? }) | Returns TanStack Query refetchInterval, refetchOnWindowFocus, and staleTime; the host clamps polling to at least ten minutes, honors slower hints, and pauses parked tabs, previews, and disabled queries |
useDashboardWidgetAutoHeight(widgetId) | Returns a ref for the widget scroll container. Inside a board it reports natural content height so a newly added widget can settle; outside a board it is inert |
useAgentToolDataInvalidation(enabled?) | Compatibility no-op. Shell-wide Reverb ingress owns tool-data invalidation, so a widget never opens a transport subscription |
Spread the freshness result into useQuery. A spec's queryRef.refresh is a
requested cadence, not permission to poll faster than the host policy.
Errors, messages, and formatting
| Symbol | Purpose |
|---|---|
parseMutationFormError, firstFieldError, withoutFieldError | Map API validation errors to fields |
MutationFormError, FormFieldErrors | Error types |
useMessage, MessageApi, ToastOptions | Toasts and messages |
useFeedbackAction, FeedbackUndoOptions | Standard operation feedback with an optional one-shot server-backed inverse action |
useDateTimeFormatter | Locale- and timezone-correct formatting; accepts a fallback option and returns locale and timezone |
formatNumber, NumberLike | Locale-aware numeric formatting; invalid or absent values render as — |
useDebouncedValue | Debounced input |
NxStatCard, NxLineChart, NxBarChart, NxDonutChart | Metrics and common comparisons |
NxHeatmap, NxGauge, NxSankey | Matrix intensity, target progress, and flow analysis; Apps pass semantic data, not ECharts options |
Use useDateTimeFormatter rather than formatting dates yourself — it applies the
tenant's business timezone.
Set href on NxStatCard or an NxOverviewBand card when the summary opens a
canonical route. Omit it for a static summary. The former onOpen card callback
is retired so route cards keep native link and Shell work-tab behavior.
For a reversible mutation, add undo to useFeedbackAction with a resolved
label, the inverse operation, and success/error feedback. The inverse receives
the original successful result and argument tuple and runs at most once. A
custom success Toast action keeps the single action slot. Do not present a
client-only visual rollback as Undo.
Approval, Signature, and agent
| Symbol | Purpose |
|---|---|
approvalComposerBusinessFormRegistry | Register an Approval business form widget |
ApprovalBusinessFormSlotPropsV2 | Props your composer widget receives at slot API version 2 |
ApprovalBusinessFormController, ApprovalBusinessFormSubmitEnvelope | Submission contract |
ApprovalCase, ApprovalDocument, ApprovalStep, ApprovalState | Approval read models |
ApprovalResourceSummary, ApprovalResourceSummaryField | Authorized Resource summary projection attached to an Approval case |
SignatureRequestPreparationEditor, SignatureRequestPreparationExactPreview | Core-owned request-local editing and protected exact-PDF review surfaces |
SignatureRequestPreparationPlacementEditor, SignatureSingleRequestDialog | Placement-only editing and the app-neutral single-request flow |
registerAgentComponent | Register an agent-renderable component |
registerLazyAgentComponent | Register the same renderer behind a dynamic import() loader |
useAgentToolDataInvalidation | Retain compatibility with existing renderers; shell-wide Reverb ingress performs invalidation |
ApprovalBusinessFormSlotPropsV2 is the prop contract named by
slotApiVersion: 2 on a SlotWidgetDescriptor. Consume only these props.
An ApprovalResourceSummaryField may carry the semantic role subtitle,
meta, or status. Treat it as a compact-presentation hint; field order and
host layout remain host-owned.
Testing
| Symbol | Purpose |
|---|---|
configureNexiaAppFrontendTestHost | Bind a test host |
appTestServer | Request mocking |
appTestI18n | Locale catalog for tests |
NexiaAppFrontendTestHostBindings | Binding type |
configureNexiaAppFrontendHost and NexiaAppFrontendHostBindings are the
runtime equivalents, supplied by the host.
App Packages consume these bindings; the host configures them. Import test helpers from @nexia/sdk/testing and use the configured harness in Test an App.
Errors
| Symptom | Cause | Resolution |
|---|---|---|
Module not found: @/..., @core/..., @shell/... | An App imported a platform alias | Import from @nexia/sdk; the coupling ratchet rejects new platform references |
| Surface route renders blank | Component name published by pageElements() has no registration | Add the surface under the derivable path, or add an overrides entry |
Invalid hook call or context errors | A duplicate React or React Query in the App bundle | Move the library to peerDependencies |
appComponentRegistry cannot be imported from the SDK | It is a host symbol behind @shell/, not an SDK export | Use autoRegisterPackageSurfaces |
| A two-column surface stays squeezed inside a narrow work tab | Its md: or lg: classes measure the browser viewport | Wrap the custom layout in NxResponsiveRegion, or use NxAdaptiveSplit for a primary-plus-supporting layout |
| An App component throws while rendering | The route boundary isolated the App failure | Read the console error, fix the component, or use the rendered retry action |
Use in your App
Register the entry through autoRegisterPackageSurfaces, then open its route in the host. Use Build an App screen for the screen workflow and Test an App for the frontend test harness.
Related
- SDK contracts — the PHP side of the same boundary
- Build an App screen — writing a surface with these primitives
- Add a Slot Widget — declaring the descriptor a composer widget needs
- Fix boundary violations — fixing a rejected import