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 };          // your own 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, in your px
}>

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. Size and anchor are in your widget's own pixels, the ones you measure inside your document. Below a Widget size of 100% the host lays your document out larger than its tile and scales it down, and it converts a popout request at that same boundary, so the pane paints at your widget's scale and the granted size comes back in your pixels. 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.

A host peek is different. The user can conceal every widget at once (a double click on empty desktop space, or Hide widgets in the tray menu); widgets stay mounted and running, receive no input, keep their hidden flag as it was, and come back the same way. widgets.list does not reflect a peek and no widgets.changed fires for it.

dd.widgets.onChange()

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

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