Skip to content

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:

ts
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

ts
// 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:

ts
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 side

Closing & results

Three paths all converge on the opener's closed Promise resolving once:

Who closesHowclosed resolves with
The surfacesdk.surface.closeSelf(result)result
The openerhandle.close(result)result
The userbackdrop / Escape / × buttonundefined
ts
// 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:

  1. The edit modal calls sdk.request("saveCard", …).
  2. The backend writes and events.publish("<slug>.card.updated", …).
  3. 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:

ts
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.