@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
package/src/studio.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  requireProjectState,
20
20
  setProjectState,
21
21
  toolbarEl,
22
+ updateSession,
22
23
  updateUi,
23
24
  } from "./store";
24
25
 
@@ -28,20 +29,28 @@ import { effect } from "./reactivity";
28
29
 
29
30
  import { view } from "./view";
30
31
 
31
- import { isEditableBlock, isEditing } from "./editor/inline-edit";
32
- import { enterComponentInlineEdit, initComponentInlineEdit } from "./editor/component-inline-edit";
33
- import { enterInlineEdit } from "./editor/content-inline-edit";
32
+ import { isEditing } from "./editor/inline-edit";
34
33
  import { applyTransform, initCanvasUtils, positionZoomIndicator } from "./canvas/canvas-utils";
35
- import { findCanvasElement, getActivePanel, initCanvasHelpers } from "./canvas/canvas-helpers";
36
34
  import {
37
- applyCanvasMediaOverrides,
38
35
  initCanvasRender,
39
36
  renderCanvas,
40
37
  renderOverlays,
41
38
  scheduleCanvasRender,
42
39
  } from "./canvas/canvas-render";
43
40
  import { consumePatchedDocument, initCanvasPatcher } from "./canvas/canvas-patcher";
44
- import { registerSubtreeDnD } from "./panels/canvas-dnd";
41
+ import {
42
+ commitActiveEditSession,
43
+ getEditSnapshot,
44
+ setCanvasContextMenuHandler,
45
+ setCanvasSlashHandler,
46
+ setIframePatchEscalation,
47
+ setInsertZoneClickHandler,
48
+ setStylebookHitHandler,
49
+ setToolbarRefresh,
50
+ } from "./canvas/iframe-host";
51
+ import { runInsertZoneAction } from "./editor/insert-zone-action";
52
+ import { canvasSlashHandler } from "./editor/canvas-slash-bridge";
53
+ import { makeCanvasContextMenuHandler } from "./editor/canvas-context-menu";
45
54
  import { initCanvasLiveRender } from "./canvas/canvas-live-render";
46
55
  import {
47
56
  mountStatusbar,
@@ -50,7 +59,12 @@ import {
50
59
  statusMessage,
51
60
  } from "./panels/statusbar";
52
61
  import { exportFile, parseSourceForPath, saveFile, serializeDocument } from "./files/file-ops";
53
- import { documentExtensions, formatForPath, loadFormats } from "./format/format-host";
62
+ import {
63
+ documentExtensions,
64
+ formatForPath,
65
+ loadFormats,
66
+ refreshFormats,
67
+ } from "./format/format-host";
54
68
  import {
55
69
  loadProject as _loadProject,
56
70
  openProject as _openProject,
@@ -66,6 +80,8 @@ import { startFsSync } from "./files/fs-events";
66
80
  import { renderImportsTemplate } from "./panels/imports-panel";
67
81
  import { renderHeadTemplate } from "./panels/head-panel";
68
82
  import { exportCemManifest as _exportCemManifest } from "./services/cem-export";
83
+ import { installAutomationHook } from "./services/automation";
84
+ import { openBrowseModal } from "./browse/browse-modal";
69
85
 
70
86
  import { getPlatform, hasPlatform, registerPlatform } from "./platform";
71
87
  import { parseMediaEntries } from "./utils/canvas-media";
@@ -76,6 +92,7 @@ import { defBadgeLabel, defCategory, renderSignalsTemplate } from "./panels/sign
76
92
  import { loadComponentRegistry } from "./files/components";
77
93
  import { ensureDependenciesInstalled } from "./packages/ensure-deps";
78
94
  import { maybePromptJxsuiteUpdate } from "./packages/jxsuite-update";
95
+ import { autoSyncProjectOnOpen } from "./packages/pull-package-sync";
79
96
 
80
97
  import { html, render as litRender } from "lit-html";
81
98
 
@@ -97,16 +114,20 @@ import * as rightPanelMod from "./panels/right-panel";
97
114
  import * as leftPanelMod from "./panels/left-panel";
98
115
  import * as tabStrip from "./panels/tab-strip";
99
116
  import * as tabBar from "./panels/tab-bar";
100
- import { renderStylebookOverlays } from "./panels/stylebook-panel";
117
+ import { selectStylebookTag } from "./panels/stylebook-panel";
101
118
  import { registerLayersDnD, registerComponentsDnD, registerElementsDnD } from "./panels/dnd";
119
+ import { registerCanvasDndBridge } from "./panels/canvas-dnd-bridge";
102
120
  import { defaultDef } from "./panels/shared";
103
121
  import { registerFunctionCompletions } from "./panels/editors";
104
- import { renderBlockActionBar, initBlockActionBar } from "./panels/block-action-bar";
122
+ import {
123
+ initBlockActionBar,
124
+ isEditChromeTarget,
125
+ renderBlockActionBar,
126
+ } from "./panels/block-action-bar";
105
127
  import { initCssData } from "./panels/style-utils";
106
- import { updateForcedPseudoPreview } from "./panels/pseudo-preview";
107
- import { initPanelEvents } from "./panels/panel-events";
108
128
  import { initQuickSearch } from "./panels/quick-search";
109
129
  import { addRecentProject, hydrateRecentProjects, removeRecentProject } from "./recent-projects";
130
+ import { hydrateSettings } from "./services/settings-store";
110
131
  import { initWelcome } from "./panels/welcome-screen";
111
132
  import { openNewProjectModal } from "./new-project/new-project-modal";
112
133
  import type { DocumentStackEntry, GitDiffState } from "./types";
@@ -119,8 +140,14 @@ void _swc;
119
140
  // These mutable variables are local to studio.js for now. As sections are extracted
120
141
  // Into their own modules, they will migrate to ctx in store.js.
121
142
 
143
+ // Effective canvas mode: the per-tab preview toggle composes with an edit/design base mode and
144
+ // Presents as "preview" to every downstream gate (doc resolution, iframe flags, interaction
145
+ // Surfaces). Consumers needing the base mode (toolbar switcher selection, canvas host layout)
146
+ // Read tab.session.ui.canvasMode directly.
122
147
  function getCanvasMode() {
123
- return activeTab.value?.session.ui.canvasMode ?? "design";
148
+ const ui = activeTab.value?.session.ui;
149
+ const base = ui?.canvasMode ?? "design";
150
+ return ui?.preview && (base === "edit" || base === "design") ? "preview" : base;
124
151
  }
125
152
 
126
153
  /** @param {string} mode */
@@ -319,6 +346,18 @@ if (!hasPlatform()) {
319
346
  registerPlatform(createDevServerPlatform());
320
347
  }
321
348
 
349
+ // Screenshot/automation runners (scripts/screenshots/) await window.__jxAutomation right after
350
+ // Navigation, so the gated hook must install before the async deep-link project load below.
351
+ installAutomationHook({
352
+ getCanvasMode,
353
+ openBrowseModal,
354
+ openNewProjectModal,
355
+ render,
356
+ renderActivityBar,
357
+ setCanvasMode,
358
+ statusMessage,
359
+ });
360
+
322
361
  mountResizeEdges();
323
362
 
324
363
  // ─── Render loop ──────────────────────────────────────────────────────────────
@@ -361,39 +400,58 @@ initBlockActionBar({
361
400
  getCanvasMode,
362
401
  navigateToComponent,
363
402
  });
364
-
365
- initComponentInlineEdit({ findCanvasElement });
366
- initCanvasHelpers({
367
- getCanvasMode,
368
- getZoom: () => activeTab.value?.session.ui.zoom ?? 1,
403
+ // The iframe's re-emitted selection snapshot drives the parent format toolbar refresh (4b-2).
404
+ setToolbarRefresh(renderBlockActionBar);
405
+ // The cross-origin insertion "+" click runs the parent-realm slash-menu → mutateInsertNode flow.
406
+ setInsertZoneClickHandler(runInsertZoneAction);
407
+ // The in-iframe "/" trigger drives the parent-realm Spectrum slash menu across the bridge.
408
+ setCanvasSlashHandler(canvasSlashHandler);
409
+ // Canvas right-clicks show the parent-realm Jx element context menu across the bridge.
410
+ setCanvasContextMenuHandler(makeCanvasContextMenuHandler({ navigateToComponent }));
411
+ // Stylebook hits decode to a TAG in the host and route here (null = clicked chrome/empty space).
412
+ setStylebookHitHandler((tag, media) => {
413
+ if (tag) {
414
+ selectStylebookTag(tag, media);
415
+ } else {
416
+ updateSession({ ui: { activeSelector: null, stylebookSelection: null } });
417
+ }
369
418
  });
419
+ // Commit-on-parent-click: a pointerdown in PARENT chrome outside the edit-session chrome (format
420
+ // Toolbar / link popover / slash menu) ends the live inline-edit session — the iframe can't observe
421
+ // Parent-realm pointer events (layers panel, tab strip, right panel…). Pointerdowns over the canvas
422
+ // Land inside the cross-origin iframe and never reach this listener, so it can't double-fire with
423
+ // The iframe's own click-away commit.
424
+ document.addEventListener(
425
+ "pointerdown",
426
+ (e) => {
427
+ if (getEditSnapshot().editing && !isEditChromeTarget(e.target)) {
428
+ commitActiveEditSession();
429
+ }
430
+ },
431
+ true,
432
+ );
433
+
370
434
  initCanvasUtils({
371
435
  getCanvasMode,
372
436
  getZoom: () => activeTab.value?.session.ui.zoom ?? 1,
373
- renderStylebookOverlays,
374
437
  setZoomDirect: (zoom) => {
375
438
  if (activeTab.value) {
376
439
  activeTab.value.session.ui.zoom = zoom;
377
440
  }
378
441
  },
379
442
  });
380
- initPanelEvents({
381
- enterInlineEdit,
382
- getCanvasMode,
383
- navigateToComponent,
384
- });
385
443
  initCanvasLiveRender({
386
444
  getCanvasMode,
387
445
  });
388
446
  initCanvasPatcher({
389
- applyCanvasMediaOverrides,
390
- enterComponentInlineEdit,
391
447
  getCanvasMode,
392
- registerSubtreeDnD,
393
448
  renderOverlays,
394
449
  scheduleCanvasRender,
395
- updateForcedPseudoPreview,
396
450
  });
451
+ // When the iframe canvas can't apply a posted patch surgically, fall back to a full render.
452
+ setIframePatchEscalation(scheduleCanvasRender);
453
+ // One global coordinator monitor drives cross-frame palette→canvas drops (Phase 4c).
454
+ registerCanvasDndBridge();
397
455
  initCanvasRender({
398
456
  getCanvasMode,
399
457
  get gitDiffState() {
@@ -441,7 +499,10 @@ effect(() => {
441
499
  void tab.session.ui.canvasMode;
442
500
  void tab.session.ui.editingFunction;
443
501
  void tab.session.ui.featureToggles;
502
+ void tab.session.ui.preview;
503
+ void tab.session.ui.previewParams;
444
504
  void tab.session.ui.settingsTab;
505
+ void tab.session.ui.showLayout;
445
506
  void tab.session.ui.stylebookTab;
446
507
  void tab.session.ui.stylebookFilter;
447
508
  void tab.session.ui.stylebookCustomizedOnly;
@@ -453,7 +514,6 @@ rightPanelMod.mount({
453
514
  getCanvasMode,
454
515
  navigateToComponent,
455
516
  renderCanvas: () => renderCanvas(),
456
- updateForcedPseudoPreview,
457
517
  });
458
518
 
459
519
  leftPanelMod.mount({
@@ -520,6 +580,19 @@ function ensureFsSync() {
520
580
  const _urlParams = new URLSearchParams(location.search);
521
581
  const _projectParam = _urlParams.get("project") || _urlParams.get("open");
522
582
 
583
+ if (!_projectParam) {
584
+ // Electrobun (and other non-?project= hosts) load their project over RPC, so the ?project= branch
585
+ // Below — which is the only place that calls platform.activate() — is skipped. Kick off activate()
586
+ // Here UNCONDITIONALLY so this window's loopback canvasUrl is fetched on boot (the canvas iframe
587
+ // Needs it); a render() once it resolves lets ensureHost swap an early default iframe for the
588
+ // Loopback one (see iframe-host ensureHost's canvasUrl-changed rebuild).
589
+ const _bootPlatform = getPlatform();
590
+ // oxlint-disable-next-line unicorn/prefer-top-level-await -- fire-and-forget: must not block the initial render
591
+ void _bootPlatform.activate?.()?.then(() => {
592
+ render();
593
+ });
594
+ }
595
+
523
596
  if (_projectParam) {
524
597
  // ?project= mode: skip normal loadProject, set up site context from the path
525
598
  const isAbsPath =
@@ -562,6 +635,7 @@ if (_projectParam) {
562
635
  selectedPath: siteCtx.fileRelPath || null,
563
636
  });
564
637
 
638
+ await autoSyncProjectOnOpen();
565
639
  await ensureDependenciesInstalled();
566
640
  await loadComponentRegistry();
567
641
 
@@ -670,6 +744,13 @@ void hydrateRecentProjects().then(() => {
670
744
  render();
671
745
  });
672
746
 
747
+ // Hydrate user settings (AI connection parameters) from the backend store, then re-render so
748
+ // Key-gated surfaces (assistant gate, New Project Import/Agent tabs) see the stored key.
749
+ // oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: hydration must not block initial render
750
+ void hydrateSettings().then(() => {
751
+ render();
752
+ });
753
+
673
754
  // ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
674
755
 
675
756
  function renderLeftPanel() {
@@ -708,6 +789,11 @@ async function openRecentProject(root: string) {
708
789
  }
709
790
 
710
791
  platform.projectRoot = root;
792
+ // The format registry is cached per project — the previous root's registry (often empty on a
793
+ // Fresh desktop launch) must not answer for this project, or non-JSON documents fail with
794
+ // "No format class imported" until a reload. Mirrors openProject in files.ts.
795
+ refreshFormats();
796
+ void loadFormats();
711
797
  const content = await platform.readFile("project.json");
712
798
  const config = JSON.parse(content) as ProjectConfig;
713
799
 
@@ -725,6 +811,7 @@ async function openRecentProject(root: string) {
725
811
  selectedPath: null,
726
812
  });
727
813
 
814
+ await autoSyncProjectOnOpen();
728
815
  await ensureDependenciesInstalled();
729
816
  await loadDirectory(".");
730
817
  await loadComponentRegistry();
@@ -779,18 +866,6 @@ function openFileFromTree(path: string) {
779
866
  initShortcuts(() => ({
780
867
  applyTransform,
781
868
  canvasMode: getCanvasMode(),
782
- componentInlineEdit: view.componentInlineEdit,
783
- enterEditOnPath(path) {
784
- requestAnimationFrame(() => {
785
- const activePanel = getActivePanel();
786
- if (activePanel) {
787
- const el = findCanvasElement(path, activePanel.canvas);
788
- if (el && isEditableBlock(el)) {
789
- enterInlineEdit(el, path);
790
- }
791
- }
792
- });
793
- },
794
869
  openProject,
795
870
  panX: view.panX,
796
871
  panY: view.panY,
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Pure document-op applier — folds a value-carrying {@link JxDocOp} into a bare (non-reactive)
3
+ * document tree. Shared by two consumers that MUST stay byte-identical or undo/redo would drift
4
+ * from live editing: history replay in the parent ({@link file://./transact.ts}) and the iframe
5
+ * canvas's non-reactive shadow doc (the patch source-of-truth across the cross-origin bridge).
6
+ *
7
+ * Dependency-light on purpose: it pulls only the pure path helper from `../state` and clones values
8
+ * with a local JSON round-trip, so the slim canvas-iframe bundle can import it without dragging in
9
+ * the editor's reactive store / format host.
10
+ */
11
+
12
+ import { getNodeAtPath } from "../state";
13
+ import type { JxDocOp } from "./patch-ops";
14
+ import type { JxMutableNode } from "@jxsuite/schema/types";
15
+
16
+ /** JSON round-trip clone — also normalizes away reactive proxies / functions / undefined. */
17
+ function jsonClone<T>(value: T): T {
18
+ // oxlint-disable-next-line unicorn/prefer-structured-clone -- structuredClone throws on reactive proxies; JSON normalization is the point
19
+ return JSON.parse(JSON.stringify(value)) as T;
20
+ }
21
+
22
+ /** Deep-clone a recorded value (undefined/null pass through; reactive proxies are read through). */
23
+ export function cloneValue<T>(v: T): T {
24
+ return v === undefined || v === null ? v : (jsonClone(v as object) as T);
25
+ }
26
+
27
+ /** The node's children array, lazily created; throws if `node` is itself a children array or mapped. */
28
+ export function childArray(node: JxMutableNode): (JxMutableNode | string)[] {
29
+ // Defense-in-depth: a path that resolves to a children array (rather than a node) would
30
+ // Otherwise get a bogus `.children` property tacked on here, silently storing the insert where
31
+ // Nothing renders. Callers must pass a node; fail loudly if they don't.
32
+ if (Array.isArray(node)) {
33
+ throw new TypeError("Cannot insert into a children array; parentPath must point at a node");
34
+ }
35
+ if (!node.children) {
36
+ node.children = [];
37
+ }
38
+ if (!Array.isArray(node.children)) {
39
+ throw new TypeError("Cannot insert into mapped-array children; edit the map template instead");
40
+ }
41
+ return node.children;
42
+ }
43
+
44
+ /**
45
+ * Apply a replayable doc op to a bare document tree. Values/nodes are cloned in, so the tree never
46
+ * aliases the op object. Throws (with a machine-readable reason) when a target path is missing.
47
+ */
48
+ export function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp): void {
49
+ switch (op.op) {
50
+ case "set-key": {
51
+ const node = getNodeAtPath(doc, op.path);
52
+ if (!node) {
53
+ throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
54
+ }
55
+ const target = node as Record<string, unknown>;
56
+ if (op.value === undefined) {
57
+ delete target[op.key];
58
+ } else {
59
+ target[op.key] = cloneValue(op.value);
60
+ }
61
+ return;
62
+ }
63
+ case "insert-child": {
64
+ const parent = getNodeAtPath(doc, op.parentPath);
65
+ childArray(parent).splice(op.index, 0, cloneValue(op.node) as JxMutableNode);
66
+ return;
67
+ }
68
+ case "remove-child": {
69
+ const parent = getNodeAtPath(doc, op.parentPath);
70
+ childArray(parent).splice(op.index, 1);
71
+ return;
72
+ }
73
+ case "set-child": {
74
+ const parent = getNodeAtPath(doc, op.parentPath);
75
+ childArray(parent).splice(op.index, 1, cloneValue(op.node) as JxMutableNode);
76
+ return;
77
+ }
78
+ case "move-child": {
79
+ const fromParent = getNodeAtPath(doc, op.fromParentPath);
80
+ const toParent = getNodeAtPath(doc, op.toParentPath);
81
+ const [node] = childArray(fromParent).splice(op.fromIndex, 1);
82
+ childArray(toParent).splice(op.toIndex, 0, node!);
83
+ return;
84
+ }
85
+ default: {
86
+ throw new Error(`unknown-doc-op:${(op as JxDocOp).op}`);
87
+ }
88
+ }
89
+ }
@@ -78,8 +78,12 @@ export interface PatchConsumer {
78
78
  classify: (tab: Tab, ops: JxPatchOp[]) => { patchable: boolean; reason: string };
79
79
  /** Mark a document root reference as surgically consumed (checked by the canvas doc-effect). */
80
80
  markConsumed: (docRef: object) => void;
81
- /** Apply the batch to all ready canvas panels. Throws on any failure (caller escalates). */
82
- apply: (tab: Tab, ops: JxPatchOp[]) => void;
81
+ /**
82
+ * Apply the batch to all ready canvas panels. Throws on any failure (caller escalates). `record`
83
+ * carries the value-carrying `docOps` the iframe consumer posts across the frame boundary; the
84
+ * legacy (parent-DOM) consumer ignores it and reads the post-mutation reactive doc instead.
85
+ */
86
+ apply: (tab: Tab, ops: JxPatchOp[], record?: TransactionRecord) => void;
83
87
  /** Schedule a full canvas render as the fallback path, recording the reason. */
84
88
  escalate: (reason: string) => void;
85
89
  }
package/src/tabs/tab.ts CHANGED
@@ -16,6 +16,12 @@ import type { JxDocOp } from "./patch-ops";
16
16
  export interface TabUi {
17
17
  rightTab: string;
18
18
  canvasMode: string;
19
+ /** Preview toggle — composes with an edit/design canvasMode; the effective mode becomes "preview". */
20
+ preview: boolean;
21
+ /** Show elements inherited from the page's layout (pages with an effective layout only). */
22
+ showLayout: boolean;
23
+ /** Chosen literal values for dynamic route params (e.g. { sku: "mini-trencher" }). */
24
+ previewParams: Record<string, string>;
19
25
  zoom: number;
20
26
  activeMedia: string | null;
21
27
  activeSelector: string | null;
@@ -79,7 +85,10 @@ export interface Tab {
79
85
  ui: TabUi;
80
86
  canvas: {
81
87
  status: string;
82
- scope: { stop: () => void; [k: string]: unknown } | null;
88
+ // A serializable snapshot of the iframe's resolved `$defs` (data-source values), posted over
89
+ // The bridge as a `dataScope` message and read by the data-explorer panel. Plain data now —
90
+ // The old live `EffectScope` (with `.stop()`) moved into the iframe realm with buildScope.
91
+ scope: Record<string, unknown> | null;
83
92
  error: string | null;
84
93
  pendingInlineEdit: InlineEditDef | null;
85
94
  };
@@ -92,9 +101,10 @@ export interface Tab {
92
101
 
93
102
  /**
94
103
  * @param {string} canvasMode — initial canvas mode (the tab's first allowed mode)
104
+ * @param {boolean} preview — initial preview-toggle state
95
105
  * @returns {TabUi}
96
106
  */
97
- function createDefaultUi(canvasMode: string) {
107
+ function createDefaultUi(canvasMode: string, preview = false) {
98
108
  return {
99
109
  activeMedia: null,
100
110
  activeSelector: null,
@@ -109,8 +119,11 @@ function createDefaultUi(canvasMode: string) {
109
119
  gitStatus: null,
110
120
  inspectorSections: {},
111
121
  pendingInlineEdit: null,
122
+ preview,
123
+ previewParams: {},
112
124
  rightTab: "properties",
113
125
  settingsTab: "stylebook",
126
+ showLayout: true,
114
127
  styleFilter: "",
115
128
  styleFilterActive: false,
116
129
  styleSections: {},
@@ -165,7 +178,10 @@ export function createTab({
165
178
  const resolvedModes = capabilities?.modes ?? inferModes(documentPath, sourceFormat);
166
179
  // A tab opens in its first allowed mode — never one the toolbar would disable.
167
180
  // Formats author mode order so the default comes first (edit, stylebook, etc.).
168
- const initialCanvasMode = resolvedModes[0] ?? "edit";
181
+ // "preview" is a per-tab toggle rather than a base mode: a preview-first format opens
182
+ // In its first non-preview mode with the toggle already on.
183
+ const initialCanvasMode = resolvedModes.find((m) => m !== "preview") ?? "edit";
184
+ const initialPreview = resolvedModes[0] === "preview";
169
185
 
170
186
  const tab = scope.run(() => ({
171
187
  capabilities: { modes: resolvedModes },
@@ -196,7 +212,7 @@ export function createTab({
196
212
  documentStack: [],
197
213
  hover: null,
198
214
  selection: null,
199
- ui: createDefaultUi(initialCanvasMode),
215
+ ui: createDefaultUi(initialCanvasMode, initialPreview),
200
216
  }),
201
217
  })) as unknown as Tab;
202
218
 
@@ -204,6 +220,9 @@ export function createTab({
204
220
  }
205
221
 
206
222
  /**
223
+ * Allowed modes for a document. "preview" in a format's mode list means the preview toggle is
224
+ * Available for the tab (it is not a base canvas mode the toolbar switches to).
225
+ *
207
226
  * @param {string | null | undefined} documentPath
208
227
  * @param {string | null} sourceFormat
209
228
  * @returns {string[]}
@@ -2,6 +2,7 @@
2
2
  import { toRaw } from "../reactivity";
3
3
  import { jsonClone } from "../utils/studio-utils";
4
4
  import { childIndex, getNodeAtPath, isAncestor, parentElementPath, pathsEqual } from "../state";
5
+ import { applyDocOpToDoc, childArray, cloneValue } from "./doc-op-apply";
5
6
  import {
6
7
  beginRecording,
7
8
  endRecording,
@@ -33,11 +34,6 @@ function patchHistoryEnabled() {
33
34
  }
34
35
  }
35
36
 
36
- /** Deep-clone a recorded value (undefined passes through; reactive proxies are read through). */
37
- function cloneValue<T>(v: T): T {
38
- return v === undefined || v === null ? v : (jsonClone(v as object) as T);
39
- }
40
-
41
37
  /** Forward/inverse pair for a single-key change on the node at path. */
42
38
  function setKeyPair(path: JxPath, key: string, before: unknown, after: unknown): JxDocOpPair {
43
39
  return {
@@ -81,26 +77,6 @@ export type JxNodeValue =
81
77
  | JxEventBinding
82
78
  | undefined;
83
79
 
84
- /**
85
- * The editable children array of a node, created when absent. Mapped-array children (`$prototype:
86
- * "Array"`) cannot be index-mutated — fail loudly instead of corrupting.
87
- */
88
- function childArray(node: JxMutableNode): (JxMutableNode | string)[] {
89
- // Defense-in-depth: a path that resolves to a children array (rather than a node) would
90
- // Otherwise get a bogus `.children` property tacked on here, silently storing the insert where
91
- // Nothing renders. Callers must pass a node; fail loudly if they don't.
92
- if (Array.isArray(node)) {
93
- throw new TypeError("Cannot insert into a children array; parentPath must point at a node");
94
- }
95
- if (!node.children) {
96
- node.children = [];
97
- }
98
- if (!Array.isArray(node.children)) {
99
- throw new TypeError("Cannot insert into mapped-array children; edit the map template instead");
100
- }
101
- return node.children;
102
- }
103
-
104
80
  // ─── Transactional layer ─────────────────────────────────────────────────────
105
81
 
106
82
  /**
@@ -152,7 +128,7 @@ export function transactDoc(
152
128
 
153
129
  if (verdict.patchable) {
154
130
  try {
155
- consumer!.apply(tab, record.ops);
131
+ consumer!.apply(tab, record.ops, record);
156
132
  } catch (error) {
157
133
  consumer!.escalate(
158
134
  `patch-apply-failed: ${error instanceof Error ? error.message : String(error)}`,
@@ -254,54 +230,6 @@ export function transact(
254
230
  /** Document-level keys whose changes require a full scope/panel rebuild on the canvas. */
255
231
  const DOC_META_KEYS = new Set(["state", "$media", "$head", "$elements", "imports", "$layout"]);
256
232
 
257
- /**
258
- * Apply a replayable doc op to a bare document tree (history replay — no canvas recording).
259
- *
260
- * @param {JxMutableNode} doc
261
- * @param {JxDocOp} op
262
- */
263
- function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp) {
264
- switch (op.op) {
265
- case "set-key": {
266
- const node = getNodeAtPath(doc, op.path);
267
- if (!node) {
268
- throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
269
- }
270
- if (op.value === undefined) {
271
- delete node[op.key];
272
- } else {
273
- node[op.key] = cloneValue(op.value) as JxNodeValue;
274
- }
275
- return;
276
- }
277
- case "insert-child": {
278
- const parent = getNodeAtPath(doc, op.parentPath);
279
- childArray(parent).splice(op.index, 0, cloneValue(op.node) as JxMutableNode);
280
- return;
281
- }
282
- case "remove-child": {
283
- const parent = getNodeAtPath(doc, op.parentPath);
284
- childArray(parent).splice(op.index, 1);
285
- return;
286
- }
287
- case "set-child": {
288
- const parent = getNodeAtPath(doc, op.parentPath);
289
- childArray(parent).splice(op.index, 1, cloneValue(op.node) as JxMutableNode);
290
- return;
291
- }
292
- case "move-child": {
293
- const fromParent = getNodeAtPath(doc, op.fromParentPath);
294
- const toParent = getNodeAtPath(doc, op.toParentPath);
295
- const [node] = childArray(fromParent).splice(op.fromIndex, 1);
296
- childArray(toParent).splice(op.toIndex, 0, node!);
297
- return;
298
- }
299
- default: {
300
- throw new Error(`unknown-doc-op:${(op as JxDocOp).op}`);
301
- }
302
- }
303
- }
304
-
305
233
  /**
306
234
  * Apply a doc op to the live document AND record the matching canvas patch op, so undo/redo
307
235
  * transactions patch the canvas surgically like any other edit.