Surfaces — modal & rail
A plugin normally renders in one place: its main panel iframe. Surfaces let the shell host your own content in two more native locations — a centered modal and the right rail — without you fighting the panel's bounds (a modal you build inside the iframe is clipped to the iframe rectangle, has the wrong z-order, and can't use space off to the side).
A surface is one primitive with a placement. You render the content; the shell owns the container chrome: backdrop, focus-trap, Escape, the close button, z-order, responsive behavior (rail → drawer, modal → bottom sheet on a phone), and theme inheritance.
The model
Two iframes of your plugin are involved:
- The opener (your main panel) calls
surface.open(...)and awaits the result. - The surface (the modal/rail iframe) renders the content and can
closeSelf(result).
They're separate JS contexts. The shell is the broker: it mounts the surface iframe, tells it (via the handshake) that it's a surface with a placement and itemId, and on any close path posts the result back to the opener.
main panel iframe shell surface iframe
───────────────── ───── ──────────────
surface.open({...}) ───────────▶ mount surface ─────▶ boots; sdk.surface
│ (modal | rail) .isSurface === true
│ renders itemId view
│ closeSelf(result) ─┐
▼ │
await handle.closed ◀──── platform.surface.closed { result } ◀─────────────┘One bundle, many views
A surface loads the same plugin bundle as your main panel — selected by itemId, exactly like sidebar navigation. Branch on sdk.surface at boot:
const sdk = await createPluginFrontend();
if (sdk.surface.isSurface) {
const item = sdk.surface.itemId;
if (item === "activity") return renderActivity(); // rail view
if (item.startsWith("note:")) return renderNote(item); // modal view
sdk.surface.closeSelf(); // unknown route
return;
}
renderMainPanel();Heavy plugins can code-split each view; small ones just branch.
Opening a surface
// Modal — centered overlay. size: "sm" | "md" | "lg" (default "md").
const edit = sdk.platform.modal.open({ itemId: "edit-card:123", title: "Edit card", size: "lg" });
// Rail — over the right-rail Welcome view. One rail slot; opening another
// replaces it.
sdk.platform.rail.open({ itemId: "activity", title: "Activity" });
// Or the core call with an explicit placement:
sdk.platform.surface.open({ placement: "modal", itemId: "edit-card:123" });open() returns a handle:
const h = sdk.platform.modal.open({ itemId: "note:42", title: "Note" });
const result = await h.closed; // resolves when the surface closes (any path)
// h.close(result?) // ask the shell to close it from the opener sideClosing & results
Three paths all converge on the opener's closed Promise resolving once:
| Who closes | How | closed resolves with |
|---|---|---|
| The surface | sdk.surface.closeSelf(result) | result |
| The opener | handle.close(result) | result |
| The user | backdrop / Escape / × button | undefined |
// In the surface iframe:
saveBtn.onclick = async () => {
await sdk.request("saveCard", { id, ...fields });
sdk.surface.closeSelf({ saved: true });
};Keeping views in sync
There is no shell-level cross-iframe bus. Surfaces stay in sync the same way two panels do — through your backend:
- The edit modal calls
sdk.request("saveCard", …). - The backend writes and
events.publish("<slug>.card.updated", …). - The main panel and an open activity rail both receive it via
sdk.subscribe("<slug>.card.updated", …)and update.
The backend is the single source of truth; every surface subscribes to it. (Use the durable event bus for state changes like this — see Data & events.)
Native confirm & alert
For the trivial cases that don't warrant a whole iframe, ask the shell to render a real widget:
const ok = await sdk.platform.modal.confirm({
title: "Delete note",
message: "Delete this note? This can't be undone.",
confirmLabel: "Delete",
danger: true,
});
if (ok) await sdk.request("deleteNote", { id });
await sdk.platform.modal.alert({ message: "Saved." });Lifecycle & cost
Surfaces are fresh per open: the iframe mounts on open and is destroyed on close. The cost is a fresh handshake (~100–400 ms) each time — fine for on-demand panels. Don't rely on a surface keeping in-memory state across opens; persist anything important through your backend (which is also what makes it appear in the main panel).
See it end-to-end
The Noteboard example wires all of this: an Activity button opens a rail surface, each note's Open opens a modal surface, and delete goes through a native confirm — all from one bundle, kept in sync by the backend.