@ai-matrx/summon
Summon any React component from anywhere — with its handlers, refs and state — and never mount it until it is called.
- Dispatch to open. Open a window, dialog, sheet or any component from anywhere with a hook or an action. A closed overlay puts nothing in the React tree and fetches no code.
- Single or multi instance per overlay.
- Handles beside Redux. Callbacks, event channels, one-shot requests and imperative refs live in a registry next to the store, owned by the open instance and disposed when it closes.
- Keep-alive. On close an overlay unmounts (
none), stays alive hidden (hidden), or unmounts and remembers its saved state (restore).useOverlayStateis the saved-state hook. - Address-bar links.
?panels=notes:42names an open overlay, loads its data and survives reload. - Persistence, off or on: browser-local, your own database through a small port, or
@ai-matrx/data. - Surfaces.
WindowPanelis a desktop in the browser (drag, resize, snap, arrange, tray with thumbnails, minimize, pop-out to a real browser window, three phone presentations).dialog,sheetandlite(a light floating window) share the same close binding.
The package owns its Redux slices. It runs on a private store, or mounts into the store you already have.
Install
pnpm add @ai-matrx/summon react react-dom react-redux @reduxjs/toolkit
| Peer | Needed for |
|---|---|
react ≥ 19, react-dom, react-redux ≥ 9, @reduxjs/toolkit ≥ 2 | everything except the framework-free . and ./handles entries |
next ≥ 16 (optional) | only ./next |
@supabase/supabase-js | only ./persistence/data, through @ai-matrx/data |
@ai-matrx/data and @ai-matrx/design-system are ordinary dependencies (the platform's sibling rule), so they install with summon, and so do theirs: Supabase, @ai-matrx/auth and the Radix packages arrive too (a fresh Vite app installing only summon measured about 310 MB of node_modules). They add install weight, not bundle weight: every entry is its own module, unused entries are tree-shaken away, Supabase is reached only by ./persistence/data, and src/importCost.test.ts + src/firstLoadBudget.test.ts keep the first-load boot set (./boot, ./boot-root, ./boot-kit) free of them under a byte budget. The one cost every page does pay is the stylesheet in Styling.
Entries:
| Entry | What it is | React? |
|---|---|---|
./boot | first load. The gate (createBootGate), the plain openers (summon, dismiss, openOverlay, closeOverlay, …), isOverlayOpen, mintInstanceId, DEFAULT_INSTANCE_ID | no |
./boot-root | first load. <SummonRoot>, its contexts, useSummonStore, useOptionalSummonStore, useOverlayLink, usePopoutContainer | yes |
./boot-kit | first load, optional. Handle entries without the registry, lazyOpener, the ?panels= writer (formatLinkToken), three light reads, expectNavigation, locationRouter | no |
./opener | createOpener(id, { multi }) → { openAction, useOpen, Controller, useIsOpen, useClose, close }: the one opener ./define and ./react are built on, light enough for first load | yes |
./open | the classic creators (openOverlay with .type/.match), every selector, the registry, ports, createSummonGate (the same function as createBootGate). About twice the boot set's bytes: prefer ./boot + ./boot-kit in first load | no |
./root | ./boot-root plus the selector, dispatch and ports hooks, dispatchers, the instance context | yes |
./define | defineOverlay (openers, Controller, open/close hooks, emitter). Import it only from definition modules | yes |
. | the core: slices, middleware, createSummonStore, window geometry | no |
./handles | callbacks, event channels, requests, refs | no Redux, no React |
./request | awaitable dialogs. requestOverlay(overlayId, request, { cancel, open, close }) opens an overlay and resolves with its answer (useOverlayRequest on ./react answers). Closing it any way, or a render that never shows the request, answers cancel | no |
./load-once | loadOnce(load): run a loader once while it succeeds, retry a failed one (the memo lazyOpener uses) | no |
./hook-controller | hookController(useOpen, toOptions?): the open-on-mount Controller for an opener whose open logic is your own, without createOpener | yes |
./instance-value | registerInstanceValue / getInstanceValue: what one open instance's opener captured, disposed when that instance closes | no |
./react | SummonProvider, SummonHost, useOverlayState, useOverlayInstance | yes |
./window | WindowPanel and the desktop | yes |
./window-host | the root-mountable half of the window section: WindowsProvider, WindowTraySync, registerTrayPreview, usePopout, usePopoutContainer, useOverlaySurfaceRenderAck. Never loads WindowPanel | yes |
./dialog, ./sheet, ./lite, ./dock | dialog, sheet/drawer, light window, floating dock | yes |
./url | OverlayLinkManager, useOverlayLink, the ?panels= read side | yes |
./persistence, ./persistence/data | saved windows and component state; the @ai-matrx/data adapter | yes / no |
./next, ./testing, ./styles.css | Next router port, test fakes, structural CSS |
Quick start
The recommended setup keeps summon out of every route's first load: the root mounts only <SummonRoot> (from ./boot-root), the store carries the gate from ./boot, definitions use ./define, and callers open with the plain actions from ./boot (or createOpener from ./opener). The summon core, the host and the window code load on the first open.
1. A store the gate can grow, and the root. The gate queues the first summon action, loads the core once, injects its slices into your reducer and replays the queue.
"use client";
import { combineReducers, configureStore, type Reducer, type ReducersMapObject } from "@reduxjs/toolkit";
import { createBootGate } from "@ai-matrx/summon/boot";
import { SummonRoot } from "@ai-matrx/summon/boot-root";
let reducers: ReducersMapObject = { app: (state: number = 0) => state };
let root: Reducer = combineReducers(reducers);
const gate = createBootGate();
gate.attachReducer((injected) => {
reducers = { ...reducers, ...injected };
root = combineReducers(reducers);
});
export const store = configureStore({
reducer: ((state, action) => root(state, action)) as Reducer,
middleware: (getDefault) => getDefault().concat(gate.middleware),
});
export function Root({ children }: { children: React.ReactNode }) {
return (
<SummonRoot store={store} runtime={() => import("./SummonRuntime")}>
{children}
</SummonRoot>
);
}
2. The runtime: mounted by <SummonRoot> once the core has loaded. It renders whatever is open.
// SummonRuntime.tsx
import { SummonHost } from "@ai-matrx/summon/react";
import { APP_OVERLAYS } from "./overlays";
export default function SummonRuntime() {
return <SummonHost definitions={APP_OVERLAYS} />;
}
3. Declare an overlay once: a light definition file and a render module (the lazy chunk).
// notes.overlay.ts
import { defineOverlay } from "@ai-matrx/summon/define";
export interface NotesWindowData {
noteId?: string;
}
export const notesWindow = defineOverlay<NotesWindowData>()({
id: "notesWindow",
label: "Notes",
render: () => import("./notes.render"),
});
// One line per overlay: opens are type-checked and a mistyped id is a compile error.
declare module "@ai-matrx/summon" {
interface OverlayRegistry {
notesWindow: NotesWindowData;
}
}
// notes.render.tsx — loaded only when the overlay is first opened
import type { OverlayRenderProps } from "@ai-matrx/summon/react";
import { WindowPanel } from "@ai-matrx/summon/window";
export default function render({ data }: OverlayRenderProps) {
const noteId = typeof data?.noteId === "string" ? data.noteId : null;
return (
<WindowPanel id="notes" title="Notes" boundToOverlay width={480} height={420}>
<NoteEditor noteId={noteId} />
</WindowPanel>
);
}
data arrives as untrusted Readonly<Record<string, unknown>> | null (it crossed Redux, a URL or storage). Narrow it in the render module, or give the definition a parse(raw) that returns the typed value (or null to refuse and close with a warning).
// overlays.ts — imported only by the runtime
import { notesWindow } from "./notes.overlay";
export const APP_OVERLAYS = [notesWindow];
4. Open it from anywhere: a plain action from ./boot (no React needed), a light opener from ./opener, or the definition's hooks.
import { summon } from "@ai-matrx/summon/boot";
import { createOpener } from "@ai-matrx/summon/opener";
import { notesWindow } from "./notes.overlay";
import { store } from "./Root";
// Anywhere, even outside React:
store.dispatch(summon("notesWindow", { noteId: "42" }));
// A caller that must stay light (first load) opens through createOpener, never the definition.
const notes = createOpener<{ noteId?: string }>("notesWindow");
function NotesButton() {
const open = notes.useOpen();
return <button onClick={() => open({ data: { noteId: "42" } })}>Notes</button>;
}
function OpenNotes() {
const open = notesWindow.useSummon();
return <button onClick={() => open({ data: { noteId: "42" } })}>Notes</button>;
}
// Declarative: opens on mount, closes on unmount, re-opens when the (serialisable) props change.
const Always = () => <notesWindow.Controller noteId="42" />;
open() returns { overlayId, instanceId, close() }. The definition also gives you useIsOpen(instanceId?), useClose() and, inside the render tree, useEmit(). Keep files that callers import for opening free of definitions (./define): a module that imports a definition pays for it.
5. Ask and await. An awaitable dialog is an overlay whose render answers a request. The caller awaits the answer; closing the dialog any way answers cancel, and so does a dialog that never renders (reported through the errors port, never a hang).
import { useCallback, useEffect, useState } from "react";
import { summonAction } from "@ai-matrx/summon/boot";
import { defineOverlay } from "@ai-matrx/summon/define";
import { hookController } from "@ai-matrx/summon/hook-controller";
import { loadOnce } from "@ai-matrx/summon/load-once";
import { useOverlayRequest, type OverlayRenderProps } from "@ai-matrx/summon/react";
import { requestOverlay } from "@ai-matrx/summon/request";
import { store } from "./Root";
// The dialog's render answers the request its open data names (and claims it, so it never times out).
function ConfirmBody({ data, close }: OverlayRenderProps) {
const { request, settle } = useOverlayRequest<{ question: string }, boolean>(data, close);
if (!request) return null;
return (
<div role="dialog">
<p>{request.question}</p>
<button onClick={() => settle(true)}>Yes</button>
<button onClick={() => settle(false)}>No</button>
</div>
);
}
export const confirmDialog = defineOverlay<{ requestId: string }>()({
id: "confirmDialog",
label: "Confirm",
render: () => Promise.resolve({ default: ConfirmBody }),
});
declare module "@ai-matrx/summon" {
interface OverlayRegistry {
confirmDialog: { requestId: string };
}
}
// The caller awaits a boolean; any close answers `cancel` (false).
export function confirm(question: string): Promise<boolean> {
return requestOverlay<{ question: string }, boolean>("confirmDialog", { question }, {
cancel: false,
open: (target) => void store.dispatch(summonAction("overlays/openOverlay", target)),
close: (target) => void store.dispatch(summonAction("overlays/closeOverlay", target)),
});
}
// One loader shared by many callers: it runs once while it succeeds; a failed load is retried next call.
const loadEditor = loadOnce(() => import("./notes.overlay"));
// Your own open logic still gets the one open-on-mount Controller (no createOpener in this route).
// Return a stable function (useCallback): a new identity re-opens.
function useOpenConfirm() {
return useCallback((props: { question: string }) => {
void confirm(props.question);
return { close: () => void store.dispatch(summonAction("overlays/closeOverlay", { overlayId: "confirmDialog" })) };
}, []);
}
const ConfirmOnMount = hookController(useOpenConfirm);
function DeleteButton() {
const [ready, setReady] = useState(false);
useEffect(() => void loadEditor().then(() => setReady(true)), []);
return <button disabled={!ready} onClick={async () => (await confirm("Delete this note?")) && console.log("deleted")}>Delete</button>;
}
Styling is required for a correct look: see Styling.
Everything in one provider (small apps, tests). <SummonProvider> from ./react makes a private store with the core installed; nothing is deferred:
import { SummonProvider, SummonHost } from "@ai-matrx/summon/react";
import { APP_OVERLAYS } from "./overlays";
export function SmallApp({ children }: { children: React.ReactNode }) {
return (
<SummonProvider>
{children}
<SummonHost definitions={APP_OVERLAYS} />
</SummonProvider>
);
}
Definition options
| Field | Meaning |
|---|---|
id, label | id is the registry key; label shows in the tray, menus and diagnostics |
instance | "single" (default, instance id "default") or "multi" (a fresh id per open, or pass instanceId) |
keepAlive | "none" (default), "hidden" or "restore" |
closesOnNavigation | close when the route changes (the overlay shows a piece of the page that opened it); a reload restores it only on the page it was open on |
defaults | merged under every open's data |
window | { mobile, mobileSidebarAs, ephemeral, preservation, restoreDefaults, windowId, deprecated, unbiddenHome } for window overlays (every window is remembered unless preservation: false; see Persistence) |
link | { key, aliases?, hydrate }, see Links |
parse | narrow raw data once; null refuses |
render | () => import("./x.render"); optional when the host supplies it (see below), and wins over the host map when present |
Minimizing a window always keeps its component mounted, whatever keepAlive says.
Keep-alive and saved state
import { useOverlayState, useOverlayInstance } from "@ai-matrx/summon/react";
function Draft() {
const [text, setText] = useOverlayState("draft", ""); // like useState; scoped to this open instance
const { close, hidden } = useOverlayInstance();
return <textarea value={text} onChange={(e) => setText(e.target.value)} />;
}
keepAlive: "hidden": closing hides the live component (<Activity mode="hidden">); reopening the same instance shows it again, state intact. A single overlay reopens its one instance. Amultioverlay opened without aninstanceIdmakes a NEW instance (a second window, a new chat); the closed one comes back only when reopened with itsinstanceId. At mostkeepAliveLimit(default 8) closed instances stay mounted; the oldest is evicted and released. An identity change (sign-out, account swap) releases every kept-alive instance (discardAllOverlays), so the next person never sees the previous one's live component.keepAlive: "restore": closing unmounts;useOverlayStatevalues come back on reopen (and persist when persistence is on).useOverlayStatevalues must be serialisable. Any descendant of the overlay may call it.
Callbacks without functions in Redux
Declare an event map and pass plain functions when opening. They travel through an event channel owned by the instance; only its id rides in the data.
export const uploader = defineOverlay<{ folder?: string }, { uploaded: { url: string }; cleared: void }>()({
id: "uploader", label: "Uploader", render: () => import("./uploader.render"),
});
const open = uploader.useSummon();
open({ data: { folder: "logos" }, handlers: { uploaded: (r) => setUrl(r.url) } });
// inside the overlay's render tree
const emit = uploader.useEmit();
emit("uploaded", { url });
Closing the instance disposes the channel. Emitting after that is a silent no-op.
Large apps: the render map lives on the host
Every file that imports a definition to open it also imports that definition's render: () => import(…). To a bundler, each such file gains an edge to the overlay's whole window subtree. With a few overlays that is free. With hundreds of overlays and hundreds of opener files it multiplies the build graph: in AI Matrx, about 450 opener files each reached about 220 window subtrees, and a sub-app build went from about 10 s to an out-of-memory kill. This is the host-build law: never fan out import() edges.
The fix keeps "declare once" at two files plus one line. Leave render off the definitions, and give the host one id → import map, generated from the file names, that only the host imports:
// definitions/notes.overlay.ts — no render: importing it costs nothing
export const notes = defineOverlay<{ noteId?: string }>()({ id: "notes", label: "Notes" });
// definitions/render-map.ts — generated, imported ONLY by the host (behind its own lazy boundary)
import type { OverlayRenderMap } from "@ai-matrx/summon/react";
export const RENDER_MAP: OverlayRenderMap = { notes: () => import("./notes.render") };
// the host
<SummonHost definitions={APP_OVERLAYS} renders={RENDER_MAP} />
OverlayRenderMap erases each module's data type (OverlayRenderProps<never>), so differently typed render modules share one map without any. An overlay that is opened with no render source at all (no render, no map entry) shows its error card (OverlayRenderMissingError, exported as MISSING_RENDER_ERROR) and logs in development. It is never a silent blank.
Mounting into an existing Redux store
import { configureStore } from "@reduxjs/toolkit";
import { summonReducers, createSummonMiddleware } from "@ai-matrx/summon";
const ports = { /* see Ports */ };
export const store = configureStore({
reducer: { ...appReducers, ...summonReducers }, // keys: overlays, windowManager, urlSync, overlayState
middleware: (getDefault) => getDefault().concat(createSummonMiddleware(ports)),
});
<SummonProvider store={store} ports={ports}>…</SummonProvider>
Pass the same ports object to both. The overlays slice keys are fixed. Without store, the provider makes a private store (createSummonStore({ ports })).
Provider knobs: fallbacks (loading, error components), errorReportExtras (extra controls in the error card), renderWrapper (wrap every rendered overlay), keepAliveLimit (8), importTimeoutMs (12 s; a render module that has not loaded becomes a ChunkLoadError with Try again), stallWarnMs (10 s).
Pay only when something opens: the gate
The quick start is this setup: the store carries no summon slice or middleware until the first
summon action, which the gate queues; it starts one import() of the core, which injects the
slices through gate.attachReducer, adds the middleware and replays the queue. Pass the gate the
same ports as the root (createSummonGate({ ports }), <SummonRoot ports={ports}>). A module
that imports the core statically (a page's WindowPanel) installs it before it renders.
runtime (the host, tray sync, router port) mounts once the core is installed.
prefetchSummonRuntime() starts the load early (idle, pointer intent). Openers and selectors come
from ./open, defineOverlay from ./define, <SummonRoot> from ./root.
Ports
Every port has a working default, so a bare host needs none. Override only what you have; pass them as ports on the provider (a partial object).
| Port | Shape | Default |
|---|---|---|
identity | current(): { key, ready }, subscribe(listener) | anonymous, always ready. A changing key closes and releases everything (kept-alive instances included) and loads the next person's saved state |
router | pathname(), search(), replaceSearch(next), subscribe(listener) | window.location + history.replaceState (inert on the server) |
notify | info, warn, error(message, options?), optional dismiss(id) | console |
errors | capture(error, context) | console.error |
debug | enabled(), optional subscribe | off |
persistence | an OverlayPersistencePort, or null | null (persistence off) |
Next.js: import { useNextRouterPort } from "@ai-matrx/summon/next" returns a stable RouterPort (call it inside a client component under Suspense; it reads useSearchParams). Testing fakes: see Testing.
Links
OverlayLinkManager keeps ?panels= and open overlays in sync through ports.router. Mount it once inside the provider.
import { OverlayLinkManager, useOverlayLink } from "@ai-matrx/summon/url";
<SummonProvider><OverlayLinkManager /><SummonHost definitions={APP_OVERLAYS} /></SummonProvider>
Give the definition a link:
defineOverlay<NotesWindowData>()({
id: "notesWindow", label: "Notes", render: () => import("./notes.render"),
link: {
key: "notes",
hydrate: ({ id, args, open }) => open({ data: { noteId: id } }), // return "refused" to drop the token
},
});
- Opening
/page?panels=notes:42runshydrate({ id: "42", args, open, dispatch }).hydratemay be async (load a record, thenopen). - A mounted overlay publishes its own address with
useOverlayLink("notes", noteId, { tab: "history" })(also exported from./react, so a render module can publish without loading the link section).WindowPaneldoes it for you from itsurlSyncKey,urlSyncIdandurlSyncArgsprops. - Unknown keys are announced at once; tokens whose overlay never registers are announced at a deadline and kept verbatim.
- Grammar v2:
panels = token ("," token)*,token = key[:id[:k-v(_k-v)*]]. Ids are percent-encoded for% , :, arg keys and values for% , : _ -, so any value survives; v1 links still parse. Helpers:parseLinkToken,parseLinkParam,formatLinkToken,readPanelsParam,withPanelsParam. - One door:
openFromLink(searchOrUrl, { dispatch, requireAll?, onError? })returns{ opened, refused, unknown, failed }and serves the address bar, notifications and any bridge.requireAll: trueopens nothing if any key is unknown.openPanelRequest(payload, { dispatch })validates and opens a{ panelId, instanceId?, data? }request from a bridge (returns{ ok: true, … }or{ ok: false, error: "invalid_payload" | "unknown_panel" }).
Persistence
Off by default. Turn it on with a persistence port plus one mounted component:
import { createLocalPersistence, OverlayPersistence } from "@ai-matrx/summon/persistence";
const persistence = createLocalPersistence(); // create once, module level
<SummonProvider ports={{ persistence }}>
<OverlayPersistence /> {/* mount once */}
<SummonHost definitions={APP_OVERLAYS} />
</SummonProvider>
What is remembered, and where:
| What | Where | Comes back |
|---|---|---|
| The open windows (the workspace) | this tab's sessionStorage | on a reload of the same tab. A new tab starts with no windows; another device never sees them. Never written to the port |
| Where each window was last placed, and its size | this device's localStorage (one map, at most 200 windows) | the next time that window opens on this device |
Component state (useOverlayState) of keepAlive: "restore" instances and remembered windows | the port, per person (state:<overlayId>:<instanceId>) | wherever the person opens that instance, any tab or device |
Every window is remembered by default: its position, size, state and its JSON open data (handle ids such as callbackGroupId are never saved), and its useOverlayState values. window.preservation: { dataKeys, requiredDataKeys?, allowedDataValues?, maxDataBytes? } narrows the saved data to an allowlist and validates it on restore; window.preservation: false (or window.ephemeral: true) never saves the window. Caps: 256 KiB workspace, 64 open windows, 32 KiB per window's data and per state entry; overruns report through ports.errors.capture (source: "overlay-persistence").
<OverlayPersistence> options: tabStorage (default window.sessionStorage; null = open windows are not remembered), deviceStorage (default window.localStorage; null = windows always open at their declared position), saveDebounceMs (250), viewport. useOverlayPersistence(options) returns { hydrated }. Boot makes no port read: saved component state is read once per identity (loadAll, else list + loads) on first use: a useOverlayState mount (it suspends until the values are in the store), the first value to save, or a keepAlive "restore" instance opening. Port writes wait for that read; nothing is written to the port until a component state value changes. Code reading saved state without useOverlayState calls savedStateGateFor(store)?.ensure() first. Sign-out clears the tab's open windows; an identity change discards every kept-alive instance.
Browser-local store. createLocalPersistence({ namespace?, backend?, storage?, locks?, idbReadBudgetMs? }): a localStorage mirror plus IndexedDB, keys per identity. It also has purgeIdentity(identityKey) for sign-out cleanup.
Your own database. Implement the port:
import type { OverlayPersistencePort } from "@ai-matrx/summon";
export const myPersistence: OverlayPersistencePort = {
perPerson: true, // true: rows are already scoped to the signed-in person (plain keys). Omit/false: keys arrive prefixed "<identityKey>/"
load: async (key) => /* { value, version } | null */ null,
save: async (key, value, options) => {
/* if options?.expectedVersion is set and differs from the stored version, throw an error named "PersistenceVersionConflictError" */
return { version: 1 };
},
remove: async (key) => {},
list: async (prefix) => [], // keys starting with prefix
};
Keys written: state:<overlayId>:<instanceId> (component state only). Values are JSON. Suggested table:
| Column | Type | |
|---|---|---|
person_id | uuid | owner; rows are read and written only for the signed-in person |
key | text | one of the keys above; unique with person_id |
value | jsonb | the saved value |
version | int | starts at 1, +1 per save (optimistic concurrency) |
On a conflict the controller re-reads the version and writes once more, so the latest write of a component state wins. Version conflicts are recognized by error name (isVersionConflict(error)).
@ai-matrx/data hosts. createDataPersistence({ db, userId?, table?, feature? }) from @ai-matrx/summon/persistence/data maps each key to a row of users.user_surface_state (feature = "summon", surface_key = key, state = value, version), through the data package's doors. The organization comes from createDb({ client, organization }) and the insert is refused when there is none.
import { createDb } from "@ai-matrx/data/db";
import { createDataPersistence } from "@ai-matrx/summon/persistence/data";
const persistence = createDataPersistence({ db: createDb({ client: supabase, organization: () => activeOrgId }) });
Handles
@ai-matrx/summon/handles is framework-free. State lives in one global slot, so duplicated bundles share one registry.
callbackManager(verbs):register,registerWithContext(callback, { context, groupId, owner }),subscribe,unsubscribe,trigger,triggerWithContext,triggerGroup,triggerGroupCommand,updateProgress,createGroup,removeGroup,remove,get,has,registerWidgetHandle,ownedBy,groupMembers,disposeOwner,size,subscribeToRegistry.- Typed helpers, each accepting
{ owner }:
import { createEventChannel, getEventChannel, createRequest, getRequest, registerHandle, getHandle } from "@ai-matrx/summon/handles";
const owner = "notesWindow:default"; // overlayId:instanceId
const channel = createEventChannel<{ saved: string }>({ owner }); // many-shot; pass channel.id in open data
channel.subscribe((e) => console.log(e)); channel.emit({ saved: "42" });
getEventChannel<{ saved: string }>(channel.id)?.emit({ saved: "43" }); // overlay side
const request = createRequest<{ prompt: string }, boolean>({ prompt: "Discard?" }, { owner }); // one-shot
getRequest<{ prompt: string }, boolean>(request.id)?.resolve(true); // overlay answers
const answer = await request.promise; // rejects with HandleDisposedError if disposed unanswered
const dispose = registerHandle("editor:42", { focus() {} }, { owner }); // imperative ref
getHandle<{ focus(): void }>("editor:42")?.focus();
dispose();
Owner disposal. When an instance closes, everything registered with its overlayId:instanceId as owner is disposed: pending requests reject, channels drop subscribers, handles are removed. No opener disposes by hand. Disposing a group member removes it from the group.
WindowPanel
import { WindowsProvider, WindowPanel } from "@ai-matrx/summon/window";
Slots contract: the body is content only. Everything that is window chrome goes in a slot, never in the body.
| Slot | Props |
|---|---|
| Title | title, titleNode |
| Header actions | actionsLeft, actionsRight |
| Footer | footer, or zoned footerLeft / footerCenter / footerRight; footerVariant: "bar" | "rich" |
| Left sidebar | sidebar, sidebarDefaultSize, sidebarMinSize, defaultSidebarOpen, sidebarExpandsWindow |
| Right panel | secondaryPanel, secondaryPanelOpen, secondaryPanelDefaultSize, secondaryPanelMinSize |
Close binding is required and checked by the type system: exactly one of overlayId (close that overlay), boundToOverlay (close the enclosing SummonHost instance) or onClose (page-local). A window with none of them does not compile.
Other props: id, width/height (numbers or "60vw"), minWidth/minHeight, position, fitContent, closeOnEscape, hidePopOutButton, urlSyncKey/urlSyncId/urlSyncArgs, onCollectData, captureTraySnapshot.
For screenshot thumbnails, pass ports.elementCapture to SummonRoot or SummonProvider. Its render(element, options) returns a canvas; a host above Alchemy can lazily delegate to renderElement from @ai-matrx/alchemy/operate/capture. A host without the capability still gets the labeled tile. Explicit captureTraySnapshot and registered snapshot/preview components retain priority. The standalone captureElementThumbnail helper uses the configured host port, or its explicit elementCapture option.
Behavior you can rely on: maximize, minimize, pop-out and dock never remount the body (state, refs and effects survive; minimize keeps the body mounted and captures the tray thumbnail). Pop-out uses a real browser window (Document Picture-in-Picture where available). On phones a window renders as fullscreen, drawer or card per the definition's window.mobile.
WindowsProvider is optional; options: escapeYieldSelector (visible elements matching it own the Escape key; default [data-overlay-escape-yield]), mobileTopOffset (default var(--header-height, 0px)), selectionSurface (a component wrapping every window body, for a host's selection or right-click menu).
Also exported from ./window: WindowTraySync and registerTrayPreview (a semantic preview for a minimized tile; both also on the light ./window-host entry — mount providers from there at the app root), LayoutIcon, usePopoutControl, MobileDrawerSurface, MobileCardSurface, useWindowPanel.
Dialog, sheet, light window
import { OverlayDialog } from "@ai-matrx/summon/dialog";
import { OverlaySheet, OverlayDrawer, OverlayBottomSheet } from "@ai-matrx/summon/sheet";
import { LiteWindow } from "@ai-matrx/summon/lite";
// inside a render module: closes through the overlay instance
<OverlayDialog boundToOverlay>{/* DialogContent, DialogTitle … from @ai-matrx/design-system */}</OverlayDialog>
// page-local: you own the state
<OverlayDialog open={open} onClose={() => setOpen(false)}>…</OverlayDialog>
<LiteWindow id="inspector" title="Inspector">…</LiteWindow> {/* inside an SummonHost overlay, onClose can be omitted */}
OverlayDialog, OverlaySheet, OverlayDrawer and OverlayBottomSheet take the design-system component's props plus the close binding: boundToOverlay (open while the instance is shown) or onClose (+ open). LiteWindow has drag, edge resize, Escape when topmost, focus in and out, and a bottom drawer on phones; no minimize, tray, persistence or links.
Dock (floating chrome-less cards, pills and docks)
import { Dock, DockHandle, DockProvider, useDock, useDockContext } from "@ai-matrx/summon/dock";
<Dock id="attention" corner="bottom-right" defaultPosition={{ x: 16, y: 16 }} zIndex={50} className="w-80">
<DockHandle>⋮⋮ Alerts <button onClick={…}>Close</button></DockHandle>
…
</Dock>
// Your own markup: spread the bindings. Destructure them: the React compiler treats an object
// carrying a `ref` as a ref, so reading `dock.style` during render counts as a ref access.
const { ref, style, handleProps } = useDock({ id: "assists", corner: "bottom-right", position, onPositionChange: save, touch: false, clamp: { margin: 0, minVisible: { width: 120, height: 32 } } });
<div ref={ref} style={style}><button {...handleProps}>pill</button></div>
// Hand every dock the host's storage (declared keys, per-user sync).
<DockProvider store={{ read: (id) => …, write: (id, position) => … }}>…</DockProvider>
Pointer drag that never re-renders while moving: each move paints the CSS translate property in one animation frame and the position is committed to React state once, on release (dragging flips twice per gesture, never per move), so a dock with a streaming answer inside stays cheap. Positions are offsets from a corner (corner, default bottom-right), clamped to the viewport on every render (clamp: { margin = 8, minVisible }; a dock bigger than the room keeps its top-left reachable); snapToCorner re-anchors to the nearest corner on release; avoid: { selector, gap } keeps it off fixed controls. Without a position it rests on defaultPosition, or on a CSS anchor (top/bottom/left/right/centerX). Remembered per id: the dock's store, else the nearest DockProvider, else localStorage (matrx:summon:dock:<id>); persist={false} for per-moment docks; a controlled host passes position + onPositionChange (told once per drag, and null on reset()). A press passes a 4px threshold before it is a drag, and a drag swallows its trailing click; controls inside the handle (ignore, default buttons/links/fields/[data-no-drag]) never start one; touch={false} leaves touch to page scroll; draggable={false} pins it (phones). On press it joins the window manager's z-order lane (raiseTransientWindow, above windows, below modals) when a SummonProvider is present, and the drag holds grabbing through design-system holdDragCursor. No listener exists outside an active press.
Styling
The windows, dialogs and sheets draw with design-system theme utilities (bg-card, text-foreground, border-border, …) and need design-system's structural CSS. A host needs, in its Tailwind v4 CSS entry:
@layer matrx-design-system-tokens, matrx-design-system; /* first line: your own tokens always win */
@import "tailwindcss";
@import "@ai-matrx/design-system/tokens.css"; /* default token values (skip if your theme declares them all) */
@import "@ai-matrx/design-system/styles.css"; /* design-system structural CSS: required */
@import "@ai-matrx/design-system/theme.css"; /* maps bg-card, text-foreground, … onto the tokens and scans design-system */
@import "@ai-matrx/summon/styles.css"; /* summon's structural CSS: required */
@source "../node_modules/@ai-matrx/summon/dist"; /* path relative to this CSS file */
Without styles.css and the theme mapping the windows render transparent and borderless. A host that already maps the semantic color vocabulary (AI Matrx does) skips theme.css and keeps its own, plus @source "../node_modules/@ai-matrx/design-system/dist". Tailwind v4 is required (the package CSS uses v4 at-rules). Colors come only from design-system tokens, so the host's theme applies; the traffic-light colors are --matrx-window-light-* custom properties you can override.
Testing
import { createTestPorts, createTestSummonStore, createMemoryPersistence } from "@ai-matrx/summon/testing";
const ports = createTestPorts({ persistence: createMemoryPersistence() });
const store = createTestSummonStore(ports);
render(<SummonProvider store={store} ports={ports}><SummonHost definitions={[notesWindow]} /></SummonProvider>);
createTestPorts gives a controllable identity (set(key, ready?)), a controllable router (navigate(path, search?)) and recording notify and errors (messages, captured). createMemoryPersistence is an in-memory, versioned port. No test-runner dependency.
Gates
pnpm typecheck (also compiles the package with a host-augmented registry), npx vitest run, pnpm check:package.