@jxsuite/studio 0.33.0 → 0.35.0

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.
Files changed (106) hide show
  1. package/dist/iframe-entry.js +6238 -0
  2. package/dist/iframe-entry.js.map +35 -0
  3. package/dist/studio.js +30972 -17222
  4. package/dist/studio.js.map +328 -83
  5. package/package.json +9 -9
  6. package/src/browse/browse.ts +11 -4
  7. package/src/canvas/canvas-helpers.ts +2 -56
  8. package/src/canvas/canvas-live-render.ts +102 -435
  9. package/src/canvas/canvas-origin.ts +66 -0
  10. package/src/canvas/canvas-patcher.ts +63 -403
  11. package/src/canvas/canvas-render.ts +70 -212
  12. package/src/canvas/canvas-utils.ts +37 -65
  13. package/src/canvas/iframe-channel.ts +154 -0
  14. package/src/canvas/iframe-drop.ts +484 -0
  15. package/src/canvas/iframe-entry.ts +600 -0
  16. package/src/canvas/iframe-host.ts +1373 -0
  17. package/src/canvas/iframe-inline-edit.ts +367 -0
  18. package/src/canvas/iframe-insert.ts +164 -0
  19. package/src/canvas/iframe-interaction.ts +176 -0
  20. package/src/canvas/iframe-keys.ts +85 -0
  21. package/src/canvas/iframe-overlay.ts +218 -0
  22. package/src/canvas/iframe-patch.ts +363 -0
  23. package/src/canvas/iframe-protocol.ts +361 -0
  24. package/src/canvas/iframe-render.ts +458 -0
  25. package/src/canvas/iframe-slash.ts +114 -0
  26. package/src/canvas/iframe-subtree.ts +113 -0
  27. package/src/canvas/path-mapping.ts +86 -0
  28. package/src/canvas/serialize-scope.ts +65 -0
  29. package/src/editor/canvas-context-menu.ts +40 -0
  30. package/src/editor/canvas-slash-bridge.ts +21 -0
  31. package/src/editor/context-menu.ts +2 -1
  32. package/src/editor/inline-edit-apply.ts +183 -0
  33. package/src/editor/inline-edit.ts +99 -21
  34. package/src/editor/inline-link.ts +89 -0
  35. package/src/editor/insert-zone-action.ts +35 -0
  36. package/src/editor/merge-tags.ts +26 -2
  37. package/src/editor/repeater-scope.ts +144 -0
  38. package/src/editor/shortcuts.ts +14 -28
  39. package/src/editor/slash-menu.ts +73 -42
  40. package/src/files/files.ts +5 -1
  41. package/src/new-project/design-fields.ts +260 -0
  42. package/src/new-project/import-tab.ts +322 -0
  43. package/src/new-project/new-project-modal.ts +472 -89
  44. package/src/new-project/templates.ts +61 -0
  45. package/src/packages/ensure-deps.ts +6 -0
  46. package/src/packages/pull-package-sync.ts +339 -0
  47. package/src/page-params.ts +383 -0
  48. package/src/panels/ai-chat/attached-context.ts +49 -0
  49. package/src/panels/ai-chat/chat-markdown.ts +38 -0
  50. package/src/panels/ai-chat/chat-view.ts +250 -0
  51. package/src/panels/ai-chat/composer.ts +315 -0
  52. package/src/panels/ai-chat/sessions-view.ts +98 -0
  53. package/src/panels/ai-panel.ts +189 -457
  54. package/src/panels/block-action-bar.ts +296 -138
  55. package/src/panels/canvas-dnd-bridge.ts +397 -0
  56. package/src/panels/component-preview.ts +56 -0
  57. package/src/panels/dnd.ts +41 -17
  58. package/src/panels/drag-ghost.ts +62 -0
  59. package/src/panels/editors.ts +1 -1
  60. package/src/panels/git-panel.ts +16 -1
  61. package/src/panels/overlays.ts +10 -125
  62. package/src/panels/properties-panel.ts +210 -0
  63. package/src/panels/right-panel.ts +21 -7
  64. package/src/panels/signals-panel.ts +136 -22
  65. package/src/panels/stylebook-doc.ts +373 -0
  66. package/src/panels/stylebook-panel.ts +46 -689
  67. package/src/panels/tab-bar.ts +159 -13
  68. package/src/panels/toolbar.ts +3 -2
  69. package/src/platforms/devserver.ts +45 -1
  70. package/src/services/agent-seed.ts +91 -0
  71. package/src/services/ai-models.ts +64 -0
  72. package/src/services/ai-session-store.ts +250 -0
  73. package/src/services/ai-settings.ts +5 -0
  74. package/src/services/automation.ts +140 -0
  75. package/src/services/document-assistant.ts +98 -38
  76. package/src/services/import-client.ts +134 -0
  77. package/src/services/monaco-setup.ts +12 -0
  78. package/src/services/render-critic.ts +9 -9
  79. package/src/services/settings-store.ts +93 -0
  80. package/src/settings/css-vars-editor.ts +2 -2
  81. package/src/store.ts +4 -62
  82. package/src/studio.ts +115 -40
  83. package/src/tabs/doc-op-apply.ts +89 -0
  84. package/src/tabs/patch-ops.ts +6 -2
  85. package/src/tabs/tab.ts +23 -4
  86. package/src/tabs/transact.ts +2 -74
  87. package/src/types.ts +98 -18
  88. package/src/ui/ai-credentials-form.ts +205 -0
  89. package/src/ui/jx-theme.ts +63 -0
  90. package/src/ui/media-picker.ts +6 -4
  91. package/src/ui/spectrum.ts +13 -0
  92. package/src/utils/canvas-media.ts +0 -137
  93. package/src/utils/edit-display.ts +23 -3
  94. package/src/utils/geometry.ts +43 -0
  95. package/src/utils/link-target.ts +93 -0
  96. package/src/utils/strip-events.ts +54 -0
  97. package/src/view.ts +0 -23
  98. package/src/workspace/workspace.ts +14 -1
  99. package/src/canvas/canvas-subtree-render.ts +0 -113
  100. package/src/editor/component-inline-edit.ts +0 -349
  101. package/src/editor/content-inline-edit.ts +0 -207
  102. package/src/editor/insertion-helper.ts +0 -308
  103. package/src/panels/canvas-dnd.ts +0 -329
  104. package/src/panels/panel-events.ts +0 -306
  105. package/src/panels/preview-render.ts +0 -132
  106. package/src/panels/pseudo-preview.ts +0 -75
@@ -0,0 +1,1373 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Parent-side iframe canvas host — manages the `<iframe>` that renders a panel's document. It keeps
4
+ * one iframe per canvas element (reused across re-renders), resolves the document parent-side via
5
+ * {@link resolveCanvasDocument}, and posts it over the authenticated channel once the iframe
6
+ * signals `ready`. The parent never reads the iframe's DOM (cross-origin bridge model); it only
7
+ * sends.
8
+ */
9
+
10
+ import { postMessageChannel } from "./iframe-channel";
11
+ import { canvasBaseOrigin } from "./canvas-origin";
12
+ import { resolveCanvasDocument } from "./canvas-live-render";
13
+ import {
14
+ applyInlineCommit,
15
+ applyInlineInsert,
16
+ applyInlineSplit,
17
+ } from "../editor/inline-edit-apply";
18
+ import { canvasRectToParent, createOverlayLayer } from "./iframe-overlay";
19
+ import { serializeJxPath } from "./path-mapping";
20
+ import { getActivePanel, panelMediaToActiveMedia } from "./canvas-helpers";
21
+ import { panToParentRect } from "./canvas-utils";
22
+ import { clearDragGhost, moveDragGhost } from "../panels/drag-ghost";
23
+ import { applyDropInstruction } from "../panels/dnd";
24
+ import { rectOf } from "../utils/geometry";
25
+ import { effect, effectScope } from "../reactivity";
26
+ import { canvasPanels, canvasWrap, pathsEqual, renderOnly, updateCanvas, updateUi } from "../store";
27
+ import { activeTab, workspace } from "../workspace/workspace";
28
+ import { getPlatform, hasPlatform } from "../platform";
29
+ import type {
30
+ ApplyFormatIntent,
31
+ CanvasMode,
32
+ DragSrcKind,
33
+ IframeToParent,
34
+ InsertZone,
35
+ NodeHit,
36
+ ParentToIframe,
37
+ SelectionSnapshot,
38
+ SerializedKey,
39
+ WireDocOp,
40
+ } from "./iframe-protocol";
41
+ import type { IframeChannel } from "./iframe-channel";
42
+ import type { OverlayLayer, OverlayPlacement } from "./iframe-overlay";
43
+ import type { SlashCommand } from "../editor/inline-edit";
44
+ import type { Tab } from "../tabs/tab";
45
+ import type { JxMutableNode } from "@jxsuite/schema/types";
46
+
47
+ /** A rect in PARENT coordinates (overlay-local from {@link canvasRectToParent}, same field shape). */
48
+ type ParentRect = OverlayPlacement;
49
+
50
+ interface HostState {
51
+ iframe: HTMLIFrameElement;
52
+ channel: IframeChannel<ParentToIframe, IframeToParent>;
53
+ /**
54
+ * The resolved canvasUrl this iframe was built with — so a host built early with the default URL
55
+ * is rebuilt once the platform's loopback canvasUrl becomes available (electrobun resolves it
56
+ * async).
57
+ */
58
+ canvasUrl: string;
59
+ ready: boolean;
60
+ pending: ParentToIframe | null;
61
+ overlay: OverlayLayer;
62
+ /** Document path of the current selection (mirrors `session.selection`), for hover de-dupe. */
63
+ selectionPath: (string | number)[] | null;
64
+ /** Id of the most recent selection `measure` request, so stale `geometry` replies are dropped. */
65
+ selReqId: number;
66
+ /** Whether an inline-edit session is live in this host's iframe (drives the format toolbar). */
67
+ editing: boolean;
68
+ /** The latest selection snapshot from this host's iframe (active tags + caret rect + link). */
69
+ snapshot: SelectionSnapshot | null;
70
+ /** Highest snapshot `seq` seen, so stale (re-ordered) snapshots are dropped. */
71
+ lastSnapshotSeq: number;
72
+ /** The last non-null selection rect drawn (parent/overlay coords) — toolbar position fallback. */
73
+ lastSelectionRect: ParentRect | null;
74
+ /**
75
+ * The gen of the render/patch this iframe's DOM currently reflects (set from `renderComplete`/
76
+ * `patchComplete`). Cross-frame drag replies (`dragOver`/`dropResult`) are dropped unless their
77
+ * `gen` matches, so a drop computed against a superseded render is never applied (Phase 4c).
78
+ */
79
+ lastRenderedGen: number;
80
+ /**
81
+ * The insertion "+" zone the overlay button currently anchors to (the iframe posts it on hover);
82
+ * captured here so the button's click handler runs the slash-menu → mutateInsertNode flow against
83
+ * the same parentPath/index. Null when no "+" is shown.
84
+ */
85
+ insertZone: InsertZone | null;
86
+ /**
87
+ * Grace timer (SALVAGED HIDE_DELAY) so a `null` zone post (cursor crossed mid-element on its way
88
+ * to the button) doesn't yank the "+" before the author reaches it; cancelled on re-show / button
89
+ * mouseenter. Null when not pending.
90
+ */
91
+ insertHideTimer: ReturnType<typeof setTimeout> | null;
92
+ /**
93
+ * Id of the tab whose document this host's iframe DOM currently reflects. Adopted from
94
+ * {@link HostState.pendingTabIds} ONLY when the render acks (`renderComplete`), so on the FIFO
95
+ * channel any edit-session commit the iframe posts ahead of that ack still routes to the tab the
96
+ * session belonged to. Doc-mutating bridge messages (editCommit/editSplit/editInsert/dropResult)
97
+ * resolve their target tab from THIS — never from `activeTab` at message time, which may have
98
+ * changed while the message was in flight (the cross-document bleed).
99
+ */
100
+ tabId: string | null;
101
+ /** Tab id keyed by render gen for posted-but-unacked renders; adopted on `renderComplete`. */
102
+ pendingTabIds: Map<number, string | null>;
103
+ /**
104
+ * A split/insert re-entry deferred until this host's DOM contains the new element: a surgical
105
+ * patch acks (`patchComplete`) at the SAME gen the host already reflects, an escalated full
106
+ * render acks (`renderComplete`) at a bumped one — both satisfy `gen >= minGen`. An immediate
107
+ * `enterEdit` would race the escalated ASYNC render and silently fail to find the element.
108
+ */
109
+ pendingEnterEdit: { path: (string | number)[]; minGen: number } | null;
110
+ /**
111
+ * Stylebook capability (set by {@link mountStylebookCanvas}, cleared by page mounts). The specimen
112
+ * doc's paths decode to TAGS, not tab-document paths: hits route to the injected stylebook
113
+ * handler instead of `session.selection`, the selection watcher measures the selected tag's card,
114
+ * and the document-editing affordances (insert "+", context menu, grab-drags) are ignored for
115
+ * this host.
116
+ */
117
+ stylebook: {
118
+ pathToTag: ReadonlyMap<string, string>;
119
+ tagToCardPath: ReadonlyMap<string, (string | number)[]>;
120
+ } | null;
121
+ /** Measure-request id of an in-flight pan-to-card (stylebook); -1 when none. */
122
+ panReqId: number;
123
+ }
124
+
125
+ /**
126
+ * Opaque drag-target host handle for the coordinator bridge. The bridge passes it back to the
127
+ * session API ({@link beginDragSession}/{@link hostDragGeometry}/{@link postDragMessage}) without
128
+ * reading its fields.
129
+ */
130
+ export type DragHost = HostState;
131
+
132
+ const hosts = new WeakMap<HTMLElement, HostState>();
133
+
134
+ /** Every live host, so the selection watcher can re-measure each one when selection changes. */
135
+ const liveHosts = new Set<HostState>();
136
+
137
+ // ─── Cross-frame drag session (Phase 4c) ────────────────────────────────────────
138
+ // The parent owns the drag session id and the source data (which never crosses the wire). The
139
+ // Coordinator bridge drives the session through the exported API below; the host's dragOver/dropResult
140
+ // Handlers stale-gate replies by `currentDragSeq` and the target host's `lastRenderedGen`.
141
+
142
+ /** Monotonic session id, bumped on each {@link beginDragSession}. Stale replies carry an older seq. */
143
+ let currentDragSeq = 0;
144
+
145
+ /** Parent-retained source data keyed by `dragSeq` (the block fragment never crosses the boundary). */
146
+ const retainedSrcData = new Map<number, Record<string, unknown>>();
147
+
148
+ /**
149
+ * The last session whose cursor-carrying `dragOver` arrived FROM an iframe, and when. While a
150
+ * parent-originated drag is over the canvas, the native dragover/drop stream is delivered to the
151
+ * IFRAME (not the parent), so the iframe drives the session's dragOvers and its native drop posts
152
+ * the dropResult. The coordinator checks {@link sawIframeDragOver} in its `onDrop` to know the
153
+ * parent's own drop location is frame-local garbage and the in-flight dropResult is authoritative.
154
+ */
155
+ let lastIframeDragOverSeq = -1;
156
+ let lastIframeDragOverAt = 0;
157
+
158
+ /**
159
+ * How recently (ms) an iframe-driven dragOver still marks the iframe as owning the stream. Native
160
+ * dragover re-fires ~350ms even with a stationary cursor, so an iframe the cursor is still over
161
+ * stays fresh; once the cursor moves back to parent chrome (e.g. drops on the block-action-bar,
162
+ * which overlays the iframe) the iframe stream stops and the parent's drop coords are trusted
163
+ * again.
164
+ */
165
+ const IFRAME_DRAGOVER_FRESH_MS = 600;
166
+
167
+ /** Whether the iframe recently drove a `dragOver` for session `seq` (see above). */
168
+ export function sawIframeDragOver(seq: number): boolean {
169
+ return (
170
+ lastIframeDragOverSeq === seq && Date.now() - lastIframeDragOverAt <= IFRAME_DRAGOVER_FRESH_MS
171
+ );
172
+ }
173
+
174
+ /** The host backing a given canvas element (or null), for the coordinator's host resolution. */
175
+ export function hostForCanvas(canvasEl: HTMLElement): HostState | null {
176
+ return hosts.get(canvasEl) ?? null;
177
+ }
178
+
179
+ /**
180
+ * Resolve the live host whose iframe's parent-viewport rect ({@link rectOf}) contains `cursor`.
181
+ * Used by the coordinator to pick the drop target by pointer position (NOT the active panel), so a
182
+ * drag over any panel's canvas targets that panel. Returns null when the cursor is over no live
183
+ * iframe.
184
+ */
185
+ export function liveDragHostAt(cursor: { x: number; y: number }): HostState | null {
186
+ for (const host of liveHosts) {
187
+ if (!host.iframe.isConnected) {
188
+ continue;
189
+ }
190
+ const r = rectOf(host.iframe);
191
+ if (cursor.x >= r.left && cursor.x <= r.right && cursor.y >= r.top && cursor.y <= r.bottom) {
192
+ return host;
193
+ }
194
+ }
195
+ return null;
196
+ }
197
+
198
+ /**
199
+ * Begin a drag session against `host`: bump the session id, retain `srcData` (keyed by the new
200
+ * seq), and post `dragStart` with the source kind and the host's current rendered gen. Returns the
201
+ * seq so the coordinator can tag subsequent move/drop posts.
202
+ */
203
+ export function beginDragSession(
204
+ host: HostState,
205
+ src: DragSrcKind,
206
+ srcData: Record<string, unknown>,
207
+ ): number {
208
+ currentDragSeq += 1;
209
+ retainedSrcData.set(currentDragSeq, srcData);
210
+ // The src crosses postMessage: plain-copy the path so a reactive-proxy array (e.g. a live
211
+ // Selection fed through a drag source) can't DataCloneError the structured clone. Test fake
212
+ // Channels pass messages by reference and never exercise the clone, so guard at the boundary.
213
+ const wireSrc: DragSrcKind =
214
+ src.type === "tree-node" ? { path: [...src.path], type: "tree-node" } : { type: src.type };
215
+ host.channel.post({
216
+ dragSeq: currentDragSeq,
217
+ gen: host.lastRenderedGen,
218
+ kind: "dragStart",
219
+ src: wireSrc,
220
+ });
221
+ return currentDragSeq;
222
+ }
223
+
224
+ /**
225
+ * Adopt an IFRAME-DRIVEN (flow 3) drag session: the iframe already owns the pointer it started and
226
+ * drives the whole gesture, so the parent does NOT post a `dragStart` — it only sets the
227
+ * authoritative `currentDragSeq` to the iframe's seq and retains the (path-only) source data keyed
228
+ * by it, so the iframe's `dragOver`/`dropResult` (which carry that same seq) pass the parent's seq
229
+ * gate and the drop applies with the retained source. Returns the adopted seq.
230
+ */
231
+ export function adoptDragSession(
232
+ _host: HostState,
233
+ _src: DragSrcKind,
234
+ srcData: Record<string, unknown>,
235
+ seq: number,
236
+ ): number {
237
+ currentDragSeq = seq;
238
+ retainedSrcData.set(seq, srcData);
239
+ return seq;
240
+ }
241
+
242
+ /** The current drag session id, for the coordinator to tag its move/drop posts. */
243
+ export function currentDragSession(): number {
244
+ return currentDragSeq;
245
+ }
246
+
247
+ /**
248
+ * The EMPIRICAL zoom scale for `host`'s iframe — `rectOf(iframe).width / iframe.clientWidth` (D-2),
249
+ * read FRESH so a pan/zoom mid-drag is reflected. NOT `effectiveZoom()` (a separate path that can
250
+ * desync). The matching iframe parent-viewport rect is returned so the cursor map cancels the pan.
251
+ */
252
+ export function hostDragGeometry(host: HostState): {
253
+ scale: number;
254
+ rect: { left: number; top: number };
255
+ } {
256
+ const rect = rectOf(host.iframe);
257
+ const scale = host.iframe.clientWidth > 0 ? rect.width / host.iframe.clientWidth : 1;
258
+ return { rect, scale };
259
+ }
260
+
261
+ /** Post a parent→iframe drag message to `host`'s channel (the coordinator builds it purely). */
262
+ export function postDragMessage(host: HostState, msg: ParentToIframe): void {
263
+ if (!host.iframe.isConnected) {
264
+ return;
265
+ }
266
+ host.channel.post(msg);
267
+ }
268
+
269
+ /** Abandon the current session's retained source data (e.g. a drop landed off-canvas). */
270
+ export function endDragSession(dragSeq: number): void {
271
+ retainedSrcData.delete(dragSeq);
272
+ }
273
+
274
+ /** Hide `host`'s drop indicator (the coordinator's timeout fallback when no dropResult arrives). */
275
+ export function clearDropIndicator(host: HostState): void {
276
+ host.overlay.setDropIndicator(null);
277
+ }
278
+
279
+ /**
280
+ * The coordinator's handler for an iframe-originated (flow 3) drag. Installed by the bridge to
281
+ * avoid a host→bridge import cycle; invoked from the `dragOriginate` message case with the host,
282
+ * the grabbed node path, AND the iframe's pre-allocated dragSeq. The iframe DRIVES the gesture
283
+ * itself (its held-button moves stay in the iframe document), so the coordinator only ADOPTS this
284
+ * seq (no dragStart, no parent-document listeners) and shows the ghost — the iframe's
285
+ * dragOver/dropResult carry the same seq and so pass the parent's seq gate.
286
+ */
287
+ let iframeOriginateHandler:
288
+ | ((host: HostState, path: (string | number)[], dragSeq: number) => void)
289
+ | null = null;
290
+
291
+ /** Register the coordinator's iframe-originated-drag handler (see {@link iframeOriginateHandler}). */
292
+ export function setIframeOriginateHandler(
293
+ fn: (host: HostState, path: (string | number)[], dragSeq: number) => void,
294
+ ): void {
295
+ iframeOriginateHandler = fn;
296
+ }
297
+
298
+ /**
299
+ * The coordinator's handler for a NATIVE drag stream entering an iframe with no session bound there
300
+ * (the `nativeDragEnter` message) — the bridge binds/migrates its live pragmatic session to that
301
+ * host. Installed by the bridge to avoid a host→bridge import cycle.
302
+ */
303
+ let nativeDragEnterHandler: ((host: HostState) => void) | null = null;
304
+
305
+ /** Register the coordinator's native-drag-enter handler (see {@link nativeDragEnterHandler}). */
306
+ export function setNativeDragEnterHandler(fn: (host: HostState) => void): void {
307
+ nativeDragEnterHandler = fn;
308
+ }
309
+
310
+ /** Delay (ms) before hiding the insertion "+" so the cursor can reach it (SALVAGED HIDE_DELAY). */
311
+ const INSERT_HIDE_DELAY = 300;
312
+
313
+ /**
314
+ * The parent-realm insertion handler: open the slash menu anchored at the "+" `btn` and, on select,
315
+ * run `transactDoc → mutateInsertNode` for the captured `zone`. Injected from studio.ts (which owns
316
+ * the slash-menu / transact / defaultDef wiring) so this host module — and its tests — stay free of
317
+ * the lit/Spectrum slash-menu and the mutation pipeline, mirroring {@link iframeOriginateHandler}.
318
+ */
319
+ let insertZoneClickHandler: ((btn: HTMLElement, zone: InsertZone) => void) | null = null;
320
+
321
+ /** Register the slash-menu → mutateInsertNode handler the insertion "+" runs on click. */
322
+ export function setInsertZoneClickHandler(fn: (btn: HTMLElement, zone: InsertZone) => void): void {
323
+ insertZoneClickHandler = fn;
324
+ }
325
+
326
+ /** A canvas-originated slash-menu request, converted to PARENT-VIEWPORT coords by the host. */
327
+ export interface CanvasSlashRequest {
328
+ rect: { left: number; top: number; bottom: number; width: number; height: number };
329
+ filter: string;
330
+ onSelect: (cmd: SlashCommand) => void;
331
+ onDismiss: () => void;
332
+ }
333
+
334
+ /**
335
+ * The parent-realm slash-menu surface the canvas iframe drives (show at a rect, navigate by key,
336
+ * dismiss). Injected from studio.ts (which owns the lit/Spectrum menu) so this host module — and
337
+ * its tests — stay free of it, mirroring {@link insertZoneClickHandler}.
338
+ */
339
+ export interface CanvasSlashHandler {
340
+ show: (req: CanvasSlashRequest) => void;
341
+ nav: (key: string) => void;
342
+ dismiss: () => void;
343
+ }
344
+
345
+ let canvasSlashHandler: CanvasSlashHandler | null = null;
346
+
347
+ /** Register the parent-realm slash-menu surface the canvas iframe drives. */
348
+ export function setCanvasSlashHandler(handler: CanvasSlashHandler): void {
349
+ canvasSlashHandler = handler;
350
+ }
351
+
352
+ /**
353
+ * The parent-realm context-menu surface for canvas right-clicks (show at parent-viewport coords,
354
+ * dismiss on a canvas left-click). Injected from studio.ts, same DI pattern as the slash handler.
355
+ */
356
+ export interface CanvasContextMenuHandler {
357
+ show: (args: { path: (string | number)[] | null; clientX: number; clientY: number }) => void;
358
+ dismiss: () => void;
359
+ }
360
+
361
+ let canvasContextMenuHandler: CanvasContextMenuHandler | null = null;
362
+
363
+ /** Register the parent-realm context-menu surface the canvas iframe drives. */
364
+ export function setCanvasContextMenuHandler(handler: CanvasContextMenuHandler): void {
365
+ canvasContextMenuHandler = handler;
366
+ }
367
+
368
+ /**
369
+ * The parent-realm stylebook selection handler: a hit in a stylebook host decodes to a TAG (or null
370
+ * for chrome/empty space) plus the clicked panel's media, and routes here — never to
371
+ * `session.selection`. Injected from studio.ts (which owns selectStylebookTag), same DI pattern as
372
+ * the slash/context handlers.
373
+ */
374
+ let stylebookHitHandler: ((tag: string | null, media: string | null) => void) | null = null;
375
+
376
+ /** Register the stylebook tag-selection handler stylebook hosts route hits to. */
377
+ export function setStylebookHitHandler(
378
+ fn: (tag: string | null, media: string | null) => void,
379
+ ): void {
380
+ stylebookHitHandler = fn;
381
+ }
382
+
383
+ /**
384
+ * Decode a stylebook hit path to its tag/compound: exact `pathToTag` lookup, then trim trailing
385
+ * path segments pairwise ("children", i) so a hit on an unmapped descendant resolves to its nearest
386
+ * mapped ancestor. Null = chrome/empty space (deselect).
387
+ */
388
+ function resolveStylebookTag(
389
+ pathToTag: ReadonlyMap<string, string>,
390
+ path: (string | number)[],
391
+ ): string | null {
392
+ let p = [...path];
393
+ for (;;) {
394
+ const tag = pathToTag.get(serializeJxPath(p));
395
+ if (tag) {
396
+ return tag;
397
+ }
398
+ if (p.length < 2) {
399
+ return null;
400
+ }
401
+ p = p.slice(0, -2);
402
+ }
403
+ }
404
+
405
+ /**
406
+ * The single host whose iframe currently owns the inline-edit session. Only one editable can be
407
+ * active across all panels, so the parent format toolbar reads/writes through this (fixes the
408
+ * multi-panel bug where two hosts' editing state could fight).
409
+ */
410
+ let activeEditHost: HostState | null = null;
411
+
412
+ /** Injected toolbar re-render (set by studio.ts → renderBlockActionBar); avoids a panel→host cycle. */
413
+ let toolbarRefresh: (() => void) | null = null;
414
+
415
+ /** Register the parent toolbar's refresh fn (mirrors {@link setIframePatchEscalation}). */
416
+ export function setToolbarRefresh(fn: () => void): void {
417
+ toolbarRefresh = fn;
418
+ }
419
+
420
+ let selectionWatch: { stop: () => void } | null = null;
421
+
422
+ /** Full-render escalation, injected by studio init (a patchError can't apply surgically). */
423
+ let patchEscalation: (() => void) | null = null;
424
+
425
+ /** Register the full-render fallback the host invokes when the iframe reports a `patchError`. */
426
+ export function setIframePatchEscalation(fn: () => void): void {
427
+ patchEscalation = fn;
428
+ }
429
+
430
+ /**
431
+ * Post a surgical patch (value-carrying forward ops) to every ready live iframe host rendering
432
+ * `tabId`'s document — a still-connected host showing another tab's doc must never fold a foreign
433
+ * edit into its shadow doc. Returns how many hosts received it; the caller escalates to a full
434
+ * render when that's zero (no host could apply the edit in place, so the suppressed full render
435
+ * must run after all).
436
+ */
437
+ export function postPatchToHosts(
438
+ forwardOps: WireDocOp[],
439
+ gen: number,
440
+ tabId: string | null,
441
+ ): number {
442
+ let posted = 0;
443
+ for (const host of liveHosts) {
444
+ if (!host.iframe.isConnected) {
445
+ liveHosts.delete(host);
446
+ continue;
447
+ }
448
+ if (host.ready && host.tabId === tabId) {
449
+ host.channel.post({ forwardOps, gen, kind: "patch" });
450
+ posted += 1;
451
+ }
452
+ }
453
+ return posted;
454
+ }
455
+
456
+ /** The tab whose document `state`'s iframe currently renders (null when unknown or closed). */
457
+ function hostTab(state: HostState): Tab | null {
458
+ return state.tabId ? (workspace.tabs.get(state.tabId) ?? null) : null;
459
+ }
460
+
461
+ /** Lazily start one reactive watcher that re-measures the selection in every live iframe host. */
462
+ function ensureSelectionWatch(): void {
463
+ if (selectionWatch) {
464
+ return;
465
+ }
466
+ const scope = effectScope(true);
467
+ scope.run(() => {
468
+ effect(() => {
469
+ const sel = activeTab.value?.session.selection ?? null;
470
+ // Track the stylebook selection too: stylebook hosts measure the selected TAG's card
471
+ // (session.selection is deliberately [] in stylebook mode).
472
+ void activeTab.value?.session.ui.stylebookSelection;
473
+ for (const host of liveHosts) {
474
+ requestSelection(host, sel);
475
+ }
476
+ });
477
+ });
478
+ selectionWatch = { stop: () => scope.stop() };
479
+ }
480
+
481
+ /** Track the selection on a host and ask its iframe to measure it (or clear the box when null). */
482
+ function requestSelection(host: HostState, sel: (string | number)[] | null): void {
483
+ if (host.stylebook) {
484
+ requestStylebookSelection(host);
485
+ return;
486
+ }
487
+ host.selectionPath = sel;
488
+ if (!host.iframe.isConnected) {
489
+ liveHosts.delete(host);
490
+ return;
491
+ }
492
+ if (!sel) {
493
+ host.overlay.setSelection(null);
494
+ return;
495
+ }
496
+ if (!host.ready) {
497
+ return;
498
+ }
499
+ host.selReqId += 1;
500
+ // Post a plain copy: `session.selection` is a reactive proxy, and only serializable values may
501
+ // Cross the postMessage boundary.
502
+ host.channel.post({ kind: "measure", paths: [[...sel]], reqId: host.selReqId });
503
+ }
504
+
505
+ /**
506
+ * Stylebook variant of {@link requestSelection}: measure the SELECTED TAG's card (the specimen doc
507
+ * addresses selection by tag, not by tab-document path). Cleared when no tag is selected or the tag
508
+ * has no card in this host's generated doc.
509
+ */
510
+ function requestStylebookSelection(host: HostState): void {
511
+ if (!host.iframe.isConnected) {
512
+ liveHosts.delete(host);
513
+ return;
514
+ }
515
+ const tag = activeTab.value?.session.ui.stylebookSelection ?? null;
516
+ const cardPath = tag ? host.stylebook?.tagToCardPath.get(tag) : undefined;
517
+ if (!tag || !cardPath) {
518
+ host.overlay.setSelection(null);
519
+ return;
520
+ }
521
+ if (!host.ready) {
522
+ return;
523
+ }
524
+ host.selReqId += 1;
525
+ host.channel.post({ kind: "measure", paths: [[...cardPath]], reqId: host.selReqId });
526
+ }
527
+
528
+ /**
529
+ * The default iframe document URL (a static shell that boots the slim canvas bundle). Used when the
530
+ * platform does not provide its own canvasUrl — i.e. the dev server and electrobun keep this path.
531
+ */
532
+ const DEFAULT_CANVAS_URL = "/packages/studio/canvas.html";
533
+
534
+ function ensureHost(canvasEl: HTMLElement): HostState {
535
+ // Read the platform's canvasUrl when one is registered; otherwise fall back to the default. The
536
+ // Dev server leaves it unset, and some tests mount without a platform registered.
537
+ const canvasUrl = (hasPlatform() ? getPlatform().canvasUrl : undefined) ?? DEFAULT_CANVAS_URL;
538
+ const existing = hosts.get(canvasEl);
539
+ if (existing) {
540
+ if (existing.canvasUrl === canvasUrl) {
541
+ return existing;
542
+ }
543
+ // The platform's loopback canvasUrl arrived after this host was built with the default URL
544
+ // (electrobun resolves it async over RPC) — tear the early iframe down and rebuild against the
545
+ // Right cross-origin origin.
546
+ existing.channel.dispose();
547
+ liveHosts.delete(existing);
548
+ hosts.delete(canvasEl);
549
+ }
550
+ // ParentOrigin is the parent's real origin, passed into the iframe URL so the cross-origin iframe
551
+ // Can target a postMessage back at it.
552
+ const parentOrigin = location.origin;
553
+ const token = crypto.randomUUID();
554
+ // IframeOrigin is the iframe's own origin. For a RELATIVE canvasUrl (dev / chromium) it resolves to
555
+ // The parent's location.origin (same-origin). For an absolute loopback canvasUrl (electrobun) it is
556
+ // The loopback origin, so the channel accepts/targets the right cross-origin peer.
557
+ const iframeOrigin = new URL(canvasUrl, location.href).origin;
558
+ const iframe = document.createElement("iframe");
559
+ iframe.className = "jx-canvas-iframe";
560
+ iframe.style.cssText =
561
+ "width:100%;min-height:480px;height:100%;border:0;display:block;background:#fff";
562
+ // Preserve any query already on canvasUrl (e.g. electrobun's ?win=7) and append the token (always)
563
+ // Plus parentOrigin (conditionally — see below).
564
+ const srcUrl = new URL(canvasUrl, location.href);
565
+ srcUrl.searchParams.set("token", token);
566
+ // Pass parentOrigin into the iframe src ONLY when the PARENT is served over http(s) (dev / chromium
567
+ // — same-origin, so the origin round-trips and the iframe can keep its acceptOrigin STRICT). For a
568
+ // Non-http(s) parent (electrobun views://) OMIT it: a custom scheme may not surface as a postMessage
569
+ // Origin (event.origin), so the iframe falls back to acceptOrigin '*' + the shared token (it logs
570
+ // The warn) rather than silently stalling. The PARENT side here stays STRICT — acceptOrigin /
571
+ // TargetOrigin are the real (loopback) iframeOrigin below, a gateable origin.
572
+ if (location.protocol === "http:" || location.protocol === "https:") {
573
+ srcUrl.searchParams.set("parentOrigin", parentOrigin);
574
+ }
575
+ // Keep a relative canvasUrl relative in the src attribute (emit only path+query, not the resolved
576
+ // Absolute URL) so the same-origin path stays byte-identical.
577
+ iframe.src = iframeOrigin === parentOrigin ? `${srcUrl.pathname}${srcUrl.search}` : srcUrl.href;
578
+ // Overlay boxes are positioned within the canvas element, so it must be a positioned ancestor.
579
+ if (!canvasEl.style.position) {
580
+ canvasEl.style.position = "relative";
581
+ }
582
+ const overlay = createOverlayLayer(document);
583
+ canvasEl.replaceChildren(iframe, overlay.root);
584
+ const channel = postMessageChannel<ParentToIframe, IframeToParent>({
585
+ acceptOrigin: iframeOrigin,
586
+ source: window,
587
+ // Read contentWindow lazily: a freshly-navigated iframe swaps its window, so never capture it.
588
+ target: {
589
+ postMessage: (message, targetOrigin) =>
590
+ iframe.contentWindow?.postMessage(message, targetOrigin),
591
+ },
592
+ targetOrigin: iframeOrigin,
593
+ token,
594
+ });
595
+
596
+ const state: HostState = {
597
+ canvasUrl,
598
+ channel,
599
+ editing: false,
600
+ iframe,
601
+ insertHideTimer: null,
602
+ insertZone: null,
603
+ lastRenderedGen: -1,
604
+ lastSelectionRect: null,
605
+ lastSnapshotSeq: 0,
606
+ overlay,
607
+ panReqId: -1,
608
+ pending: null,
609
+ pendingEnterEdit: null,
610
+ pendingTabIds: new Map(),
611
+ ready: false,
612
+ selectionPath: null,
613
+ selReqId: 0,
614
+ snapshot: null,
615
+ stylebook: null,
616
+ tabId: null,
617
+ };
618
+ // The insertion "+" lives on the overlay (the one pointer-events:auto element there). Clicking it
619
+ // Opens the slash menu → mutateInsertNode for the captured zone; mouseenter/leave drive the same
620
+ // Grace timer the zone posts use, so moving the cursor onto the button never drops it.
621
+ overlay.insertButton.addEventListener("click", (e) => onInsertButtonClick(state, e));
622
+ overlay.insertButton.addEventListener("mouseenter", () => cancelInsertHide(state));
623
+ overlay.insertButton.addEventListener("mouseleave", () => scheduleInsertHide(state));
624
+ channel.onMessage((msg) => handleMessage(state, msg));
625
+ hosts.set(canvasEl, state);
626
+ liveHosts.add(state);
627
+ ensureSelectionWatch();
628
+ return state;
629
+ }
630
+
631
+ // ─── Insertion "+" affordance (cross-origin) ────────────────────────────────────
632
+
633
+ /** Show the "+" for `zone`, capturing it on the host for the click handler; cancels any hide. */
634
+ function showInsertZone(state: HostState, zone: InsertZone): void {
635
+ cancelInsertHide(state);
636
+ state.insertZone = zone;
637
+ state.overlay.setInsertZone(canvasRectToParent(zone.rect), zone.edge);
638
+ }
639
+
640
+ /** Hide the "+" immediately and forget the captured zone. */
641
+ function hideInsertZoneNow(state: HostState): void {
642
+ cancelInsertHide(state);
643
+ state.insertZone = null;
644
+ state.overlay.setInsertZone(null);
645
+ }
646
+
647
+ /** Arm the grace timer to hide the "+" (SALVAGED HIDE_DELAY), unless one is already pending. */
648
+ function scheduleInsertHide(state: HostState): void {
649
+ cancelInsertHide(state);
650
+ state.insertHideTimer = setTimeout(() => {
651
+ state.insertHideTimer = null;
652
+ hideInsertZoneNow(state);
653
+ }, INSERT_HIDE_DELAY);
654
+ }
655
+
656
+ /** Cancel a pending grace-timer hide of the "+". */
657
+ function cancelInsertHide(state: HostState): void {
658
+ if (state.insertHideTimer !== null) {
659
+ clearTimeout(state.insertHideTimer);
660
+ state.insertHideTimer = null;
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Click the "+": defer to the injected slash-menu → mutateInsertNode handler with the captured
666
+ * zone.
667
+ */
668
+ function onInsertButtonClick(state: HostState, e: MouseEvent): void {
669
+ e.stopPropagation();
670
+ e.preventDefault();
671
+ const zone = state.insertZone;
672
+ if (!zone) {
673
+ return;
674
+ }
675
+ insertZoneClickHandler?.(state.overlay.insertButton, zone);
676
+ }
677
+
678
+ /** Handle a message the iframe posted back: ready handshake, pointer hit/hover, measured geometry. */
679
+ function handleMessage(state: HostState, msg: IframeToParent): void {
680
+ switch (msg.kind) {
681
+ case "ready": {
682
+ state.ready = true;
683
+ if (state.pending) {
684
+ state.channel.post(state.pending);
685
+ state.pending = null;
686
+ }
687
+ // Re-measure the current selection now that the iframe can answer.
688
+ requestSelection(state, state.selectionPath);
689
+ return;
690
+ }
691
+ case "hit": {
692
+ // The clicked panel becomes the ACTIVE panel (same as clicking its header): getActivePanel(),
693
+ // Header highlighting, and the style panel's breakpoint context all follow the click — and the
694
+ // Block action bar anchors to the panel the selection was actually made in, not panel 0.
695
+ let panelMedia: string | null = null;
696
+ for (const p of canvasPanels) {
697
+ if (hosts.get(p.canvas as HTMLElement) === state) {
698
+ if (!p.mediaName?.startsWith("git-diff")) {
699
+ panelMedia = panelMediaToActiveMedia(p.mediaName);
700
+ updateUi("activeMedia", panelMedia);
701
+ }
702
+ break;
703
+ }
704
+ }
705
+ // A canvas left-click closes an open context menu — its parent-realm outside-click listener
706
+ // Can't see clicks inside the cross-origin iframe.
707
+ canvasContextMenuHandler?.dismiss();
708
+ // Stylebook host: the path decodes to a TAG (or null = deselect) and routes to the injected
709
+ // Handler — specimen paths are NOT tab-document paths, so session.selection stays untouched.
710
+ if (state.stylebook) {
711
+ const tag = resolveStylebookTag(state.stylebook.pathToTag, msg.hit.path);
712
+ stylebookHitHandler?.(tag, panelMedia);
713
+ if (tag) {
714
+ // Draw immediately from the posted rect; the selection watcher re-measures the CARD.
715
+ const rect = canvasRectToParent(msg.hit.rect);
716
+ state.overlay.setSelection(rect, `<${tag}>`);
717
+ state.lastSelectionRect = rect;
718
+ } else {
719
+ state.overlay.setSelection(null);
720
+ }
721
+ return;
722
+ }
723
+ // A click in the canvas selects the node; the selection watcher redraws the box via `measure`.
724
+ state.selectionPath = msg.hit.path;
725
+ const tab = activeTab.value;
726
+ if (tab) {
727
+ tab.session.selection = msg.hit.path;
728
+ }
729
+ // Draw immediately from the posted rect for snappiness (the measure round-trip confirms it).
730
+ {
731
+ const rect = canvasRectToParent(msg.hit.rect);
732
+ state.overlay.setSelection(rect);
733
+ state.lastSelectionRect = rect;
734
+ }
735
+ return;
736
+ }
737
+ case "hover": {
738
+ if (state.stylebook) {
739
+ // Decode to a tag; suppress the box when hovering the selected tag (legacy parity).
740
+ const tag = msg.hit ? resolveStylebookTag(state.stylebook.pathToTag, msg.hit.path) : null;
741
+ const selected = activeTab.value?.session.ui.stylebookSelection ?? null;
742
+ if (msg.hit && tag && tag !== selected) {
743
+ state.overlay.setHover(canvasRectToParent(msg.hit.rect));
744
+ } else {
745
+ state.overlay.setHover(null);
746
+ }
747
+ return;
748
+ }
749
+ drawHover(state, msg.hit);
750
+ return;
751
+ }
752
+ case "insertZones": {
753
+ // Document-editing affordance — never for specimen catalogs (belt-and-braces with the
754
+ // Iframe-side mode gate).
755
+ if (state.stylebook) {
756
+ return;
757
+ }
758
+ // The iframe recomputed the insertion "+" zones for the hovered node. Draw the "+" from the
759
+ // First zone's rect (scale=1, D-2 — the overlay is inside the scaled panzoom-wrap); a null/empty
760
+ // Set arms the grace timer rather than hiding immediately, so the cursor can reach the button.
761
+ const zone = msg.zones?.[0] ?? null;
762
+ if (zone) {
763
+ showInsertZone(state, zone);
764
+ } else {
765
+ scheduleInsertHide(state);
766
+ }
767
+ return;
768
+ }
769
+ case "geometry": {
770
+ // Pan-to-card reply (stylebook): convert the card's iframe rect to parent-viewport space by
771
+ // The empirical zoom + iframe offset and center it.
772
+ if (msg.reqId === state.panReqId) {
773
+ state.panReqId = -1;
774
+ const [hit] = msg.hits;
775
+ if (hit) {
776
+ const { rect: ifr, scale } = hostDragGeometry(state);
777
+ panToParentRect({ height: hit.rect.height * scale, top: hit.rect.y * scale + ifr.top });
778
+ }
779
+ return;
780
+ }
781
+ if (msg.reqId === state.selReqId) {
782
+ const [hit] = msg.hits;
783
+ const rect = hit ? canvasRectToParent(hit.rect) : null;
784
+ const sbTag = state.stylebook
785
+ ? (activeTab.value?.session.ui.stylebookSelection ?? null)
786
+ : null;
787
+ state.overlay.setSelection(rect, sbTag ? `<${sbTag}>` : null);
788
+ if (rect) {
789
+ state.lastSelectionRect = rect;
790
+ }
791
+ }
792
+ return;
793
+ }
794
+ case "renderComplete": {
795
+ // Adopt the tab identity this render was mounted with. host.tabId flips ONLY here — after any
796
+ // Edit-session commit the iframe posted ahead of this ack on the FIFO channel — so a commit
797
+ // Racing a tab switch still routes to the tab its session belonged to.
798
+ if (state.pendingTabIds.has(msg.gen)) {
799
+ state.tabId = state.pendingTabIds.get(msg.gen) ?? null;
800
+ }
801
+ for (const gen of state.pendingTabIds.keys()) {
802
+ if (gen <= msg.gen) {
803
+ state.pendingTabIds.delete(gen);
804
+ }
805
+ }
806
+ onDomUpdated(state, msg.gen);
807
+ return;
808
+ }
809
+ case "patchComplete": {
810
+ // A patch never re-targets the host (tabId untouched) — only the DOM/geometry changed.
811
+ onDomUpdated(state, msg.gen);
812
+ return;
813
+ }
814
+ case "renderError": {
815
+ // The render never landed — its pending identity must not be adopted by a later ack.
816
+ state.pendingTabIds.delete(msg.gen);
817
+ return;
818
+ }
819
+ case "dataScope": {
820
+ // The iframe posted its resolved $defs snapshot right after renderComplete. Adopt it into the
821
+ // Parent's canvas state so the data-explorer panel shows live data (buildScope moved into the
822
+ // Iframe realm; the parent hard-codes scope:null at ready). Gate on the last-rendered gen so a
823
+ // Snapshot from a superseded render can't clobber the current one, then re-render the left
824
+ // Panel (which hosts the data-explorer) to reflect the new scope.
825
+ if (msg.gen !== state.lastRenderedGen) {
826
+ return;
827
+ }
828
+ updateCanvas({ scope: msg.scope });
829
+ renderOnly("leftPanel");
830
+ return;
831
+ }
832
+ case "contentHeight": {
833
+ // Size the iframe element to its document so the canvas never scrolls internally — the parent
834
+ // Canvas pans/scrolls instead, every node stays inside the iframe box (hit-testable), and the
835
+ // Overlay (drawn in canvas space) tracks it. The cssText's 480px `min-height` is a
836
+ // Pre-measurement floor so an empty/short PAGE stays a usable canvas; a component DEFINITION
837
+ // (fragment) instead hugs its content, so drop the floor for it once measured (else a short
838
+ // Component leaves dead space below — pages keep the floor and stay tall via #jx-canvas-root).
839
+ state.iframe.style.height = `${msg.height}px`;
840
+ state.iframe.style.minHeight = msg.fragment ? "0px" : "480px";
841
+ return;
842
+ }
843
+ case "dragOver": {
844
+ // Display-only drop indicator (Phase 4c). Drop stale replies: a different drag session
845
+ // (dragSeq) or a superseded render (gen). The indicator draw side uses scale=1 (D-2) — the
846
+ // Overlay is inside the scaled panzoom-wrap, so the browser already applies the zoom.
847
+ if (msg.dragSeq !== currentDragSeq) {
848
+ return;
849
+ }
850
+ // A cursor-carrying dragOver means the IFRAME is driving the over-stream from its own events
851
+ // (native routing or flow 3) — not merely replying to a parent-forwarded dragMove. Record it
852
+ // BEFORE the gen gate: it's a fact about event routing, not render freshness.
853
+ if (msg.cursor) {
854
+ lastIframeDragOverSeq = msg.dragSeq;
855
+ lastIframeDragOverAt = Date.now();
856
+ }
857
+ if (msg.gen !== state.lastRenderedGen) {
858
+ return;
859
+ }
860
+ if (msg.preview) {
861
+ state.overlay.setDropIndicator(
862
+ canvasRectToParent(msg.preview.referenceRect),
863
+ msg.preview.edge,
864
+ );
865
+ } else {
866
+ state.overlay.setDropIndicator(null);
867
+ }
868
+ // Flow 3 (iframe-driven) posts a `cursor` in iframe-viewport coords: the parent has no pointer
869
+ // Of its own during an iframe-originated drag, so position the ghost by FORWARD-converting the
870
+ // Cursor to parent-viewport space (the inverse of parentCursorToIframe). Parent-driven flows
871
+ // (1/2/4) omit `cursor` — the bridge moves their ghost from the raw parent pointer.
872
+ if (msg.cursor) {
873
+ const g = hostDragGeometry(state);
874
+ moveDragGhost(msg.cursor.x * g.scale + g.rect.left, msg.cursor.y * g.scale + g.rect.top);
875
+ }
876
+ return;
877
+ }
878
+ case "dragOriginate": {
879
+ if (state.stylebook) {
880
+ return; // Specimens can't be reordered (belt-and-braces with the iframe-side gate).
881
+ }
882
+ // Flow 3: the iframe began a body-grab drag and DRIVES it locally. Hand off to the coordinator,
883
+ // Which ADOPTS the iframe's seq (so its dragOver/dropResult pass the gate) and shows the ghost
884
+ // — it attaches NO parent-document listeners (the iframe owns the pointer it started).
885
+ iframeOriginateHandler?.(state, msg.path, msg.dragSeq);
886
+ return;
887
+ }
888
+ case "nativeDragEnter": {
889
+ if (state.stylebook) {
890
+ return; // A specimen catalog is never a drop target.
891
+ }
892
+ // A parent-originated NATIVE drag crossed onto this iframe before any session bound it (the
893
+ // Parent never sees a cursor inside the iframe rect) — let the bridge bind/migrate here.
894
+ nativeDragEnterHandler?.(state);
895
+ return;
896
+ }
897
+ case "dragEnd": {
898
+ // The iframe cancelled a flow-3 drag locally (Escape). Tear down the indicator/ghost and
899
+ // Release the retained source data so no drop is applied.
900
+ state.overlay.setDropIndicator(null);
901
+ clearDragGhost();
902
+ retainedSrcData.delete(msg.dragSeq);
903
+ return;
904
+ }
905
+ case "dropResult": {
906
+ // The authoritative, freshly-computed drop (Phase 4c). Apply through the realm-agnostic
907
+ // Mutation helper with the PARENT-retained source data, unless stale or empty.
908
+ if (msg.dragSeq !== currentDragSeq || msg.gen !== state.lastRenderedGen) {
909
+ return;
910
+ }
911
+ if (msg.instruction && msg.targetPath) {
912
+ const srcData = retainedSrcData.get(msg.dragSeq);
913
+ if (srcData) {
914
+ applyDropInstruction(hostTab(state), { type: msg.instruction }, srcData, msg.targetPath);
915
+ }
916
+ }
917
+ retainedSrcData.delete(msg.dragSeq);
918
+ // The drop resolved (or was empty) — tear down the display affordances on this host.
919
+ state.overlay.setDropIndicator(null);
920
+ clearDragGhost();
921
+ return;
922
+ }
923
+ case "patchError": {
924
+ // The iframe couldn't apply the edit surgically — fall back to a full render of the live doc.
925
+ patchEscalation?.();
926
+ return;
927
+ }
928
+ case "forwardKey": {
929
+ // A global shortcut pressed while the iframe had focus — replay it for the editor's handler.
930
+ redispatchKey(msg.event);
931
+ return;
932
+ }
933
+ case "forwardWheel": {
934
+ // A wheel over the iframe — replay it on canvasWrap so the editor's zoom/pan handler fires (the
935
+ // Cursor is mapped from iframe-viewport coords to parent-viewport via this host's scale + offset).
936
+ redispatchWheel(state, msg);
937
+ return;
938
+ }
939
+ case "editStart": {
940
+ // Inline editing began in this host's iframe. Only one editable is active across all panels —
941
+ // Tear down any other host's editing state and make this the single active edit host.
942
+ if (activeEditHost && activeEditHost !== state) {
943
+ activeEditHost.editing = false;
944
+ activeEditHost.snapshot = null;
945
+ }
946
+ activeEditHost = state;
947
+ state.editing = true;
948
+ state.snapshot = null;
949
+ state.lastSnapshotSeq = 0;
950
+ toolbarRefresh?.();
951
+ return;
952
+ }
953
+ case "selectionChanged": {
954
+ // Drop stale (re-ordered) snapshots; otherwise store the latest and refresh the toolbar.
955
+ if (msg.seq <= state.lastSnapshotSeq) {
956
+ return;
957
+ }
958
+ state.lastSnapshotSeq = msg.seq;
959
+ state.snapshot = msg;
960
+ toolbarRefresh?.();
961
+ return;
962
+ }
963
+ case "editCommit": {
964
+ // Route to the tab this host's iframe renders — NOT activeTab, which may have changed while
965
+ // The message was in flight (the cross-document bleed).
966
+ applyInlineCommit(hostTab(state), msg.path, msg.children, msg.textContent);
967
+ return;
968
+ }
969
+ case "editSplit": {
970
+ // The mutation lands now (surgical patch or escalated render); re-entry on the new paragraph
971
+ // Is deferred until this host acks the DOM that contains it (see deferEnterEdit).
972
+ deferEnterEdit(state, applyInlineSplit(hostTab(state), msg.path, msg.before, msg.after));
973
+ return;
974
+ }
975
+ case "editInsert": {
976
+ deferEnterEdit(state, applyInlineInsert(hostTab(state), msg.path, msg.cmd, msg.commitData));
977
+ return;
978
+ }
979
+ case "slashShow": {
980
+ // The iframe engine wants the slash menu at its edited element's rect (iframe-viewport) —
981
+ // Convert to parent-viewport by the empirical zoom + iframe offset and show the real menu.
982
+ // The select/dismiss callbacks post back over THIS host's channel, closing the loop.
983
+ const { rect: ifr, scale } = hostDragGeometry(state);
984
+ const left = msg.rect.x * scale + ifr.left;
985
+ const top = msg.rect.y * scale + ifr.top;
986
+ canvasSlashHandler?.show({
987
+ filter: msg.filter,
988
+ onDismiss: () => state.channel.post({ kind: "slashDismissed" }),
989
+ onSelect: (cmd) => state.channel.post({ cmd: { ...cmd }, kind: "slashSelect" }),
990
+ rect: {
991
+ bottom: top + msg.rect.height * scale,
992
+ height: msg.rect.height * scale,
993
+ left,
994
+ top,
995
+ width: msg.rect.width * scale,
996
+ },
997
+ });
998
+ return;
999
+ }
1000
+ case "slashNav": {
1001
+ canvasSlashHandler?.nav(msg.key);
1002
+ return;
1003
+ }
1004
+ case "slashDismiss": {
1005
+ canvasSlashHandler?.dismiss();
1006
+ return;
1007
+ }
1008
+ case "contextMenu": {
1009
+ if (state.stylebook) {
1010
+ return; // The doc context menu's actions are meaningless for specimen paths.
1011
+ }
1012
+ // A canvas right-click — convert to parent-viewport coords and show the Jx element menu.
1013
+ const { rect: ifr, scale } = hostDragGeometry(state);
1014
+ canvasContextMenuHandler?.show({
1015
+ clientX: msg.x * scale + ifr.left,
1016
+ clientY: msg.y * scale + ifr.top,
1017
+ path: msg.path ? [...msg.path] : null,
1018
+ });
1019
+ return;
1020
+ }
1021
+ case "editEnd": {
1022
+ // Ignore a superseded late editEnd (a re-enter's stop→start can deliver a stale one): only act
1023
+ // When this host is still the one editing.
1024
+ if (!state.editing) {
1025
+ return;
1026
+ }
1027
+ state.editing = false;
1028
+ state.snapshot = null;
1029
+ if (activeEditHost === state) {
1030
+ activeEditHost = null;
1031
+ }
1032
+ toolbarRefresh?.();
1033
+ return;
1034
+ }
1035
+ default: {
1036
+ break;
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ /**
1042
+ * Shared renderComplete/patchComplete bookkeeping: the DOM (and so all geometry) just changed —
1043
+ * re-measure the selection box, record the gen the DOM now reflects (cross-frame drag replies are
1044
+ * stale-gated against it, Phase 4c), drop the "+" (anchored to a now-stale rect), and flush a
1045
+ * deferred split/insert re-entry once the DOM containing the new element is live.
1046
+ */
1047
+ function onDomUpdated(state: HostState, gen: number): void {
1048
+ state.lastRenderedGen = gen;
1049
+ requestSelection(state, state.selectionPath);
1050
+ hideInsertZoneNow(state);
1051
+ if (state.pendingEnterEdit && gen >= state.pendingEnterEdit.minGen) {
1052
+ const { path } = state.pendingEnterEdit;
1053
+ state.pendingEnterEdit = null;
1054
+ // Re-enter only when this host still shows the ACTIVE tab — a background tab's iframe renders a
1055
+ // Different doc, so re-entering there would grab the wrong element (the commit itself already
1056
+ // Landed in the right tab via hostTab routing).
1057
+ if (state.tabId !== null && state.tabId === workspace.activeTabId) {
1058
+ reenterEdit(state, path);
1059
+ }
1060
+ }
1061
+ }
1062
+
1063
+ /**
1064
+ * Defer re-entering inline editing on `path` until this host's DOM reflects the split/insert (see
1065
+ * {@link onDomUpdated}). `minGen` is the gen the host currently reflects: the surgical patch acks at
1066
+ * that same gen; an escalated full render acks at a bumped one — both satisfy `gen >= minGen`,
1067
+ * while a stale ack cannot. Latest-wins on overwrite.
1068
+ */
1069
+ function deferEnterEdit(state: HostState, path: (string | number)[]): void {
1070
+ state.pendingEnterEdit = { minGen: state.lastRenderedGen, path: [...path] };
1071
+ }
1072
+
1073
+ /** Ask the host's iframe to (re-)enter inline editing on `path` (a plain copy crosses the bridge). */
1074
+ function reenterEdit(state: HostState, path: (string | number)[]): void {
1075
+ state.channel.post({ kind: "enterEdit", path: [...path] });
1076
+ }
1077
+
1078
+ /**
1079
+ * Replay a forwarded wheel on `canvasWrap` so the editor's zoom/pan handler fires. The forwarded
1080
+ * cursor is in iframe-viewport CSS px; map it to parent-viewport space by the host's empirical zoom
1081
+ * scale ({@link hostDragGeometry}) plus the iframe's on-screen offset, so ctrl+wheel zooms toward
1082
+ * the real cursor. A synthetic event triggers no native scroll, which is fine — the handler does
1083
+ * the work.
1084
+ */
1085
+ function redispatchWheel(
1086
+ state: HostState,
1087
+ msg: Extract<IframeToParent, { kind: "forwardWheel" }>,
1088
+ ): void {
1089
+ const { rect, scale } = hostDragGeometry(state);
1090
+ canvasWrap.dispatchEvent(
1091
+ new WheelEvent("wheel", {
1092
+ bubbles: true,
1093
+ cancelable: true,
1094
+ clientX: rect.left + msg.x * scale,
1095
+ clientY: rect.top + msg.y * scale,
1096
+ ctrlKey: msg.ctrlKey,
1097
+ deltaX: msg.deltaX,
1098
+ deltaY: msg.deltaY,
1099
+ metaKey: msg.metaKey,
1100
+ shiftKey: msg.shiftKey,
1101
+ }),
1102
+ );
1103
+ }
1104
+
1105
+ /** Rebuild and dispatch a synthetic `keydown` on the editor document from a forwarded keystroke. */
1106
+ function redispatchKey(event: SerializedKey): void {
1107
+ document.dispatchEvent(
1108
+ new KeyboardEvent("keydown", {
1109
+ altKey: event.altKey,
1110
+ bubbles: true,
1111
+ cancelable: true,
1112
+ code: event.code,
1113
+ ctrlKey: event.ctrlKey,
1114
+ key: event.key,
1115
+ metaKey: event.metaKey,
1116
+ shiftKey: event.shiftKey,
1117
+ }),
1118
+ );
1119
+ }
1120
+
1121
+ /** Draw the hover box, hidden when there's no hover or it coincides with the current selection. */
1122
+ function drawHover(state: HostState, hit: NodeHit | null): void {
1123
+ if (!hit || pathsEqual(hit.path, state.selectionPath)) {
1124
+ state.overlay.setHover(null);
1125
+ return;
1126
+ }
1127
+ state.overlay.setHover(canvasRectToParent(hit.rect));
1128
+ }
1129
+
1130
+ /**
1131
+ * Render `doc` into the iframe canvas mounted in `canvasEl`: resolve the document parent-side and
1132
+ * post it (queued until the iframe is `ready`).
1133
+ *
1134
+ * `widthPx` makes the panel's breakpoint width an EXPLICIT lever on the iframe element itself (only
1135
+ * `style.width` is touched — the rest of cssText, incl. `min-height:480px; height:100%`, is kept).
1136
+ * The iframe is a real viewport, so its CSS width is the layout viewport `@media` evaluates
1137
+ * against; setting it here survives iframe reuse and any future container-styling change. Null
1138
+ * (edit-mode / git-diff / full-width panels) falls back to `100%`. The iframe stays FIXED-HEIGHT —
1139
+ * content scrolls inside it like a real device viewport; narrowing the width does not auto-grow
1140
+ * it.
1141
+ *
1142
+ * `tabId` is the identity of the tab whose document this render shows (null for override docs like
1143
+ * git-diff, whose iframes must never route doc mutations anywhere). It is recorded against `gen`
1144
+ * and adopted into `host.tabId` only when the iframe acks the render — see
1145
+ * {@link HostState.tabId}.
1146
+ */
1147
+ export async function mountIframeCanvas(
1148
+ gen: number,
1149
+ doc: JxMutableNode,
1150
+ canvasEl: HTMLElement,
1151
+ widthPx?: number | null,
1152
+ tabId: string | null = null,
1153
+ ): Promise<void> {
1154
+ const state = ensureHost(canvasEl);
1155
+ state.pendingTabIds.set(gen, tabId);
1156
+ // A page mount clears any stylebook capability from a previous mode's reuse of this host.
1157
+ state.stylebook = null;
1158
+ state.iframe.style.width = widthPx ? `${widthPx}px` : "100%";
1159
+ // Always resolve and post the latest render. The iframe drops stale generations itself (via its
1160
+ // Own `latestGen`), so the parent must NOT gate on `view.renderGeneration`: during boot many
1161
+ // Renders fire and the generation is usually stale by the time resolution finishes, which would
1162
+ // Otherwise drop every post.
1163
+ const resolved = await resolveCanvasDocument(doc);
1164
+ // The doc must be structured-cloneable to cross postMessage. A Jx document is JSON by contract, so
1165
+ // A JSON round-trip (NOT structuredClone, which would throw) drops residual functions / reactive
1166
+ // Proxy artifacts that would otherwise raise DataCloneError and silently drop the entire message.
1167
+ // oxlint-disable-next-line unicorn/prefer-structured-clone
1168
+ const cloneableDoc = JSON.parse(JSON.stringify(resolved.renderDoc)) as unknown;
1169
+ // The RAW page doc (forward-op + data-jx-path coordinate space) crosses as the iframe's shadow doc.
1170
+ // oxlint-disable-next-line unicorn/prefer-structured-clone
1171
+ const cloneableShadow = JSON.parse(JSON.stringify(doc)) as unknown;
1172
+ const message: ParentToIframe = {
1173
+ doc: cloneableDoc,
1174
+ docBase: resolved.docBase ?? `${canvasBaseOrigin()}/`,
1175
+ gen,
1176
+ kind: "render",
1177
+ mapperCtx: resolved.mapperCtx,
1178
+ mode: resolved.mapperCtx.canvasMode as CanvasMode,
1179
+ shadowDoc: cloneableShadow,
1180
+ siteStyle: resolved.siteStyle,
1181
+ };
1182
+ // A mode switch to preview mid-split must not start an edit session in the preview render.
1183
+ if (message.mode === "preview") {
1184
+ state.pendingEnterEdit = null;
1185
+ }
1186
+ if (state.ready) {
1187
+ state.channel.post(message);
1188
+ } else {
1189
+ state.pending = message;
1190
+ }
1191
+ }
1192
+
1193
+ /**
1194
+ * Mount a STYLEBOOK canvas: post the pre-generated specimen document (no `resolveCanvasDocument` —
1195
+ * the generator already merged the effective style/media and there is no layout/page mapping) and
1196
+ * arm the host's stylebook capability (tag-addressed hits/selection). Mounted with a NULL tab
1197
+ * identity: specimen paths are not tab-document paths, so any doc-mutating bridge message from this
1198
+ * host must drop — the existing null-tabId routing does exactly that.
1199
+ */
1200
+ export function mountStylebookCanvas(
1201
+ gen: number,
1202
+ generated: {
1203
+ doc: JxMutableNode;
1204
+ pathToTag: ReadonlyMap<string, string>;
1205
+ tagToCardPath: ReadonlyMap<string, (string | number)[]>;
1206
+ },
1207
+ canvasEl: HTMLElement,
1208
+ widthPx: number | null,
1209
+ ): void {
1210
+ const state = ensureHost(canvasEl);
1211
+ state.pendingTabIds.set(gen, null);
1212
+ state.stylebook = { pathToTag: generated.pathToTag, tagToCardPath: generated.tagToCardPath };
1213
+ state.pendingEnterEdit = null;
1214
+ state.iframe.style.width = widthPx ? `${widthPx}px` : "100%";
1215
+ // Two independent plain clones: the iframe renders `doc` and folds styleUpdates into `shadowDoc`
1216
+ // (and fake test channels pass messages by reference, so sharing one object would alias them).
1217
+ // oxlint-disable-next-line unicorn/prefer-structured-clone
1218
+ const cloneableDoc = JSON.parse(JSON.stringify(generated.doc)) as unknown;
1219
+ // oxlint-disable-next-line unicorn/prefer-structured-clone
1220
+ const cloneableShadow = JSON.parse(JSON.stringify(generated.doc)) as unknown;
1221
+ const message: ParentToIframe = {
1222
+ doc: cloneableDoc,
1223
+ docBase: `${canvasBaseOrigin()}/`,
1224
+ gen,
1225
+ kind: "render",
1226
+ mapperCtx: {
1227
+ arrayPaths: [],
1228
+ canvasMode: "stylebook",
1229
+ layoutWrapped: false,
1230
+ pageContentOffset: null,
1231
+ pageContentPrefix: null,
1232
+ },
1233
+ mode: "stylebook",
1234
+ shadowDoc: cloneableShadow,
1235
+ // The generator already merged projectConfig.style into the doc's own style block — passing
1236
+ // SiteStyle too would double-apply it.
1237
+ siteStyle: null,
1238
+ };
1239
+ if (state.ready) {
1240
+ state.channel.post(message);
1241
+ } else {
1242
+ state.pending = message;
1243
+ }
1244
+ }
1245
+
1246
+ /**
1247
+ * Post a live style update to every ready stylebook host (gen-tagged per host so a stale update is
1248
+ * dropped iframe-side; the superseding render carries the same style). Returns how many hosts
1249
+ * received it — zero means no stylebook iframe is live yet and the caller should fall through to a
1250
+ * full render. Each post is followed by a selection re-measure so the box tracks the reflow.
1251
+ */
1252
+ export function postStyleUpdateToStylebookHosts(style: Record<string, unknown>): number {
1253
+ let posted = 0;
1254
+ // Style objects come off the reactive doc — only plain values may cross postMessage.
1255
+ // oxlint-disable-next-line unicorn/prefer-structured-clone
1256
+ const cloneable = JSON.parse(JSON.stringify(style)) as Record<string, unknown>;
1257
+ for (const host of liveHosts) {
1258
+ if (!host.iframe.isConnected) {
1259
+ liveHosts.delete(host);
1260
+ continue;
1261
+ }
1262
+ if (host.ready && host.stylebook) {
1263
+ host.channel.post({ gen: host.lastRenderedGen, kind: "styleUpdate", style: cloneable });
1264
+ posted += 1;
1265
+ requestSelection(host, host.selectionPath);
1266
+ }
1267
+ }
1268
+ return posted;
1269
+ }
1270
+
1271
+ /**
1272
+ * Pan the canvas so the selected tag's card is centered (layers-panel "locate" affordance). The
1273
+ * card lives inside the iframe, so it is measured over the bridge and the reply pans by the
1274
+ * converted parent-viewport rect (see the `geometry` handler's panReqId branch).
1275
+ */
1276
+ export function panToStylebookTag(tag: string): void {
1277
+ const panel = getActivePanel();
1278
+ const host = panel ? (hosts.get(panel.canvas as HTMLElement) ?? null) : null;
1279
+ if (!host?.stylebook || !host.ready) {
1280
+ return;
1281
+ }
1282
+ const cardPath = host.stylebook.tagToCardPath.get(tag);
1283
+ if (!cardPath) {
1284
+ return;
1285
+ }
1286
+ host.selReqId += 1;
1287
+ host.panReqId = host.selReqId;
1288
+ host.channel.post({ kind: "measure", paths: [[...cardPath]], reqId: host.selReqId });
1289
+ }
1290
+
1291
+ // ─── Format-toolbar bridge (Phase 4b-2) ─────────────────────────────────────────
1292
+
1293
+ /** The host whose iframe currently owns the inline-edit session (or null). */
1294
+ export function getActiveEditHost(): HostState | null {
1295
+ return activeEditHost;
1296
+ }
1297
+
1298
+ /** The current edit session's editing flag + latest selection snapshot, for the parent toolbar. */
1299
+ export function getEditSnapshot(): { editing: boolean; snapshot: SelectionSnapshot | null } {
1300
+ if (!activeEditHost) {
1301
+ return { editing: false, snapshot: null };
1302
+ }
1303
+ return { editing: activeEditHost.editing, snapshot: activeEditHost.snapshot };
1304
+ }
1305
+
1306
+ /** Post an `applyFormat` intent to the active edit host's iframe (no-op when none/not ready). */
1307
+ export function postApplyFormat(intent: ApplyFormatIntent): void {
1308
+ const host = activeEditHost;
1309
+ if (!host || !host.ready) {
1310
+ return;
1311
+ }
1312
+ host.channel.post({ intent, kind: "applyFormat" });
1313
+ }
1314
+
1315
+ /**
1316
+ * Ask the active edit host's iframe to commit-and-end its inline-edit session (no-op when none).
1317
+ * The parent calls this when intent leaves the edit surface in the PARENT realm — a tab switch, a
1318
+ * chrome click outside the edit toolbars — which the iframe cannot observe itself. The resulting
1319
+ * `editCommit` routes by the host's tabId, so a commit racing a tab switch still lands in the
1320
+ * document its session belonged to.
1321
+ */
1322
+ export function commitActiveEditSession(): void {
1323
+ const host = activeEditHost;
1324
+ if (host?.editing && host.ready) {
1325
+ host.channel.post({ kind: "endEdit" });
1326
+ }
1327
+ }
1328
+
1329
+ /** The live host backing the active panel's canvas (for non-edit selection-bar positioning). */
1330
+ function hostForActivePanel(): HostState | null {
1331
+ const panel = getActivePanel();
1332
+ return panel ? (hosts.get(panel.canvas as HTMLElement) ?? null) : null;
1333
+ }
1334
+
1335
+ /**
1336
+ * The format toolbar's anchor rect, in PARENT-VIEWPORT space (the bar is `position:fixed`). Both
1337
+ * source rects — the edit session's caret snapshot and the `lastSelectionRect` fallback — are in
1338
+ * UNSCALED iframe-viewport px (D-2: the overlay draws them inside the scaled panzoom-wrap, so the
1339
+ * browser applies the zoom there); the fixed bar gets no such free ride, so scale by the live
1340
+ * empirical zoom ({@link hostDragGeometry}) and add the iframe's on-screen offset, whose GBCR
1341
+ * already bakes in pan + zoom + ancestor scroll. Edit mode has no transform → scale is 1.
1342
+ */
1343
+ export function getEditBarAnchorRect(): ParentRect | null {
1344
+ // The format toolbar follows the live caret/selection snapshot of the active edit session; the
1345
+ // Structural bar (tag badge / parent selector / move / convert / drag handle) must still position
1346
+ // On a plain selection with no inline-edit session, so fall back to the active panel's host and
1347
+ // Its last measured selection rect.
1348
+ const editHost = activeEditHost;
1349
+ const host = editHost ?? hostForActivePanel();
1350
+ if (!host) {
1351
+ return null;
1352
+ }
1353
+ const { rect: ifr, scale } = hostDragGeometry(host);
1354
+ const snapRect = host === editHost ? host.snapshot?.rect : null;
1355
+ if (snapRect) {
1356
+ return {
1357
+ height: snapRect.height * scale,
1358
+ left: snapRect.x * scale + ifr.left,
1359
+ top: snapRect.y * scale + ifr.top,
1360
+ width: snapRect.width * scale,
1361
+ };
1362
+ }
1363
+ if (host.lastSelectionRect) {
1364
+ // The fallback rect is overlay-local (same top-left + coordinate space as the iframe viewport).
1365
+ return {
1366
+ height: host.lastSelectionRect.height * scale,
1367
+ left: host.lastSelectionRect.left * scale + ifr.left,
1368
+ top: host.lastSelectionRect.top * scale + ifr.top,
1369
+ width: host.lastSelectionRect.width * scale,
1370
+ };
1371
+ }
1372
+ return null;
1373
+ }