@nimblebrain/synapse 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,6 +37,10 @@ npm install @nimblebrain/synapse
37
37
 
38
38
  **Peer dependency:** `@modelcontextprotocol/ext-apps@^1.3.1`
39
39
 
40
+ **Building a Python MCP server?** The server half — one self-contained `ui://`
41
+ component rendered across ChatGPT, Claude, and NimbleBrain — ships as the
42
+ [`nimblebrain-synapse`](python/README.md) PyPI package (`pip install nimblebrain-synapse`).
43
+
40
44
  ## Package Exports
41
45
 
42
46
  | Entry Point | Description |
@@ -78,8 +82,10 @@ These are the durable decisions behind the library; they rarely change.
78
82
  - **Responsive to the pane, not the device.** Layouts observe their own width
79
83
  (`ResizeObserver` via `useBreakpoint`), because an app's iframe may be
80
84
  fullscreen, split, or a narrow rail regardless of screen size.
81
- - **Lean on the platform.** `Drawer` is built on the native `<dialog>` element
82
- (focus-trap, Escape, scroll behavior for free) rather than re-implementing them.
85
+ - **Sandbox-safe overlays.** `Drawer` is a plain `<div>` overlay, not a native
86
+ `<dialog>`: the app iframe withholds `allow-modals`, so `<dialog>.showModal()`
87
+ throws there. The scrim, Tab focus trap, focus-in/restore, scroll-lock, and
88
+ Escape are hand-rolled.
83
89
 
84
90
  ## Quick Start
85
91
 
@@ -113,7 +119,7 @@ app.updateModelContext(
113
119
  ### React
114
120
 
115
121
  ```tsx
116
- import { AppProvider, useToolResult, useCallTool, useResize } from "@nimblebrain/synapse/react";
122
+ import { AppProvider, useToolResult, useResize } from "@nimblebrain/synapse/react";
117
123
 
118
124
  function App() {
119
125
  return (
@@ -125,7 +131,6 @@ function App() {
125
131
 
126
132
  function ItemList() {
127
133
  const result = useToolResult();
128
- const { call, data, isPending } = useCallTool("list_items");
129
134
  const resize = useResize();
130
135
 
131
136
  useEffect(() => { if (result) resize(); }, [result, resize]);
@@ -336,7 +341,7 @@ await synapse.ready;
336
341
  | `ready` | Promise that resolves after the ext-apps handshake |
337
342
  | `isNimbleBrainHost` | Whether the host is a NimbleBrain platform |
338
343
  | `callTool(name, args?)` | Call an MCP tool and get typed result |
339
- | `callToolAsTask(name, args?, opts?)` | Call a long-running tool task-augmented; returns a `TaskHandle` immediately. See [Long-running tools](#long-running-tools-tasks) below. |
344
+ | `callToolAsTask(name, args?, opts?)` | Call a long-running tool task-augmented; returns a `Promise<TaskHandle>`. See [Long-running tools](#long-running-tools-tasks) below. |
340
345
  | `onDataChanged(cb)` | Subscribe to data change events |
341
346
  | `onAction(cb)` | Subscribe to agent actions (typed, declarative) |
342
347
  | `getTheme()` | Get current theme |
@@ -359,7 +364,7 @@ await synapse.ready;
359
364
  Wrap your app with `<AppProvider>` and use these hooks. Each is a thin wrapper over `connect()`.
360
365
 
361
366
  ```tsx
362
- import { AppProvider, useApp, useToolResult, useToolInput, useResize, useCallTool } from "@nimblebrain/synapse/react";
367
+ import { AppProvider, useApp, useToolResult, useToolInput, useResize } from "@nimblebrain/synapse/react";
363
368
  ```
364
369
 
365
370
  | Hook | Returns | Description |
@@ -369,7 +374,8 @@ import { AppProvider, useApp, useToolResult, useToolInput, useResize, useCallToo
369
374
  | `useToolInput()` | `Record<string, unknown> \| null` | Re-renders on every `tool-input` event |
370
375
  | `useConnectTheme()` | `Theme` | Reactive theme from `connect()` |
371
376
  | `useResize()` | `(w?, h?) => void` | Resize helper — auto-measures body if no args |
372
- | `useCallTool(name)` | `{ call, data, isPending, error }` | Call a tool with loading/error state |
377
+
378
+ To call a tool on this path, use the `App` instance: `useApp()`, then `app.callTool(name, args)`. The `useCallTool` hook (with built-in loading state) belongs to the `SynapseProvider` set below.
373
379
 
374
380
  ### `SynapseProvider`-based (Legacy)
375
381
 
package/dist/ui/index.cjs CHANGED
@@ -307,21 +307,23 @@ function Card({
307
307
  }
308
308
  var STYLE_ID3 = "nb-synapse-drawer";
309
309
  var RULES3 = `
310
+ .nb-drawer-scrim {
311
+ position: fixed; inset: 0; z-index: 1000;
312
+ display: flex; background: rgba(0, 0, 0, 0.32);
313
+ animation: nb-drawer-fade 200ms ease;
314
+ }
315
+ .nb-drawer-scrim--right { justify-content: flex-end; }
316
+ .nb-drawer-scrim--left { justify-content: flex-start; }
317
+ .nb-drawer-scrim--bottom { align-items: flex-end; }
310
318
  .nb-drawer {
311
319
  margin: 0; padding: 0; border: none; max-width: 92%;
312
320
  height: 100%; max-height: 100%; display: flex; flex-direction: column;
321
+ outline: none;
313
322
  }
314
- .nb-drawer--right { margin-left: auto; }
315
- .nb-drawer--left { margin-right: auto; }
316
- .nb-drawer--bottom {
317
- margin-top: auto; width: 100%; max-width: 100%;
318
- height: auto; max-height: 92%;
319
- }
320
- .nb-drawer--right[open] { animation: nb-drawer-in-right 240ms cubic-bezier(0.2, 0, 0, 1); }
321
- .nb-drawer--left[open] { animation: nb-drawer-in-left 240ms cubic-bezier(0.2, 0, 0, 1); }
322
- .nb-drawer--bottom[open] { animation: nb-drawer-in-bottom 240ms cubic-bezier(0.2, 0, 0, 1); }
323
- .nb-drawer::backdrop { background: rgba(0, 0, 0, 0.32); }
324
- .nb-drawer[open]::backdrop { animation: nb-drawer-fade 200ms ease; }
323
+ .nb-drawer--bottom { width: 100%; max-width: 100%; height: auto; max-height: 92%; }
324
+ .nb-drawer--right { animation: nb-drawer-in-right 240ms cubic-bezier(0.2, 0, 0, 1); }
325
+ .nb-drawer--left { animation: nb-drawer-in-left 240ms cubic-bezier(0.2, 0, 0, 1); }
326
+ .nb-drawer--bottom { animation: nb-drawer-in-bottom 240ms cubic-bezier(0.2, 0, 0, 1); }
325
327
  @keyframes nb-drawer-in-right { from { transform: translateX(100%); } }
326
328
  @keyframes nb-drawer-in-left { from { transform: translateX(-100%); } }
327
329
  @keyframes nb-drawer-in-bottom { from { transform: translateY(100%); } }
@@ -331,6 +333,18 @@ var RULES3 = `
331
333
  .nb-drawer-iconbtn { min-width: 44px; min-height: 44px; }
332
334
  }
333
335
  `;
336
+ function inClosedDetails(el) {
337
+ const details = el.closest("details:not([open])");
338
+ if (!details) return false;
339
+ const summary = details.querySelector("summary");
340
+ return !summary || !summary.contains(el);
341
+ }
342
+ var CANDIDATES = "a[href], button, input, select, textarea, summary, [tabindex]";
343
+ function tabbables(panel) {
344
+ return Array.from(panel.querySelectorAll(CANDIDATES)).filter(
345
+ (el) => el.tabIndex >= 0 && !el.matches(":disabled") && !el.closest("[hidden]") && !inClosedDetails(el) && el.type !== "hidden"
346
+ );
347
+ }
334
348
  var DrawerContext = react.createContext(null);
335
349
  function DrawerRoot({
336
350
  open,
@@ -343,41 +357,86 @@ function DrawerRoot({
343
357
  ...rest
344
358
  }) {
345
359
  chunkRX6XCLFF_cjs.ensureStyle(STYLE_ID3, RULES3);
346
- const dialogRef = react.useRef(null);
360
+ const panelRef = react.useRef(null);
347
361
  const labelId = react.useId();
348
362
  const [hasTitle, setHasTitle] = react.useState(false);
349
363
  react.useEffect(() => {
350
- const dialog = dialogRef.current;
351
- if (!dialog || typeof dialog.showModal !== "function") return;
352
- if (open && !dialog.open) dialog.showModal();
353
- else if (!open && dialog.open) dialog.close();
364
+ if (!open) return;
365
+ const onKey = (e) => {
366
+ if (e.key === "Escape") {
367
+ e.preventDefault();
368
+ (onEscape ?? onClose)();
369
+ }
370
+ };
371
+ window.addEventListener("keydown", onKey);
372
+ return () => window.removeEventListener("keydown", onKey);
373
+ }, [open, onEscape, onClose]);
374
+ react.useEffect(() => {
375
+ if (!open) return;
376
+ const panel = panelRef.current;
377
+ const previouslyFocused = document.activeElement;
378
+ panel?.focus();
379
+ const { body } = document;
380
+ const prevOverflow = body.style.overflow;
381
+ body.style.overflow = "hidden";
382
+ const onKeyDown = (e) => {
383
+ if (e.key !== "Tab" || !panel) return;
384
+ const focusables = tabbables(panel);
385
+ if (focusables.length === 0) {
386
+ e.preventDefault();
387
+ return;
388
+ }
389
+ const first = focusables[0];
390
+ const last = focusables[focusables.length - 1];
391
+ const active = document.activeElement;
392
+ if (e.shiftKey && (active === first || active === panel)) {
393
+ e.preventDefault();
394
+ last.focus();
395
+ } else if (!e.shiftKey && active === last) {
396
+ e.preventDefault();
397
+ first.focus();
398
+ }
399
+ };
400
+ panel?.addEventListener("keydown", onKeyDown);
401
+ return () => {
402
+ panel?.removeEventListener("keydown", onKeyDown);
403
+ body.style.overflow = prevOverflow;
404
+ previouslyFocused?.focus?.();
405
+ };
354
406
  }, [open]);
407
+ const ctxValue = react.useMemo(() => ({ labelId, setHasTitle }), [labelId]);
408
+ if (!open) return null;
355
409
  const isBottom = side === "bottom";
356
410
  const panelStyle = {
357
411
  ...isBottom ? { width: "100%", maxWidth: "100%" } : { width },
358
412
  background: tokens.bg,
359
413
  color: tokens.fg,
360
414
  boxShadow: tokens.shadowLg,
415
+ outline: "none",
361
416
  ...side === "right" ? { borderLeft: `${tokens.borderWidth} solid ${tokens.border}` } : side === "left" ? { borderRight: `${tokens.borderWidth} solid ${tokens.border}` } : { borderTop: `${tokens.borderWidth} solid ${tokens.border}` },
362
417
  ...style
363
418
  };
364
- const ctxValue = react.useMemo(() => ({ labelId, setHasTitle }), [labelId]);
365
419
  return /* @__PURE__ */ jsxRuntime.jsx(DrawerContext.Provider, { value: ctxValue, children: /* @__PURE__ */ jsxRuntime.jsx(
366
- "dialog",
420
+ "div",
367
421
  {
368
- ref: dialogRef,
369
- className: `nb-drawer nb-drawer--${side}`,
370
- style: panelStyle,
371
- "aria-labelledby": hasTitle ? labelId : void 0,
372
- onCancel: (e) => {
373
- e.preventDefault();
374
- (onEscape ?? onClose)();
375
- },
422
+ className: `nb-drawer-scrim nb-drawer-scrim--${side}`,
376
423
  onClick: (e) => {
377
- if (e.target === dialogRef.current) onClose();
424
+ if (e.target === e.currentTarget) onClose();
378
425
  },
379
- ...rest,
380
- children
426
+ children: /* @__PURE__ */ jsxRuntime.jsx(
427
+ "div",
428
+ {
429
+ ref: panelRef,
430
+ role: "dialog",
431
+ "aria-modal": "true",
432
+ "aria-labelledby": hasTitle ? labelId : void 0,
433
+ tabIndex: -1,
434
+ className: `nb-drawer nb-drawer--${side}`,
435
+ style: panelStyle,
436
+ ...rest,
437
+ children
438
+ }
439
+ )
381
440
  }
382
441
  ) });
383
442
  }