ReferenceAPI

Popouts, peers, and widgets

Reference for dd.popout including flyouts, dd.peers drag and drop between widgets, and the dd.widgets control surface.

dd.popout

Needs a manifest popout declaration (see the manifest reference): a geometry capability, not a permission. A popout is a second document from your widget folder (the manifest's popout.entry), rendered by the host in an overlay next to your widget. Your own document never moves or resizes.

dd.popout.open()

dd.popout.open(opts: {
  size: { w: number; h: number };          // CSS px, 8..4096 per dimension
  anchor?: Element | { x: number; y: number; w: number; h: number };
  prefer?: "down" | "up" | "left" | "right" | "auto";
  data?: unknown;                          // JSON, up to 8 KB
}): Promise<{
  side: "down" | "up" | "left" | "right";  // the applied side
  size: { w: number; h: number };          // the granted pane size
}>

Opens the pane at exactly size (capped by the manifest's popout.max, which is in base grid cells), placed next to anchor: an element (the SDK measures it for you) or a pixel rect in your viewport; omitted means your whole rect. The host tries prefer, flips to the opposite side when that fits better, and nudges the pane fully on-canvas. Size is granted exactly, position is best-effort, so the pane may end up overlapping your widget (it paints on top).

data reaches the pane document as ctx.popout.data. Re-calling reshapes: the pane document remounts with the new data, and its entrance animation replays.

Gates: the real cursor must be inside the widget (popouts open only as part of a user interaction), never in edit mode, main document only. Popouts are transient and never persisted. 20 calls per rolling 10 seconds.

dd.popout.close()

dd.popout.close(): Promise<void>

Idempotent, callable from either document. The host also force-closes the pane on edit mode entry, widget reload, grid rescale, hide, disable, remove, and a crash of either document.

dd.popout.onChange()

dd.popout.onChange(cb: (state: { active: boolean; side?: string | null }) => void): () => void

The popout lifecycle, including host force-closes. active: false means the pane is gone: un-highlight your trigger. Both documents receive it.

dd.popout.send() and onMessage()

dd.popout.send(msg: unknown): void
dd.popout.onMessage(cb: (msg: unknown) => void): () => void

The channel between your instance's two documents (main and pane), relayed by the shell, since sandboxed siblings cannot talk directly. JSON, up to 8 KB per message; sends are dropped silently when no pane is open.

dd.popout.flyout()

dd.popout.flyout(opts: FlyoutOpts): {
  open: (key?: string) => Promise<void>;
  close: () => void;
  toggle: (key?: string) => void;
  active: () => string | null;
}

interface FlyoutOpts {
  panes: Record<string, FlyoutPane>;
  /** The pane em base in px. Default: the anchor's computed font-size,
   *  clamped to 13..22. */
  font?: () => number;
  /** Close when the cursor leaves the widget+pane footprint. Default true. */
  retractOnLeave?: boolean;
  prefer?: "down" | "up" | "left" | "right" | "auto";
  /** Fires on every active-pane change: open, switch, close, host close. */
  onState?: (active: string | null) => void;
}

interface FlyoutPane {
  /** Pane size in CSS px for a given pane font. */
  size: (font: number) => { w: number; h: number };
  /** The element (or px rect) the pane anchors to, resolved at open time. */
  anchor: () => Element | { x: number; y: number; w: number; h: number };
  /** Extra pane data, merged over the built-in { pane, font }. */
  data?: (font: number) => Record<string, unknown>;
}

The transient-pane lifecycle in one helper, for the common "icon bar with flyout panels" idiom. Give it your pane registry and it owns the whole dance:

  • mirrors host force-closes back into onState, so your trigger un-highlights;
  • retracts when the cursor leaves the widget-plus-pane footprint (unless retractOnLeave: false);
  • holds the retract while the pane reports a slider drag in flight (the pane relays { dragging } via dd.popout.send);
  • rolls back cleanly, with a warning log, when an open is refused.
const flyout = dd.popout.flyout({
  panes: {
    volume: {
      size: (font) => ({ w: font * 16, h: font * 3.4 }),
      anchor: () => volumeBtn,
    },
  },
  onState: (active) => volumeBtn.toggleAttribute("active", active === "volume"),
});
volumeBtn.addEventListener("click", () => flyout.toggle("volume"));

dd.peers

Needs the peers permission. Host-mediated drag and drop between instances of the same widget type: the payload is captured host-side and delivered only to the host-resolved drop target.

dd.peers.dragStart()

dd.peers.dragStart(opts: {
  data: unknown;                                   // up to 8 KB
  ghost?: { label?: string; icon?: string };       // label 64 chars, icon a data:image/ URI up to 64 KB
}): Promise<void>

Starts a drag. Only callable while the primary button is physically held inside this widget (start it from pointerdown plus movement); the gesture is the consent. ghost feeds the cursor chip the shell draws. One drag exists globally, with a 60 second timeout. The host resolves the drop on the real button release: the payload goes only to the eligible instance under the cursor (same widget type, visible, not the source). 20 calls per rolling 10 seconds.

dd.peers.cancel()

dd.peers.cancel(): Promise<void>

Abandon your own active drag (Escape, for example). No-op otherwise.

dd.peers.onDrag()

dd.peers.onDrag(cb: (state: { active: boolean; self: boolean }) => void): () => void

Drag lifecycle for your widget type: highlight drop targets while active && !self.

dd.peers.onDrop()

dd.peers.onDrop(cb: (drop: { data: unknown; x: number; y: number }) => void): () => void

You are the drop target. x and y are the drop point as fractions of your viewport. Targeted: no other frame sees the payload.

dd.peers.onEnd()

dd.peers.onEnd(cb: (end: { accepted: boolean; outside: boolean }) => void): () => void

Your drag finished. accepted means a peer received it (remove the item for move semantics). outside means a real drop that landed on neither a peer nor yourself: treat as "dropped into the void" (usually remove). Both false means snap back (released over yourself, or the host cancelled the drag).

dd.peers.draggable()

dd.peers.draggable(el: HTMLElement, opts: {
  data: () => unknown;
  ghost?: () => { label?: string; icon?: string };
  onClick?: (e: MouseEvent) => void;
}): () => void

Makes an element press-draggable to peers while keeping it clickable. A press that moves past a 6 px threshold starts the host drag (data() and ghost() are called at that moment, and the element carries a dd-dragging class until the drag ends); a plain click calls onClick; the phantom click a released drag would land is swallowed for you. Returns a dispose function. The semantic outcome still arrives via onDrop and onEnd.

dd.peers.recentDrag()

dd.peers.recentDrag(): boolean

True briefly (350 ms) after any drag activity. Guard click handlers on elements that are not draggable themselves; a drag released over them would otherwise count as a click.

dd.widgets

Needs the control permission.

dd.widgets.list()

dd.widgets.list(): Promise<{ targets: WidgetTarget[] }>

interface WidgetTarget {
  instanceId: string;
  widgetId: string;
  /** The widget type's display name. */
  name: string;
  /** The target's own title setting, or "". */
  title: string;
  enabled: boolean;
  hidden: boolean;
}

Returns only the instances the user ticked in this widget's instances-typed settings. The shell settings UI is the sole writer of those, so a widget can never expand its own target set; manifest defaults for instances fields are ignored. Self is always excluded, and stale approvals are dropped silently. 60 calls per rolling 10 seconds.

dd.widgets.setHidden()

dd.widgets.setHidden(instanceId: string, hidden: boolean): Promise<void>

Show or hide an approved target. Unapproved ids reject PERMISSION_DENIED whether or not they exist. Hidden widgets unmount entirely: they stop running (no timers, no listeners) until shown again, from the controlling widget or from edit mode. 30 calls per rolling 10 seconds.

dd.widgets.onChange()

dd.widgets.onChange(cb: () => void): () => void

Data-free ping when any instance's state changes (hidden, enabled, added, removed): re-list.