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): () => 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.
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): () => voidData-free ping when any instance's state changes (hidden, enabled, added, removed): re-list.