Riftix Console Bridge

Embedded Apps

What an app must delegate to the console — theme, breadcrumbs, toasts, confirmations and the URL.

An embedded app is not a website in a box. It is a panel inside a larger application that already has chrome, and the chrome is not yours to redraw.

Every rule here is about the same failure: an app rendering something the host is already rendering, so the user sees it twice, or rendering something the host owns, so the two disagree.

The examples use the vanilla window.Riftix API, which is the framework-neutral surface. React hooks exist (React Hooks) and are a convenience over exactly these calls.

Theme is host state

The host decides the theme. It pushes the current value at handshake and pushes a new one whenever the user toggles it in the navbar.

Rule. Mirror the host's theme onto your document root. Never read prefers-color-scheme, never persist a theme, and never ship a theme toggle.

A toggle inside an app produces two controls that disagree: the navbar says dark, the panel below it is light. The host's toggle is the only one, and it is already on screen.

const applyTheme = (context) => {
  document.documentElement.classList.toggle("dark", context.theme === "dark");
  document.documentElement.lang = context.locale;
};

await Riftix.ready;
applyTheme(Riftix.context);
Riftix.events.onContextChanged(applyTheme);

That is the whole integration. The class toggle is what the dark variant in the token file keys off.

Corollary: no per-component dark styling

Rule. Application code contains zero dark-mode variants. Semantic tokens already resolve correctly in both themes — that is what they are for.

A dark: variant in a page or feature component means a color was chosen outside the token set. The legitimate exceptions are rare and belong in your primitive layer: tuning the opacity of a destructive tint, or an invalid-state ring, where the same alpha does not read equally on both backgrounds.

First paint

The host cannot push context before your document exists, so your first paint happens without it. Two consequences:

  • Do not hardcode <html lang> to a language your interface is not written in. Pick a neutral default and set the real value from context.locale.
  • Do not paint a full-screen loader while waiting for the handshake. The host already covers the iframe with an overlay and a delayed spinner until ready resolves — the delay is tuned so a fast handshake never flashes a spinner at all. Your own boot loader stacks a second wait on top of it.

The host navbar renders the breadcrumb trail. When an app pushes a trail, it replaces the host-derived one wholesale.

Rule. Push the full trail to the host. Do not render a second trail inside the app when a host is connected.

Declaratively, with the web components — each layout level contributes its own crumb, no shared parent needed:

<riftix-breadcrumb-item href="/" label="Billing"></riftix-breadcrumb-item>
<riftix-breadcrumb-item href="/invoices" label="Invoices"></riftix-breadcrumb-item>
<riftix-breadcrumb-item label="Invoice #1234"></riftix-breadcrumb-item>

Or imperatively:

Riftix.navigation.setBreadcrumbs({
  crumbs: [
    { href: "/", label: "Billing" },
    { href: "/invoices", label: "Invoices" },
    { label: "Invoice #1234" },      // no href on the terminal crumb
  ],
});

Conventions:

  • The terminal crumb has no href — it is the current page, rendered as text.
  • Push null, not a placeholder, while a label is loading. A crumb that reads "Loading…" and then changes is worse than a trail that is briefly one item shorter.
  • Clear the trail on unmount (crumbs: []) so a stale trail does not outlive the page that set it.
  • Do not push an empty trail on first render. Wait until you have crumbs; an empty push only makes the host navbar flicker.

Your app's own top bar still exists — it holds the sidebar trigger and page-local controls. It just does not hold a duplicate trail. If your app also runs standalone in development, render local crumbs only when no host is connected.

Toasts and confirmations are host chrome

Rule. Route toasts and destructive confirmations through the bridge.

A toast rendered inside the iframe is clipped to the iframe and stacks independently of the host's own toasts, so two notifications can overlap. A confirm dialog rendered inside the iframe is centered on the panel rather than the viewport, and its backdrop dims only the panel — the rest of the console stays bright and clickable behind a supposedly modal dialog.

await Riftix.toast.show({ message: `Invoice #${id} sent`, variant: "success" });

const confirmed = await Riftix.modal.confirm({
  title: "Remove backup.acme.com?",
  body: "Guests using this hostname will stop being able to join.",
  confirmLabel: "Remove hostname",
  destructive: true,
});
if (!confirmed) return;

This is also where success, warning and info exist as colors at all — the host maps the four variants onto its own toast styling. See Status colors.

Toast copy is specific and names the object: Project "Acme Store" created, not Saved.

Fall back locally, but only when there is no host

If your app also runs outside the console in development, catch the not-connected case and render a local toast. Do not fall back on any error — a timeout from a connected host means the message was probably delivered, and a duplicate is worse than a missing one.

Every page is a real URL

Rule. Every state a user can reach and would want to return to has its own URL. No modal-only routes, no state that exists solely in memory.

This is what makes a deep link into an embedded page work: the host mirrors the iframe's path into the console address bar, so the whole console URL is bookmarkable and shareable, and the browser's back button moves through the app's own history.

The snippet's location-sync layer reports URL changes to the host and applies host-initiated navigation to the iframe automatically — you do not need to call locationChanged yourself. Subscribe only if you need custom logic on top:

Riftix.events.onNavigate(({ path, replace }) => {
  // The snippet has already applied this to history.
  // Hook here only for framework routers that need an explicit push.
});

Related rules that follow from it:

  • Filter and tab state belongs in the URL, not component state, whenever someone might share the filtered view.
  • Link to the host, not to your own origin, when leaving your app. Use Riftix.navigation.navigate({ to }) — a raw link would break the user out of the console frame.
  • Set appPath handling aside: the snippet rewrites same-origin anchors so middle-click and Ctrl+click open the full console URL rather than your bare origin.

Host chrome is best effort

Rule. Every bridge call is wrapped so a failure cannot break a page.

An app may be running against an older host, a host that has not registered a handler, or no host at all in development. None of those is an error condition for your page.

const attempt = async (work) => {
  try { await work(); } catch { /* standalone, or a host without this RPC */ }
};

attempt(() => Riftix.navigation.setBreadcrumbs({ crumbs }));

The exception is modal.confirm, whose return value gates a destructive action. If that call fails, treat it as not confirmed.

There is no standalone surface

Rule. An embedded app has no meaningful standalone page. Detect direct visits and say so.

Detect it server-side from the sec-fetch-dest request header (iframe versus anything else), and client-side from window.parent === window. Render a single centered panel:

<div class="bg-background text-foreground flex min-h-screen items-center justify-center p-6">
  <div class="bg-card text-card-foreground ring-foreground/10 w-full max-w-md p-8 ring-1">
    <h1 class="font-heading text-xl font-semibold tracking-tight">Open this in the Riftix Console</h1>
    <p class="text-muted-foreground mt-1 text-xs/relaxed">
      This app runs inside the console. Sign in there to continue.
    </p>
  </div>
</div>

Use the same panel for the other two boot states — still handshaking, and authorized but without access — so the three read as one family rather than three different error pages.

Reset on organization change

Rule. When the host switches organization, discard everything.

The console's org switcher changes which tenant the user is operating on. Any cached list, form draft or selected row from the previous organization is not just stale — it is another tenant's data on screen.

Riftix.events.onAuthorizationChanged((authorization) => {
  if (authorization?.organizationId !== currentOrgId) {
    // Tear down and remount the authorized tree; drop every cache.
  }
});

In component frameworks the cheapest correct implementation is to key the authorized subtree by organizationId, so a change remounts it.

Checklist

Before shipping an embedded app:

  • Theme mirrors the host; there is no toggle in the app.
  • No dark-mode variants outside the primitive layer.
  • Breadcrumbs are pushed to the host and not rendered twice.
  • Trail is cleared on unmount, and never pushed empty on first render.
  • Toasts and destructive confirms go through the bridge.
  • Every page has a URL; the console address bar updates as you navigate.
  • Browser back and forward move through the app's history.
  • Every bridge call is failure-tolerant.
  • Direct visits render the "open in console" panel.
  • Switching organization in the host resets the app.
  • The app scrolls internally; the document does not scroll.
  • No full-screen loader on boot.

On this page