@jxsuite/studio 0.37.1 → 1.1.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 (113) hide show
  1. package/dist/iframe-entry.js +622 -49
  2. package/dist/iframe-entry.js.map +15 -12
  3. package/dist/studio.css +1333 -0
  4. package/dist/studio.js +103709 -81001
  5. package/dist/studio.js.map +201 -157
  6. package/package.json +47 -45
  7. package/src/account-status.ts +39 -0
  8. package/src/browse/browse.ts +10 -14
  9. package/src/canvas/canvas-live-render.ts +21 -2
  10. package/src/canvas/canvas-render.ts +35 -16
  11. package/src/canvas/canvas-utils.ts +118 -72
  12. package/src/canvas/iframe-entry.ts +20 -0
  13. package/src/canvas/iframe-eval.ts +155 -0
  14. package/src/canvas/iframe-host.ts +132 -6
  15. package/src/canvas/iframe-inline-edit.ts +107 -1
  16. package/src/canvas/iframe-protocol.ts +43 -3
  17. package/src/canvas/iframe-render.ts +18 -0
  18. package/src/collab/collab-session.ts +23 -8
  19. package/src/collab/collab-state.ts +6 -3
  20. package/src/component-props.ts +104 -0
  21. package/src/editor/context-menu.ts +1 -1
  22. package/src/editor/inline-edit-apply.ts +36 -1
  23. package/src/editor/inline-edit.ts +58 -1
  24. package/src/editor/repeater-scope.ts +8 -13
  25. package/src/editor/shortcuts.ts +41 -12
  26. package/src/files/file-ops.ts +14 -0
  27. package/src/files/files.ts +56 -1
  28. package/src/format/format-host.ts +63 -1
  29. package/src/grid/cell-editors.ts +288 -0
  30. package/src/grid/cell-popovers.ts +108 -0
  31. package/src/grid/csv-codec.ts +122 -0
  32. package/src/grid/edit-buffer.ts +490 -0
  33. package/src/grid/grid-controller.ts +417 -0
  34. package/src/grid/grid-layout.ts +83 -0
  35. package/src/grid/grid-open.ts +167 -0
  36. package/src/grid/grid-panel.ts +302 -0
  37. package/src/grid/grid-source.ts +210 -0
  38. package/src/grid/grid-view.ts +367 -0
  39. package/src/grid/schema-columns.ts +261 -0
  40. package/src/grid/sources/connector-source.ts +217 -0
  41. package/src/grid/sources/content-source.ts +542 -0
  42. package/src/grid/sources/csv-file-source.ts +247 -0
  43. package/src/grid/tabulator-tables.d.ts +120 -0
  44. package/src/new-project/add-repo-modal.ts +183 -0
  45. package/src/new-project/new-project-modal.ts +22 -3
  46. package/src/page-params.ts +34 -8
  47. package/src/panels/ai-chat/chat-markdown.ts +2 -2
  48. package/src/panels/ai-panel.ts +61 -4
  49. package/src/panels/block-action-bar.ts +8 -3
  50. package/src/panels/chat-panel.ts +98 -0
  51. package/src/panels/data-grid.ts +241 -0
  52. package/src/panels/editors.ts +43 -17
  53. package/src/panels/events-panel.ts +137 -44
  54. package/src/panels/formula-workspace.ts +379 -0
  55. package/src/panels/frontmatter-fields.ts +231 -0
  56. package/src/panels/frontmatter-panel.ts +132 -0
  57. package/src/panels/git-panel.ts +1 -1
  58. package/src/panels/head-panel.ts +11 -175
  59. package/src/panels/properties-panel.ts +154 -144
  60. package/src/panels/right-panel.ts +9 -47
  61. package/src/panels/signals-panel.ts +217 -460
  62. package/src/panels/statement-editor.ts +710 -0
  63. package/src/panels/statusbar.ts +1 -1
  64. package/src/panels/style-panel.ts +25 -1
  65. package/src/panels/stylebook-panel.ts +0 -2
  66. package/src/panels/tab-bar.ts +175 -6
  67. package/src/panels/tab-strip.ts +62 -6
  68. package/src/panels/toolbar.ts +37 -3
  69. package/src/panels/welcome-screen.ts +50 -0
  70. package/src/platform-errors.ts +30 -0
  71. package/src/platforms/cloud.ts +172 -89
  72. package/src/platforms/devserver.ts +172 -0
  73. package/src/services/ai-project-tools.ts +541 -0
  74. package/src/services/ai-session-store.ts +28 -0
  75. package/src/services/ai-system-prompt.ts +194 -24
  76. package/src/services/ai-tools.ts +88 -21
  77. package/src/services/automation.ts +16 -0
  78. package/src/services/context-resolver.ts +73 -0
  79. package/src/services/data-service.ts +155 -0
  80. package/src/services/document-assistant.ts +81 -11
  81. package/src/services/gated-registry.ts +72 -0
  82. package/src/services/live-preview.ts +0 -0
  83. package/src/services/monaco-setup.ts +75 -26
  84. package/src/services/preview-eval.ts +68 -0
  85. package/src/services/project-adoption.ts +31 -0
  86. package/src/settings/contributed-section.ts +406 -0
  87. package/src/settings/extension-sections.ts +145 -0
  88. package/src/settings/schema-field-ui.ts +4 -2
  89. package/src/settings/settings-modal.ts +101 -42
  90. package/src/site-context.ts +12 -1
  91. package/src/store.ts +4 -0
  92. package/src/studio.ts +107 -52
  93. package/src/tabs/patch-ops.ts +25 -0
  94. package/src/tabs/tab.ts +39 -1
  95. package/src/tabs/transact.ts +61 -14
  96. package/src/types.ts +132 -1
  97. package/src/ui/dynamic-slot.ts +272 -0
  98. package/src/ui/expression-editor.ts +423 -125
  99. package/src/ui/field-row.ts +5 -0
  100. package/src/ui/form-controls.ts +322 -0
  101. package/src/ui/formula-catalog.ts +557 -0
  102. package/src/ui/formula-chips.ts +216 -0
  103. package/src/ui/formula-palette.ts +211 -0
  104. package/src/ui/layers.ts +40 -0
  105. package/src/ui/media-picker.ts +1 -1
  106. package/src/ui/panel-resize.ts +15 -1
  107. package/src/ui/progress-modal.ts +2 -2
  108. package/src/ui/schema-form.ts +524 -0
  109. package/src/utils/preview-format.ts +26 -0
  110. package/src/utils/studio-utils.ts +4 -3
  111. package/src/view.ts +4 -4
  112. package/src/workspace/workspace.ts +14 -0
  113. package/src/settings/content-types-editor.ts +0 -599
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.37.1",
3
+ "version": "1.1.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,63 +39,65 @@
39
39
  "@atlaskit/pragmatic-drag-and-drop": "^2.0.1",
40
40
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0",
41
41
  "@jxsuite/ai": "^0.33.0",
42
- "@jxsuite/collab": "^0.2.1",
43
- "@jxsuite/create": "^0.36.0",
44
- "@jxsuite/parser": "^0.35.1",
45
- "@jxsuite/protocol": "^0.3.1",
46
- "@jxsuite/runtime": "^0.34.2",
47
- "@jxsuite/schema": "^0.35.0",
48
- "@spectrum-web-components/accordion": "^1.12.1",
49
- "@spectrum-web-components/action-bar": "1.12.1",
50
- "@spectrum-web-components/action-button": "^1.12.1",
51
- "@spectrum-web-components/action-group": "^1.12.1",
52
- "@spectrum-web-components/base": "^1.12.1",
53
- "@spectrum-web-components/button": "^1.12.1",
54
- "@spectrum-web-components/checkbox": "^1.12.1",
55
- "@spectrum-web-components/color-area": "^1.12.1",
56
- "@spectrum-web-components/color-handle": "^1.12.1",
57
- "@spectrum-web-components/color-slider": "^1.12.1",
58
- "@spectrum-web-components/combobox": "^1.12.1",
59
- "@spectrum-web-components/dialog": "^1.12.1",
60
- "@spectrum-web-components/divider": "^1.12.1",
61
- "@spectrum-web-components/field-label": "^1.12.1",
62
- "@spectrum-web-components/help-text": "^1.12.1",
63
- "@spectrum-web-components/icon": "^1.12.1",
64
- "@spectrum-web-components/icons-ui": "^1.12.1",
65
- "@spectrum-web-components/icons-workflow": "^1.12.1",
66
- "@spectrum-web-components/menu": "^1.12.1",
67
- "@spectrum-web-components/number-field": "^1.12.1",
68
- "@spectrum-web-components/overlay": "^1.12.1",
42
+ "@jxsuite/collab": "^0.4.0",
43
+ "@jxsuite/create": "^1.1.0",
44
+ "@jxsuite/formulas": "^0.0.2",
45
+ "@jxsuite/markup": "^0.3.0",
46
+ "@jxsuite/protocol": "^0.5.0",
47
+ "@jxsuite/runtime": "^1.1.0",
48
+ "@jxsuite/schema": "^1.1.0",
49
+ "@spectrum-web-components/accordion": "^1.12.2",
50
+ "@spectrum-web-components/action-bar": "1.12.2",
51
+ "@spectrum-web-components/action-button": "^1.12.2",
52
+ "@spectrum-web-components/action-group": "^1.12.2",
53
+ "@spectrum-web-components/base": "^1.12.2",
54
+ "@spectrum-web-components/button": "^1.12.2",
55
+ "@spectrum-web-components/checkbox": "^1.12.2",
56
+ "@spectrum-web-components/color-area": "^1.12.2",
57
+ "@spectrum-web-components/color-handle": "^1.12.2",
58
+ "@spectrum-web-components/color-slider": "^1.12.2",
59
+ "@spectrum-web-components/combobox": "^1.12.2",
60
+ "@spectrum-web-components/dialog": "^1.12.2",
61
+ "@spectrum-web-components/divider": "^1.12.2",
62
+ "@spectrum-web-components/field-label": "^1.12.2",
63
+ "@spectrum-web-components/help-text": "^1.12.2",
64
+ "@spectrum-web-components/icon": "^1.12.2",
65
+ "@spectrum-web-components/icons-ui": "^1.12.2",
66
+ "@spectrum-web-components/icons-workflow": "^1.12.2",
67
+ "@spectrum-web-components/menu": "^1.12.2",
68
+ "@spectrum-web-components/number-field": "^1.12.2",
69
+ "@spectrum-web-components/overlay": "^1.12.2",
69
70
  "@spectrum-web-components/overlay-trigger": "^0.1.7",
70
- "@spectrum-web-components/picker": "^1.12.1",
71
- "@spectrum-web-components/picker-button": "^1.12.1",
72
- "@spectrum-web-components/popover": "^1.12.1",
73
- "@spectrum-web-components/progress-circle": "^1.12.1",
74
- "@spectrum-web-components/search": "^1.12.1",
75
- "@spectrum-web-components/swatch": "^1.12.1",
76
- "@spectrum-web-components/switch": "^1.12.1",
77
- "@spectrum-web-components/table": "^1.12.1",
78
- "@spectrum-web-components/tabs": "^1.12.1",
79
- "@spectrum-web-components/textfield": "^1.12.1",
80
- "@spectrum-web-components/theme": "^1.12.1",
81
- "@spectrum-web-components/toast": "^1.12.1",
82
- "@spectrum-web-components/tooltip": "^1.12.1",
83
- "@spectrum-web-components/underlay": "^1.12.1",
84
- "@vue/reactivity": "3.5.39",
71
+ "@spectrum-web-components/picker": "^1.12.2",
72
+ "@spectrum-web-components/picker-button": "^1.12.2",
73
+ "@spectrum-web-components/popover": "^1.12.2",
74
+ "@spectrum-web-components/progress-circle": "^1.12.2",
75
+ "@spectrum-web-components/search": "^1.12.2",
76
+ "@spectrum-web-components/swatch": "^1.12.2",
77
+ "@spectrum-web-components/switch": "^1.12.2",
78
+ "@spectrum-web-components/table": "^1.12.2",
79
+ "@spectrum-web-components/tabs": "^1.12.2",
80
+ "@spectrum-web-components/textfield": "^1.12.2",
81
+ "@spectrum-web-components/theme": "^1.12.2",
82
+ "@spectrum-web-components/toast": "^1.12.2",
83
+ "@spectrum-web-components/tooltip": "^1.12.2",
84
+ "@spectrum-web-components/underlay": "^1.12.2",
85
+ "@vue/reactivity": "3.5.40",
85
86
  "ajv": "^8.20.0",
86
87
  "ajv-formats": "^3.0.1",
87
88
  "lit": "^3.3.3",
88
89
  "lit-html": "^3.3.3",
89
90
  "monaco-editor": "^0.55.1",
91
+ "tabulator-tables": "^6.5",
90
92
  "y-monaco": "^0.1.6",
91
93
  "yaml": "^2.9.0"
92
94
  },
93
95
  "devDependencies": {
94
96
  "@happy-dom/global-registrator": "^20.10.6",
95
97
  "@types/mdast": "^4.0.4",
96
- "@webref/css": "^8.6.1",
98
+ "@webref/css": "^8.7.0",
97
99
  "@webref/elements": "^2.7.1",
98
- "@webref/idl": "^3.81.2"
100
+ "@webref/idl": "^3.82.0"
99
101
  },
100
102
  "//vue-reactivity": "Exact pin required — must match @jxsuite/runtime to avoid cross-boundary proxy bugs"
101
103
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Account onboarding status — a synchronous render cache over the optional
3
+ * `platform.getAccountStatus` PAL member (cloud GitHub-App installations). Hydrated once at boot
4
+ * (studio.ts) like the project-list cache; the welcome screen reads it synchronously to prompt
5
+ * "install the GitHub App" when the user has no repository access yet.
6
+ */
7
+ import { getPlatform, hasPlatform } from "./platform";
8
+ import type { AccountStatus } from "./types";
9
+
10
+ let cache: AccountStatus | null = null;
11
+
12
+ /** Refresh the cache from the platform; null when unsupported, failing, or unknown. */
13
+ export async function hydrateAccountStatus(): Promise<void> {
14
+ if (!hasPlatform() || typeof getPlatform().getAccountStatus !== "function") {
15
+ cache = null;
16
+ return;
17
+ }
18
+ try {
19
+ cache = (await getPlatform().getAccountStatus?.()) ?? null;
20
+ } catch {
21
+ // Onboarding prompts are progressive enhancement; unknown status never nags.
22
+ cache = null;
23
+ }
24
+ }
25
+
26
+ /** Synchronous snapshot for render paths (never awaits); null = unknown. */
27
+ export function getAccountStatus(): AccountStatus | null {
28
+ return cache;
29
+ }
30
+
31
+ /** True when the account verifiably has no repository access yet and we know where to fix it. */
32
+ export function needsAppInstall(): boolean {
33
+ return cache !== null && cache.installations.length === 0 && Boolean(cache.appInstallUrl);
34
+ }
35
+
36
+ /** Reset seam for tests. */
37
+ export function resetAccountStatus(): void {
38
+ cache = null;
39
+ }
@@ -34,7 +34,8 @@ import {
34
34
  } from "../format/format-host";
35
35
 
36
36
  import type { ComponentEntry } from "../files/components";
37
- import type { ContentTypeDef, JxDocument } from "@jxsuite/schema/types";
37
+ import type { ContentSectionEntry } from "../types";
38
+ import type { JxDocument } from "@jxsuite/schema/types";
38
39
 
39
40
  // ─── Category definitions ────────────────────────────────────────────────────
40
41
 
@@ -184,19 +185,16 @@ async function collectFiles(
184
185
  }
185
186
 
186
187
  /**
187
- * Match a file path against project contentTypes source directories to find its content type name.
188
- * Returns the content type name (capitalized) or null if no match.
188
+ * Match a file path against the project `content` section's source directories to find its content
189
+ * type name. Returns the content type name (capitalized) or null if no match.
189
190
  *
190
191
  * @param {string} filePath
191
192
  * @returns {string | null}
192
193
  */
193
194
  function contentTypeFor(filePath: string) {
194
195
  const config = projectState?.projectConfig;
195
- if (!config?.contentTypes) {
196
- return null;
197
- }
198
- for (const [name, def] of Object.entries(config.contentTypes)) {
199
- const d = def as ContentTypeDef;
196
+ const content = (config?.content ?? {}) as Record<string, ContentSectionEntry>;
197
+ for (const [name, d] of Object.entries(content)) {
200
198
  if (!d.source) {
201
199
  continue;
202
200
  }
@@ -264,7 +262,8 @@ function getEntityTypes() {
264
262
  */
265
263
  function buildFrontmatterYaml(contentTypeName: string) {
266
264
  const config = projectState?.projectConfig;
267
- const col = config?.contentTypes?.[contentTypeName];
265
+ const content = (config?.content ?? {}) as Record<string, ContentSectionEntry>;
266
+ const col = content[contentTypeName];
268
267
  if (!col?.schema?.properties) {
269
268
  return "title: Untitled\n";
270
269
  }
@@ -284,11 +283,8 @@ function buildFrontmatterYaml(contentTypeName: string) {
284
283
  */
285
284
  function getContentTypeTypes() {
286
285
  const config = projectState?.projectConfig;
287
- if (!config?.contentTypes) {
288
- return [];
289
- }
290
- return Object.entries(config.contentTypes).map(([name, def]) => {
291
- const d = def as ContentTypeDef;
286
+ const content = (config?.content ?? {}) as Record<string, ContentSectionEntry>;
287
+ return Object.entries(content).map(([name, d]) => {
292
288
  const dir = d.source ? d.source.replace(/^\.\//, "").replace(/\/$/, "") : name;
293
289
  return {
294
290
  contentTypeName: name,
@@ -26,6 +26,7 @@ import {
26
26
  resolveParamBoundState,
27
27
  substitutePreviewParams,
28
28
  } from "../page-params";
29
+ import { isComponentDoc, substitutePreviewProps } from "../component-props";
29
30
 
30
31
  import type { JxElement, JxMutableNode } from "@jxsuite/schema/types";
31
32
  import type { ComponentEntry } from "../files/components.js";
@@ -182,6 +183,17 @@ export async function resolveCanvasDocument(doc: JxMutableNode): Promise<{
182
183
  }
183
184
  }
184
185
 
186
+ // Component definition docs (non-page): seed chosen test-prop values into the render doc's state
187
+ // So a non-instantiated component previews with real data — templates, dataScope snapshots, and
188
+ // Live/snapshot expression previews all see the values (M6, the previewParams mirror). Pure
189
+ // Rebuild for the same reason as substitutePreviewParams above.
190
+ if (!isPage && tab && isComponentDoc(renderDoc)) {
191
+ const { previewProps } = tab.session.ui;
192
+ if (previewProps && Object.keys(previewProps).length > 0) {
193
+ renderDoc = substitutePreviewProps(renderDoc, previewProps);
194
+ }
195
+ }
196
+
185
197
  const arrayPaths = new Set<string>();
186
198
  if (canvasMode === "design" || canvasMode === "edit") {
187
199
  (function findArrayPaths(node: JxMutableNode, path: (string | number)[]) {
@@ -219,9 +231,16 @@ export async function resolveCanvasDocument(doc: JxMutableNode): Promise<{
219
231
  })(doc, []);
220
232
  }
221
233
 
222
- // Component auto-discovery (content mode or layout) mirrors the legacy render path.
234
+ // Component auto-discovery (content mode, wrapped layout, or a layout opened on its own)
235
+ // Mirrors the legacy render path. A directly-opened layout is neither a page (so it never
236
+ // Sets layoutWrapped) nor content mode (a plain .json file skips the mode-setting parse),
237
+ // So it needs its own gate; scoping to layouts/ avoids the single-component-edit path, where
238
+ // The doc's own root tag would otherwise inject a $ref to itself.
239
+ const isLayoutDoc =
240
+ S.documentPath != null &&
241
+ (S.documentPath.startsWith("layouts/") || S.documentPath.startsWith("./layouts/"));
223
242
  const effectiveElements = getEffectiveElements(renderDoc.$elements as (JxElement | string)[]);
224
- if ((S.mode === "content" || layoutWrapped) && componentRegistry.length > 0) {
243
+ if ((S.mode === "content" || layoutWrapped || isLayoutDoc) && componentRegistry.length > 0) {
225
244
  const existingRefs = new Set(
226
245
  effectiveElements.map((e: JxElement | string) => (typeof e === "string" ? e : e?.$ref)),
227
246
  );
@@ -15,15 +15,15 @@ import { attachCursorStyles } from "../collab/monaco-cursors";
15
15
  import type { AwarenessLike } from "../collab/monaco-cursors";
16
16
  import { view } from "../view";
17
17
  import { parseSourceForPath, serializeDocument } from "../files/file-ops";
18
+ import { detachGridPanel, gridPanelMounted, renderGridMode } from "../grid/grid-panel";
18
19
  import { formatByName, formatForPath } from "../format/format-host";
19
20
  import { renderWelcome } from "../panels/welcome-screen";
20
21
  import { projectState } from "../state";
21
22
  import {
23
+ applyEditZoom,
22
24
  applyTransform,
23
25
  canvasPanelTemplate,
24
26
  observeCenterUntilStable,
25
- renderZoomIndicator,
26
- resetZoomIndicator,
27
27
  updateActivePanelHeaders,
28
28
  } from "./canvas-utils";
29
29
  import { parseMediaEntries } from "../utils/canvas-media";
@@ -40,6 +40,7 @@ import { dismissBlockActionBar, dismissLinkPopover } from "../panels/block-actio
40
40
  import { dismissContextMenu } from "../editor/context-menu";
41
41
  import { dismissSlashMenu } from "../editor/slash-menu";
42
42
  import { renderFunctionEditor } from "../panels/editors";
43
+ import { renderFormulaWorkspace } from "../panels/formula-workspace";
43
44
  import { mediaDisplayName } from "../panels/shared";
44
45
  import { statusMessage } from "../panels/statusbar";
45
46
  import * as overlaysPanel from "../panels/overlays";
@@ -174,6 +175,7 @@ function resetCanvasView() {
174
175
  view.functionEditor.dispose();
175
176
  view.functionEditor = null;
176
177
  }
178
+ detachGridPanel();
177
179
  disposeSourceCollab();
178
180
  if (view.monacoEditor) {
179
181
  view.monacoEditor.getModel()?.dispose();
@@ -206,7 +208,6 @@ function resetCanvasView() {
206
208
  canvasWrap.style.flexDirection = "";
207
209
  canvasWrap.style.display = "";
208
210
  canvasWrap.style.overflow = "";
209
- resetZoomIndicator();
210
211
  dismissBlockActionBar();
211
212
  dismissLinkPopover();
212
213
  dismissContextMenu();
@@ -266,6 +267,13 @@ export function renderCanvas() {
266
267
  view.functionEditor = null;
267
268
  }
268
269
 
270
+ // Formula workspace mode: full-screen structured editing of an $expression (the function editor
271
+ // Takes precedence when both targets are set).
272
+ if (S.ui.editingFormula) {
273
+ renderFormulaWorkspace();
274
+ return;
275
+ }
276
+
269
277
  // Source mode: update existing Monaco editor without recreating. Don't replace the buffer while
270
278
  // The user is actively typing in it — that would reformat under the cursor (the source view is
271
279
  // The editing surface here, mirroring the panel draft-state behaviour).
@@ -287,6 +295,12 @@ export function renderCanvas() {
287
295
  return;
288
296
  }
289
297
 
298
+ // Grid fast-path: the grid panel runs its own effect scope (toolbar + engine stay live), so a
299
+ // Same-tab re-render while the panel is mounted needs nothing from the canvas pipeline.
300
+ if (canvasMode === "grid" && gridPanelMounted(tab)) {
301
+ return;
302
+ }
303
+
290
304
  // Stylebook fast-path: a style edit re-applies IN PLACE via the bridge (the iframe re-runs the
291
305
  // Runtime's style applier on the specimen root — real @media, no re-render, no iframe reload).
292
306
  // Filter/Customized changes fall through to the full rebuild (they change which specimens exist),
@@ -344,6 +358,9 @@ export function renderCanvas() {
344
358
  view.centerObserver = null;
345
359
  }
346
360
 
361
+ // Destroy the grid panel if switching away from grid mode
362
+ detachGridPanel();
363
+
347
364
  // Dispose Monaco editor if switching away from source mode
348
365
  disposeSourceCollab();
349
366
  if (view.monacoEditor) {
@@ -362,9 +379,6 @@ export function renderCanvas() {
362
379
  canvasWrap.style.overflow = "";
363
380
  canvasWrap.style.overflow = "";
364
381
 
365
- // Clear zoom indicator (only re-rendered by design/stylebook)
366
- resetZoomIndicator();
367
-
368
382
  // Dismiss open popovers/toolbars that are no longer relevant
369
383
  dismissBlockActionBar();
370
384
  dismissLinkPopover();
@@ -380,12 +394,20 @@ export function renderCanvas() {
380
394
  applyTransform,
381
395
  canvasPanelTemplate,
382
396
  observeCenterUntilStable,
383
- renderZoomIndicator,
384
397
  updateActivePanelHeaders,
385
398
  });
386
399
  return;
387
400
  }
388
401
 
402
+ // Grid mode: spreadsheet editor over the tab's grid source (mirrors the Monaco/stylebook
403
+ // Non-iframe-editor pattern; the panel owns its own reactivity from here).
404
+ if (canvasMode === "grid") {
405
+ canvasWrap.style.padding = "0";
406
+ canvasWrap.style.display = "block";
407
+ renderGridMode(canvasWrap, tab);
408
+ return;
409
+ }
410
+
389
411
  // Source mode: create Monaco editor instead of canvas
390
412
  if (canvasMode === "source") {
391
413
  canvasWrap.style.padding = "0";
@@ -599,18 +621,15 @@ export function renderCanvas() {
599
621
  if (modeChanged) {
600
622
  observeCenterUntilStable();
601
623
  }
602
- renderZoomIndicator();
603
624
  return;
604
625
  }
605
626
 
606
- // Edit (content) mode — centered column, no panzoom, always 100%
627
+ // Edit (content) mode — centered column, no panzoom; `ui.editZoom` reflows content at the zoomed
628
+ // Effective width while the on-screen footprint stays fixed (browser-page-zoom semantics).
607
629
  if (canvasMode === "edit") {
608
630
  if (modeChanged) {
609
631
  canvasWrap.style.padding = "0";
610
632
  canvasWrap.style.overflow = "hidden";
611
-
612
- // Remove zoom indicator left over from design mode
613
- resetZoomIndicator();
614
633
  }
615
634
 
616
635
  const { baseWidth } = parseMediaEntries(getEffectiveMedia(S.document.$media));
@@ -633,6 +652,10 @@ export function renderCanvas() {
633
652
  litRender(editTpl, canvasWrap);
634
653
  canvasPanels.push(panel as unknown as CanvasPanel);
635
654
  renderCanvasIntoPanel(panel as unknown as CanvasPanel, S.ui.featureToggles);
655
+ // The column must exist in the DOM before the zoom's live width measurement — so the zoom is
656
+ // Applied after the render rather than baked into the template (the panel mounts fluid and is
657
+ // Immediately re-fitted; the iframe hasn't painted yet, so nothing visibly jumps).
658
+ applyEditZoom();
636
659
  return;
637
660
  }
638
661
 
@@ -684,7 +707,6 @@ export function renderCanvas() {
684
707
  if (modeChanged) {
685
708
  observeCenterUntilStable();
686
709
  }
687
- renderZoomIndicator();
688
710
  return;
689
711
  }
690
712
 
@@ -747,9 +769,6 @@ export function renderCanvas() {
747
769
  if (modeChanged) {
748
770
  observeCenterUntilStable();
749
771
  }
750
-
751
- // Floating zoom indicator
752
- renderZoomIndicator();
753
772
  }
754
773
 
755
774
  /**
@@ -4,7 +4,7 @@
4
4
  * template creation, centering, transform application, zoom indicator, and fit-to-screen.
5
5
  */
6
6
 
7
- import { html, render as litRender, nothing } from "lit-html";
7
+ import { html, nothing } from "lit-html";
8
8
  import type { CanvasPanel } from "../types";
9
9
  import { ref } from "lit-html/directives/ref.js";
10
10
  import { classMap } from "lit-html/directives/class-map.js";
@@ -14,7 +14,6 @@ import { ifDefined } from "lit-html/directives/if-defined.js";
14
14
  import { canvasPanels, canvasWrap, renderOnly, updateUi } from "../store";
15
15
  import { activeTab } from "../workspace/workspace";
16
16
  import { view } from "../view";
17
- import { getLayerSlot } from "../ui/layers";
18
17
  import { findCanvasElement, getActivePanel, panelMediaToActiveMedia } from "./canvas-helpers";
19
18
  import { rectOf } from "../utils/geometry";
20
19
  import type { TemplateResult } from "lit-html";
@@ -25,8 +24,6 @@ let _ctx: {
25
24
  setZoomDirect: (zoom: number) => void;
26
25
  };
27
26
 
28
- let _zoomIndicatorEl: HTMLElement | null = null;
29
-
30
27
  /**
31
28
  * Initialize the canvas utils module.
32
29
  *
@@ -162,12 +159,127 @@ export function applyTransform() {
162
159
  }
163
160
  const zoom = _ctx.getZoom();
164
161
  view.panzoomWrap.style.transform = `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`;
165
- renderZoomIndicator();
166
162
  // Overlays live INSIDE the scaled panzoom-wrap (iframe hosts draw there), so no per-mode redraw
167
- // Is needed here — the flush only re-anchors the fixed block-action-bar.
163
+ // Is needed here — the flush only re-anchors the fixed block-action-bar. The tab-bar zoom widget
164
+ // Tracks `ui.zoom` reactively, so no explicit indicator refresh is needed either.
165
+ renderOnly("overlays");
166
+ }
167
+
168
+ // ─── Edit-mode content zoom ──────────────────────────────────────────────────
169
+ // Browser-page-zoom semantics: the canvas footprint stays fixed while the CONTENT reflows at the
170
+ // Zoomed effective width — unlike design mode's panzoom, which visually scales a fixed layout.
171
+
172
+ export const EDIT_ZOOM_MIN = 0.25;
173
+
174
+ export const EDIT_ZOOM_MAX = 3;
175
+
176
+ /** Clamp an edit-zoom value to the supported range. */
177
+ export function clampEditZoom(zoom: number): number {
178
+ return Math.min(EDIT_ZOOM_MAX, Math.max(EDIT_ZOOM_MIN, zoom));
179
+ }
180
+
181
+ /**
182
+ * Apply the active tab's edit-mode content zoom to the single edit panel.
183
+ *
184
+ * Mechanism: a parent-side transform on the iframe never reflows the iframe's internal document (it
185
+ * is its own layout viewport), so genuine reflow requires resizing the iframe's REAL CSS width to
186
+ * `renderWidth / editZoom` — and a compensating `scale(editZoom)` on the canvas element brings the
187
+ * rendered footprint back to exactly `renderWidth`. The overlay layer (the iframe's DOM sibling
188
+ * inside the canvas element) inherits the transform, so overlay boxes stay drawn at scale 1 (D-2),
189
+ * and `hostDragGeometry`'s empirical `rect.width / clientWidth` evaluates to exactly `editZoom`
190
+ * with no bridge changes.
191
+ *
192
+ * `renderWidth` is measured live from `.content-edit-column` (already `min(baseWidth, available)`
193
+ * via normal block layout) — the nominal `$media["--"]` width would overflow a narrow studio
194
+ * window.
195
+ *
196
+ * HARD INVARIANT: never trigger a canvas re-render from here (`renderCanvas`/`mountIframeCanvas`) —
197
+ * a re-render rebuilds the iframe DOM and would destroy a live inline-edit session. Live zoom is
198
+ * bare style writes only; the iframe's own ResizeObserver re-posts `contentHeight` after the
199
+ * reflow, which also finalizes the viewport height.
200
+ */
201
+ export function applyEditZoom() {
202
+ if (_ctx.getCanvasMode() !== "edit") {
203
+ return;
204
+ }
205
+ const [panel] = canvasPanels;
206
+ if (!panel?.canvas || !panel.viewport) {
207
+ return;
208
+ }
209
+ const canvasEl = panel.canvas;
210
+ const iframe = canvasEl.querySelector("iframe");
211
+ const editZoom = activeTab.value?.session.ui.editZoom ?? 1;
212
+ if (editZoom === 1) {
213
+ // Exactly today's fluid behavior — no inline width, no transform, auto viewport height.
214
+ panel._width = null;
215
+ canvasEl.style.width = "";
216
+ canvasEl.style.transform = "";
217
+ canvasEl.style.transformOrigin = "";
218
+ if (iframe) {
219
+ iframe.style.width = "100%";
220
+ }
221
+ panel.viewport.style.height = "";
222
+ } else {
223
+ const column = canvasWrap.querySelector<HTMLElement>(".content-edit-column");
224
+ if (!column) {
225
+ return;
226
+ }
227
+ // The column width is parent-driven (width:100%, max-width:baseWidth), so it is independent of
228
+ // The canvas content — measuring it mid-zoom is stable. No rounding: layoutWidth * editZoom
229
+ // Must equal renderWidth exactly.
230
+ const renderWidth = rectOf(column).width;
231
+ if (renderWidth <= 0) {
232
+ return;
233
+ }
234
+ const layoutWidth = renderWidth / editZoom;
235
+ panel._width = layoutWidth;
236
+ canvasEl.style.width = `${layoutWidth}px`;
237
+ canvasEl.style.transform = `scale(${editZoom})`;
238
+ canvasEl.style.transformOrigin = "top left";
239
+ if (iframe) {
240
+ iframe.style.width = `${layoutWidth}px`;
241
+ // Transforms don't affect an ancestor's auto-height, so the viewport (the white "page"
242
+ // Surface) needs its height pinned to the SCALED content height. offsetHeight is the
243
+ // Pre-measurement approximation; the next contentHeight message writes the exact value.
244
+ panel.viewport.style.height = `${iframe.offsetHeight * editZoom}px`;
245
+ }
246
+ }
247
+ // Re-anchor the fixed block-action-bar over the rescaled canvas.
168
248
  renderOnly("overlays");
169
249
  }
170
250
 
251
+ /** Set the active tab's edit zoom (clamped) and apply it synchronously. */
252
+ export function setEditZoom(zoom: number) {
253
+ const tab = activeTab.value;
254
+ if (!tab) {
255
+ return;
256
+ }
257
+ tab.session.ui.editZoom = clampEditZoom(zoom);
258
+ applyEditZoom();
259
+ }
260
+
261
+ let _editZoomRaf = 0;
262
+
263
+ /**
264
+ * Wheel-rate edit-zoom setter: the reactive `editZoom` write lands immediately (the tab-bar label
265
+ * tracks it), but the DOM work — an iframe width resize, i.e. a real reflow — is coalesced to one
266
+ * `applyEditZoom()` per animation frame so a fast ctrl+scroll burst doesn't thrash layout.
267
+ */
268
+ export function requestEditZoom(zoom: number) {
269
+ const tab = activeTab.value;
270
+ if (!tab) {
271
+ return;
272
+ }
273
+ tab.session.ui.editZoom = clampEditZoom(zoom);
274
+ if (_editZoomRaf) {
275
+ return;
276
+ }
277
+ _editZoomRaf = requestAnimationFrame(() => {
278
+ _editZoomRaf = 0;
279
+ applyEditZoom();
280
+ });
281
+ }
282
+
171
283
  /** Calculate zoom + pan to fit all panels within the viewport. */
172
284
  export function fitToScreen() {
173
285
  if (!view.panzoomWrap) {
@@ -209,12 +321,6 @@ export function resetZoom() {
209
321
  _ctx.setZoomDirect(1);
210
322
  centerCanvas();
211
323
  applyTransform();
212
- renderZoomIndicator();
213
- }
214
-
215
- /** Reset the zoom indicator (clear its content). Called when switching to non-panzoom modes. */
216
- export function resetZoomIndicator() {
217
- litRender(nothing, getLayerSlot("popover", "zoom-indicator"));
218
324
  }
219
325
 
220
326
  /**
@@ -286,66 +392,6 @@ export function panToElement(path: (string | number)[]) {
286
392
  _panToEl(el, panel);
287
393
  }
288
394
 
289
- /**
290
- * Render the floating zoom indicator at the bottom center of canvas-wrap. Uses position: fixed,
291
- * computed from canvas-wrap bounds.
292
- */
293
- export function renderZoomIndicator() {
294
- const zoom = _ctx.getZoom();
295
- const host = getLayerSlot("popover", "zoom-indicator");
296
- litRender(
297
- html`
298
- <div
299
- class="zoom-indicator"
300
- ${ref((el: Element | undefined) => {
301
- _zoomIndicatorEl = (el as HTMLElement) || null;
302
- })}
303
- >
304
- <span class="zoom-indicator-action" title="Reset to 100%" @click=${resetZoom}>
305
- <svg
306
- width="14"
307
- height="14"
308
- viewBox="0 0 16 16"
309
- fill="none"
310
- stroke="currentColor"
311
- stroke-width="1.5"
312
- >
313
- <circle cx="8" cy="8" r="5.5" />
314
- <path d="M8 5.5v5M5.5 8h5" />
315
- </svg>
316
- </span>
317
- <span class="zoom-indicator-label">${Math.round(zoom * 100)}%</span>
318
- <span class="zoom-indicator-action" title="Fit to screen" @click=${fitToScreen}>
319
- <svg
320
- width="14"
321
- height="14"
322
- viewBox="0 0 16 16"
323
- fill="none"
324
- stroke="currentColor"
325
- stroke-width="1.5"
326
- >
327
- <rect x="2" y="2" width="12" height="12" rx="1" />
328
- <path d="M2 6h12M6 2v12" />
329
- </svg>
330
- </span>
331
- </div>
332
- `,
333
- host,
334
- );
335
- positionZoomIndicator();
336
- }
337
-
338
- /** Position the zoom indicator relative to canvas-wrap bounds. */
339
- export function positionZoomIndicator() {
340
- if (!_zoomIndicatorEl) {
341
- return;
342
- }
343
- const rect = rectOf(canvasWrap);
344
- _zoomIndicatorEl.style.left = `${rect.left + rect.width / 2}px`;
345
- _zoomIndicatorEl.style.top = `${rect.bottom - 32}px`;
346
- _zoomIndicatorEl.style.transform = "translateX(-50%)";
347
- }
348
-
349
395
  /** Toggle "active" class on canvas panel headers based on activeMedia. */
350
396
  export function updateActivePanelHeaders() {
351
397
  const activeMedia = activeTab.value?.session.ui.activeMedia ?? null;