Guides

Popouts and flyouts

Open a second sandboxed pane from a widget, exchange messages with it, and use the flyout helper for panels that retract on their own.

A widget's main document never resizes: its rect is the user's layout decision. When a widget needs more room temporarily (a network list, a volume slider, a detail view), it opens a popout: a second sandboxed document from the same widget folder, running with the same grants, overlaid next to the widget at a rect the host grants. Close it, and the desktop is exactly as before.

Declaring it

"popout": { "max": { "w": 4, "h": 4 }, "entry": "pane.html" }

entry names the pane's HTML document (same path rules as the main entry), and max caps the pane's size in grid cells. Without this manifest block, dd.popout.open is not available.

The pane document

The pane is a full document with its own markup and script. System Controls' pane body, in full:

<div id="root" class="pane-root"></div>
<script src="/__sdk/widget-sdk.js"></script>
<script type="module" src="pane.js"></script>

No data-dd-interactive anywhere: a pane captures its whole rect for as long as it is open, so the marking machinery from interactivity does not apply inside it.

The pane's dd.ready context tells it why it exists: ctx.popout.data is whatever open() sent, and ctx.popout.side is the side of the widget the host actually placed it on. System Controls uses both, picking what to build from the data and matching its entrance animation to the side:

async function main() {
  const ctx = await dd.ready;
  const { pane, font } = (ctx.popout && ctx.popout.data) || {};

  // pane content scales with the em base the opener sent along
  document.documentElement.style.fontSize = `${Number(font) > 0 ? Number(font) : 14}px`;

  const root = document.getElementById("root");
  root.className = `pane-root dd-anim-slide-${ctx.popout.side}`;
  root.append(BUILDERS[pane]());
}

One pane document serving several roles through data, as here, beats declaring nothing and guessing.

Opening

From the main document only:

await dd.popout.open({
  size: { w: 320, h: 240 },      // CSS pixels; granted exactly, up to the manifest max
  anchor: wifiBtn,               // an Element: the SDK measures it for you
  prefer: "down",                // "down" | "up" | "left" | "right" | "auto"
  data: { pane: "wifi" },        // lands in the pane's ctx.popout.data
});

The host grants the size exactly (within the manifest cap) and places the pane on the preferred side when it fits, flipping when it does not; the resolved side comes back to both documents. Opening is consent-gated: it works only while the user's cursor is inside the widget, and never in edit mode. In practice that means popouts open from clicks and hovers, not from timers; a widget cannot pop UI at an unsuspecting desktop.

dd.popout.close() closes from either document and is fine to call when nothing is open.

Messaging

The two documents are isolated frames; the SDK relays messages between them. dd.popout.send(payload) in one document, dd.popout.onMessage(cb) in the other, both directions, with a size cap per message (see the reference).

One relay contract is load-bearing enough to call out: a dd-volume-pill in a pane emits dragchange while its slider is being dragged, and the pane should forward it so the main document's flyout helper knows not to retract mid-drag:

pill.addEventListener("dragchange", (e) => dd.popout.send(e.detail));

Lifecycle: design for force-closes

dd.popout.onChange(({ active, side }) => ...) fires in both documents on open, reshape, and close. Closes are not always yours: the host force-closes a popout when the user enters edit mode, on widget reload, on a grid change, when the instance is hidden, disabled, or removed, and when either document crashes.

The design consequence: a pane holds no state the main document cannot rebuild, and reopening after any close must just work. Treat the pane as a projection, keep the source of truth in the main document (or in dd.storage), and send it across on open via data.

Flyouts: the transient-pane helper

Most popouts are the same pattern: click a trigger, a small pane slides out anchored to it, and it should retract when the cursor leaves or the trigger is clicked again. dd.popout.flyout packages that pattern, owning the open-and-close choreography: mirroring host closes, hover-out retraction, holding open during a slider drag (the dragchange relay above), and rolling back cleanly when an open fails.

The Status Bar's volume flyout is the minimal case, one pane and two lines of wiring:

const flyout = dd.popout.flyout({
  panes: {
    sound: { size: (font) => ({ w: 20 * font, h: 3.4 * font }), anchor: () => volumeEl },
  },
});

volumeEl.addEventListener("click", () => flyout.toggle());

System Controls scales the same shape to three panes behind three buttons, with the pane font following the bar's autoscaled size, and its buttons highlighting from the state callback:

const flyout = dd.popout.flyout({
  panes: {
    wifi:    { size: (font) => ({ w: 20 * font, h: 18 * font }),  anchor: () => wifiBtn },
    sound:   { size: (font) => ({ w: 20 * font, h: 3.4 * font }), anchor: () => soundBtn },
    battery: { size: (font) => ({ w: 16 * font, h: 3.4 * font }), anchor: () => batteryBtn },
  },
  font: () => paneFont(unit()),
  onState: (active) => { activePane.value = active ?? "none"; },
});

Sizes are functions of the pane's em base, so panes scale with the widget instead of arriving at one fixed size. Clicking an open pane's trigger closes it; clicking another switches panes. The full option surface is in the reference.

Raw open or flyout?

Use flyout whenever the pane is an anchored transient that should retract on its own; that is nearly every popout, and the helper's edge cases (drag guards, host closes, failed opens) are exactly the ones widgets get wrong by hand. Drop to raw open/close when the pane is a deliberate, persistent companion panel the user dismisses explicitly, or when you need pixel-rect anchoring the element form does not express.