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): () => voidThe 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): () => voidThe 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 }viadd.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): () => voidDrag 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): () => voidYou 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): () => voidYour 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;
}): () => voidMakes 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(): booleanTrue 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): () => voidData-free ping when any instance's state changes (hidden, enabled, added, removed): re-list.