@webmcp-auto-ui/ui 2.5.28 → 2.5.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webmcp-auto-ui/ui",
3
- "version": "2.5.28",
3
+ "version": "2.5.29",
4
4
  "description": "Svelte 5 UI components — primitives, widgets, window manager",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -57,15 +57,16 @@
57
57
  "auto-ui"
58
58
  ],
59
59
  "dependencies": {
60
+ "@types/d3": "^7.4.3",
60
61
  "@webmcp-auto-ui/core": "*",
61
62
  "@webmcp-auto-ui/sdk": "*",
62
- "@types/d3": "^7.4.3",
63
63
  "bits-ui": "^2.17.2",
64
64
  "clsx": "^2.1.1",
65
65
  "highlight.js": "^11.10.0",
66
66
  "html-to-image": "^1.11.13",
67
67
  "marked": "^14.1.3",
68
68
  "tailwind-merge": "^3.5.0",
69
- "tailwind-variants": "^3.2.2"
69
+ "tailwind-variants": "^3.2.2",
70
+ "turndown": "^7.2.4"
70
71
  }
71
72
  }
package/src/index.ts CHANGED
@@ -5,6 +5,8 @@ export { default as ThemeProvider, getTheme } from './theme/ThemeProvider.svelte
5
5
  export type { ThemeJSON } from './theme/ThemeProvider.svelte';
6
6
  export { DARK_TOKENS, LIGHT_TOKENS, THEME_MAP } from './theme/tokens.js';
7
7
  export type { ThemeMode, ThemeOverrides, ThemeTokens } from './theme/tokens.js';
8
+ export { getUIScale, setUIScale, toggleUIScale, initUIScale, isUIScaled } from './theme/scale.js';
9
+ export type { UIScale, UIScaleKey } from './theme/scale.js';
8
10
 
9
11
  // Primitives
10
12
  export { default as Card } from './primitives/Card.svelte';
@@ -46,14 +48,13 @@ export { render as renderLog } from './widgets/rich/log.js';
46
48
  export { render as renderGallery } from './widgets/rich/gallery.js';
47
49
  export { render as renderCarousel } from './widgets/rich/carousel.js';
48
50
 
49
- // Notebook widget renderers (vanilla)
50
- export { render as renderCompact } from './widgets/notebook/compact.js';
51
- export { render as renderWorkspace } from './widgets/notebook/workspace.js';
52
- export { render as renderDocument } from './widgets/notebook/document.js';
53
- export { render as renderEditorial } from './widgets/notebook/editorial.js';
51
+ // Notebook widget renderer (vanilla)
52
+ export { render as renderNotebook } from './widgets/notebook/notebook.js';
54
53
  export { render as renderRecipeBrowserWidget } from './widgets/notebook/recipe-browser.js';
55
54
  // Notebook types (optional public API)
56
55
  export type { NotebookState, NotebookCell } from './widgets/notebook/shared.js';
56
+ // Notebook cell extractors (for hosts that build notebooks from recipes/tools)
57
+ export { extractCellsFromRecipe, extractCellsFromTool, extractCellFromMarkdown, extractCellFromFence } from './widgets/notebook/resource-extractor.js';
57
58
 
58
59
  // Safe image helper (URL validation + error fallback)
59
60
  export { createSafeImage } from './widgets/helpers/safe-image.js';
@@ -109,7 +110,6 @@ export { default as AgentConsole } from './agent/AgentConsole.svelte';
109
110
  export { default as SettingsPanel } from './agent/SettingsPanel.svelte';
110
111
  export { default as RemoteMCPserversDemo } from './agent/RemoteMCPserversDemo.svelte';
111
112
  export { default as WebMCPserversList } from './agent/WebMCPserversList.svelte';
112
- export { default as DataServersPanel } from './agent/DataServersPanel.svelte';
113
113
  export { default as EphemeralBubble } from './agent/EphemeralBubble.svelte';
114
114
  export { default as TokenBubble } from './agent/TokenBubble.svelte';
115
115
  export { default as DiagnosticModal } from './agent/DiagnosticModal.svelte';
@@ -0,0 +1,128 @@
1
+ /**
2
+ * UI scale — binary 1× / 1.5× toggle, labeled as "2×" in the UI for affordance.
3
+ *
4
+ * Implementation: CSS `zoom` on `<html>`, driven by a custom property set
5
+ * imperatively. This rescales everything uniformly (fonts, icons, borders,
6
+ * modals, widgets) while keeping layout reflow (contrary to `transform: scale`).
7
+ *
8
+ * Persistence: `localStorage['webmcp-ui-scale']` — "x1" | "x2".
9
+ */
10
+
11
+ export type UIScale = 1 | 1.5;
12
+ export type UIScaleKey = 'x1' | 'x2';
13
+
14
+ const STORAGE_KEY = 'webmcp-ui-scale';
15
+ const STYLE_ID = 'webmcp-ui-scale-style';
16
+
17
+ function injectStyle(): void {
18
+ if (typeof document === 'undefined') return;
19
+ if (document.getElementById(STYLE_ID)) return;
20
+ const style = document.createElement('style');
21
+ style.id = STYLE_ID;
22
+ // When scaled:
23
+ // - zoom the root so everything grows uniformly
24
+ // - compensate layouts sized in 100vh/100vw (viewport units stay tied to the
25
+ // physical viewport regardless of zoom, so a `h-screen` container rendered
26
+ // at 1.5× would overflow by 50%). Force these containers to the logical
27
+ // equivalent so they fit after scaling.
28
+ // - keep html/body overflow:hidden (matches the unscaled baseline) so
29
+ // floating bars with flex-shrink-0 stay pinned to the viewport edge
30
+ // instead of drifting mid-screen when the body scrolls.
31
+ style.textContent = `
32
+ html { zoom: var(--ui-scale, 1); }
33
+ html[data-ui-scale="x2"] {
34
+ width: calc(100vw / 1.5);
35
+ height: calc(100vh / 1.5);
36
+ overflow: hidden;
37
+ }
38
+ html[data-ui-scale="x2"] body {
39
+ width: 100%;
40
+ height: 100%;
41
+ overflow: hidden;
42
+ }
43
+ html[data-ui-scale="x2"] .h-screen,
44
+ html[data-ui-scale="x2"] [class*="h-screen"] {
45
+ height: calc(100vh / 1.5);
46
+ max-height: calc(100vh / 1.5);
47
+ }
48
+ html[data-ui-scale="x2"] .w-screen,
49
+ html[data-ui-scale="x2"] [class*="w-screen"] {
50
+ width: calc(100vw / 1.5);
51
+ max-width: calc(100vw / 1.5);
52
+ }
53
+ `;
54
+ document.head.appendChild(style);
55
+ }
56
+
57
+ function applyScale(scale: UIScale): void {
58
+ if (typeof document === 'undefined') return;
59
+ injectStyle();
60
+ const root = document.documentElement;
61
+ root.style.setProperty('--ui-scale', String(scale));
62
+ root.dataset.uiScale = scale === 1 ? 'x1' : 'x2';
63
+ try {
64
+ window.dispatchEvent(new CustomEvent('webmcp:ui-scale-change', { detail: { scale } }));
65
+ } catch { /* ignore */ }
66
+ // Canvas-based widgets (cytoscape, d3, vega-embed, leaflet, custom SVG) size
67
+ // their rendering surface at mount via getBoundingClientRect and don't react
68
+ // to CSS-only zoom changes. Fire a resize event after the browser has applied
69
+ // the zoom (next RAF) so those libs redraw against the new container size.
70
+ // A second tick after ~200ms catches widgets that listen via debounced
71
+ // ResizeObserver + widgets that rebuild on a longer cycle.
72
+ try {
73
+ requestAnimationFrame(() => {
74
+ window.dispatchEvent(new Event('resize'));
75
+ setTimeout(() => window.dispatchEvent(new Event('resize')), 200);
76
+ });
77
+ } catch { /* ignore */ }
78
+ }
79
+
80
+ function readStored(): UIScale {
81
+ if (typeof localStorage === 'undefined') return 1;
82
+ try {
83
+ const raw = localStorage.getItem(STORAGE_KEY);
84
+ if (raw === 'x2') return 1.5;
85
+ return 1;
86
+ } catch { return 1; }
87
+ }
88
+
89
+ function writeStored(scale: UIScale): void {
90
+ if (typeof localStorage === 'undefined') return;
91
+ try { localStorage.setItem(STORAGE_KEY, scale === 1 ? 'x1' : 'x2'); } catch { /* ignore */ }
92
+ }
93
+
94
+ /** Current scale value. Safe to call before init. */
95
+ export function getUIScale(): UIScale {
96
+ if (typeof document === 'undefined') return readStored();
97
+ const key = document.documentElement.dataset.uiScale;
98
+ if (key === 'x2') return 1.5;
99
+ if (key === 'x1') return 1;
100
+ return readStored();
101
+ }
102
+
103
+ /** True when the UI is currently displayed at 1.5× (label "2×" in the UI). */
104
+ export function isUIScaled(): boolean {
105
+ return getUIScale() !== 1;
106
+ }
107
+
108
+ /** Set the scale explicitly. Persists + applies. */
109
+ export function setUIScale(scale: UIScale): void {
110
+ writeStored(scale);
111
+ applyScale(scale);
112
+ }
113
+
114
+ /** Toggle between 1× and 1.5×. Returns the new scale. */
115
+ export function toggleUIScale(): UIScale {
116
+ const next: UIScale = getUIScale() === 1 ? 1.5 : 1;
117
+ setUIScale(next);
118
+ return next;
119
+ }
120
+
121
+ /**
122
+ * Read persisted scale and apply it. Call once at app boot (e.g. in a root
123
+ * +layout onMount) to restore the user's preference before first paint.
124
+ * Safe to call multiple times.
125
+ */
126
+ export function initUIScale(): void {
127
+ applyScale(readStored());
128
+ }
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { type Component, onMount, onDestroy } from 'svelte';
2
+ import { type Component, onMount, onDestroy, untrack } from 'svelte';
3
3
  import type { WebMcpServer } from '@webmcp-auto-ui/core';
4
4
  import { bus } from '../messaging/bus.svelte.js';
5
5
 
@@ -85,8 +85,8 @@
85
85
  'cards': renderCards,
86
86
  'grid-data': renderGridData,
87
87
  'map': renderMap,
88
- 'd3': renderD3,
89
- 'js-sandbox': renderJsSandbox,
88
+ 'd3': renderD3 as unknown as VanillaRenderer,
89
+ 'js-sandbox': renderJsSandbox as unknown as VanillaRenderer,
90
90
  'log': renderLog,
91
91
  'gallery': renderGallery,
92
92
  'carousel': renderCarousel,
@@ -151,13 +151,17 @@
151
151
  // ── Vanilla renderer container + lifecycle ────────────
152
152
  let vanillaContainer: HTMLElement | undefined = $state(undefined);
153
153
 
154
+ // Mount effect — re-runs only when the widget identity changes (type /
155
+ // renderer / container). Data changes are handled separately to avoid a
156
+ // flickering full remount on every agent update.
154
157
  $effect(() => {
158
+ // Touch only the mount deps; `plainData` is intentionally read via untrack
159
+ // so data updates don't retrigger this effect.
155
160
  if (!useVanilla || !vanillaRenderer || !vanillaContainer) return;
156
161
  const container = vanillaContainer;
157
- // Clear previous content
162
+ const renderer = vanillaRenderer;
158
163
  container.innerHTML = '';
159
164
 
160
- // Listen for the standard vanilla event contract: CustomEvent('widget:interact')
161
165
  const onInteract = (ev: Event) => {
162
166
  const ce = ev as CustomEvent<{ action?: string; payload?: unknown }>;
163
167
  const action = ce.detail?.action ?? 'interact';
@@ -169,7 +173,7 @@
169
173
  let cancelled = false;
170
174
 
171
175
  try {
172
- const result = vanillaRenderer(container, plainData);
176
+ const result = renderer(container, untrack(() => plainData));
173
177
  if (result && typeof (result as Promise<unknown>).then === 'function') {
174
178
  (result as Promise<void | (() => void)>).then(
175
179
  (c) => { if (!cancelled) cleanup = c ?? undefined; },
@@ -190,6 +194,29 @@
190
194
  };
191
195
  });
192
196
 
197
+ // Data-update effect — only triggers when plainData changes. Renderers that
198
+ // support in-place updates should listen for `widget:data-update` and call
199
+ // preventDefault() on the event; doing so signals they've handled the new
200
+ // data themselves so we don't need to remount. Renderers that don't listen
201
+ // fall back to a full remount here (innerHTML cleared + mount effect re-run).
202
+ let firstDataCycle = true;
203
+ $effect(() => {
204
+ const data = plainData;
205
+ if (!vanillaContainer || !useVanilla) return;
206
+ if (firstDataCycle) { firstDataCycle = false; return; }
207
+ const ev = new CustomEvent('widget:data-update', { detail: data, cancelable: true });
208
+ const handled = !vanillaContainer.dispatchEvent(ev);
209
+ if (handled || !vanillaRenderer) return;
210
+ // Not handled — fall back to remount by clearing + calling renderer again.
211
+ const container = vanillaContainer;
212
+ container.innerHTML = '';
213
+ try {
214
+ vanillaRenderer(container, data);
215
+ } catch (err) {
216
+ console.error('[WidgetRenderer] fallback remount failed:', err);
217
+ }
218
+ });
219
+
193
220
  // ── Auto-register WebMCP tools when modelContext is available ────────────
194
221
  type ModelContext = {
195
222
  registerTool: (t: unknown) => void;
@@ -316,7 +316,14 @@ function extractRecipeBody(res: any): string | null {
316
316
  if (!text) return null;
317
317
  try {
318
318
  const parsed = JSON.parse(text);
319
- if (parsed && typeof parsed === 'object' && typeof parsed.content === 'string') return parsed.content;
319
+ // Recipe servers return either { body: "..." } (autoui-style),
320
+ // { content: "..." } (legacy), or { markdown: "..." }. Pick whichever
321
+ // string field carries the markdown body, in priority order.
322
+ if (parsed && typeof parsed === 'object') {
323
+ if (typeof parsed.body === 'string') return parsed.body;
324
+ if (typeof parsed.content === 'string') return parsed.content;
325
+ if (typeof parsed.markdown === 'string') return parsed.markdown;
326
+ }
320
327
  } catch { /* not JSON */ }
321
328
  return text;
322
329
  }
@@ -480,7 +487,7 @@ function injectImportStyles() {
480
487
  }
481
488
  .nb-imp-btn:hover { background: var(--color-surface3, #eeeef0); }
482
489
  .nb-imp-primary { background: var(--color-accent, #6a55ff); color: #fff; border: 0; }
483
- .nb-imp-primary:hover { filter: brightness(1.08); }
490
+ .nb-imp-primary:hover { filter: brightness(1.1); color: #fff; }
484
491
  .nb-imp-search, .nb-imp-url {
485
492
  width: 100%; padding: 8px 10px; border: 1px solid var(--color-border, #e4e4e7);
486
493
  border-radius: 6px; font-size: 12px; margin-bottom: 10px;
@@ -141,8 +141,15 @@ export function mountLeftPane(
141
141
  let body = text;
142
142
  try {
143
143
  const parsed = JSON.parse(text);
144
- if (parsed && typeof parsed === 'object' && typeof parsed.content === 'string') body = parsed.content;
145
- } catch { /* not JSON */ }
144
+ // Recipe servers return either { content: "..." } (legacy) or
145
+ // { name, description, body, ... } (autoui-style). Pick whichever
146
+ // string field carries the markdown body, in priority order.
147
+ if (parsed && typeof parsed === 'object') {
148
+ if (typeof parsed.body === 'string') body = parsed.body;
149
+ else if (typeof parsed.content === 'string') body = parsed.content;
150
+ else if (typeof parsed.markdown === 'string') body = parsed.markdown;
151
+ }
152
+ } catch { /* not JSON, use raw text */ }
146
153
  imported.body = body;
147
154
  recipeBodyCache.set(key, body);
148
155
  }
@@ -1,6 +1,6 @@
1
1
  // @ts-nocheck
2
2
  // ---------------------------------------------------------------------------
3
- // notebook-editorial — publication-ready layout (observable-like)
3
+ // notebook — publication-ready layout (observable-like)
4
4
  // Serif prose + cells in a single ordered list, all drag-and-droppable together.
5
5
  // Cells alternate freely: md (prose paragraph) / sql / js cells share the flow.
6
6
  // ---------------------------------------------------------------------------
@@ -13,13 +13,13 @@ import {
13
13
  renderCellLogs,
14
14
  createPublishControls, autoConnectFrontmatterServers,
15
15
  createRuntimeOverlay, effectiveResult, cellRuntimeStatus,
16
- lastRefreshedAt, bootstrapLiveRefresh, fmtRelTime,
16
+ lastRefreshedAt, bootstrapLiveRefresh, fmtRelTime, preserveScrollAround,
17
17
  type NotebookState, type NotebookCell, type CellResult, type CellExecContext,
18
18
  type RuntimeOverlay,
19
19
  } from './shared.js';
20
20
  import { renderChart } from './chart-renderer.js';
21
21
  import { dispatchShare } from './share-handlers.js';
22
- import { renderProse } from './prose.js';
22
+ import { renderProse, mountEditableProse } from './prose.js';
23
23
  import { openAddMdModal, openAddRecipeModal } from './import-modals.js';
24
24
  import { extractCellsFromRecipe, extractCellFromMarkdown } from './resource-extractor.js';
25
25
  import { mountLeftPane } from './left-pane.js';
@@ -106,8 +106,11 @@ export async function render(container: HTMLElement, data: Record<string, unknow
106
106
  });
107
107
  }
108
108
 
109
+ const hideLiveToggle = (data as any).hideLiveToggle === true;
110
+
109
111
  function renderLiveToggle() {
110
112
  const slot = shell.querySelector('.nbe-live-toggle-slot') as HTMLElement;
113
+ if (hideLiveToggle) { slot.innerHTML = ''; return; }
111
114
  if (state.mode === 'edit') {
112
115
  const checked = state.autoRun === true ? 'checked' : '';
113
116
  slot.innerHTML = `<label class="nbe-live-toggle" title="Re-execute SQL cells against connected servers when this notebook is opened in view mode."><input type="checkbox" ${checked} />Live data</label>`;
@@ -185,11 +188,13 @@ export async function render(container: HTMLElement, data: Record<string, unknow
185
188
  }
186
189
 
187
190
  function rerender() {
191
+ const restore = preserveScrollAround(cellsEl);
188
192
  mountHistoryPanel(historyPanel, state, (snap) => { restoreCellFromSnapshot(state, snap); rerender(); });
189
193
  renderLiveToggle();
190
194
  renderLiveBadge();
191
195
  renderEmptyState();
192
196
  renderCells();
197
+ restore();
193
198
  }
194
199
 
195
200
  // Toolbar: direct add (prose/sql/js)
@@ -429,24 +434,13 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
429
434
  rendered.innerHTML = renderProse(cell.content || '');
430
435
  wrap.appendChild(rendered);
431
436
  } else {
432
- // Editor: plain textarea (markdown source) + rendered preview below
433
- const editor = document.createElement('textarea');
434
- editor.className = 'nbe-prose-edit';
435
- editor.value = cell.content || '';
436
- editor.rows = 2;
437
- editor.placeholder = 'write prose (markdown)…';
438
- editor.spellcheck = true;
439
- editor.addEventListener('input', () => {
440
- cell.content = editor.value;
441
- autosize(editor);
442
- preview.innerHTML = renderProse(cell.content || '');
437
+ // Inline WYSIWYG single contenteditable zone, floating toolbar on select.
438
+ const editor = mountEditableProse({
439
+ getContent: () => cell.content || '',
440
+ setContent: (md) => { cell.content = md; },
441
+ onChange: () => { state.lastEditAt = Date.now(); },
443
442
  });
444
- const preview = document.createElement('div');
445
- preview.className = 'nbe-prose nbe-prose-render';
446
- preview.innerHTML = renderProse(cell.content || '');
447
- wrap.appendChild(editor);
448
- wrap.appendChild(preview);
449
- requestAnimationFrame(() => requestAnimationFrame(() => autosize(editor)));
443
+ wrap.appendChild(editor.el);
450
444
  }
451
445
  return wrap;
452
446
  }