@jxsuite/studio 3.0.0 → 4.0.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 (173) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
  3. package/dist/chunks/{cf-settings-zj2cj1ar.js → cf-settings-45fgyx8y.js} +6 -5
  4. package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
  5. package/dist/chunks/content-source-t88vb2dr.js +49 -0
  6. package/dist/chunks/{convert-to-repeater-6vvx96n2.js → convert-to-repeater-dhw1nrz9.js} +27 -19
  7. package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
  8. package/dist/chunks/{extension-sections-kwzvpemr.js → extension-sections-3ydgjbdy.js} +32 -24
  9. package/dist/chunks/file-ops-rskmhxyq.js +48 -0
  10. package/dist/chunks/files-0cratsr0.js +92 -0
  11. package/dist/chunks/{format-host-8fr3545e.js → format-host-tgcmfs4p.js} +3 -3
  12. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  13. package/dist/chunks/{git-panel-g01d93vc.js → git-panel-0yywyk76.js} +21 -16
  14. package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8q2g05x1.js} +6 -4
  15. package/dist/chunks/layers-panel-z3tqe5cd.js +67 -0
  16. package/dist/chunks/layers-q75tj2mf.js +45 -0
  17. package/dist/chunks/lit-html-j055mkh8.js +22 -0
  18. package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
  19. package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-rws7k4br.js} +4 -4
  20. package/dist/chunks/notify-yjm79n4s.js +30 -0
  21. package/dist/chunks/parse-24d0gay3.js +19 -0
  22. package/dist/chunks/platform-ba1j1gbh.js +16 -0
  23. package/dist/chunks/publish-panel-54jvhskq.js +28 -0
  24. package/dist/chunks/quick-search-cgfmjwq5.js +65 -0
  25. package/dist/chunks/references-3qx8fkyp.js +29 -0
  26. package/dist/chunks/right-panel-cqp8bc4v.js +46 -0
  27. package/dist/chunks/sp-overlay.dev-yztmtrs1.js +18 -0
  28. package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
  29. package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
  30. package/dist/chunks/sp-tooltip.dev-xcvpnqr0.js +15 -0
  31. package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
  32. package/dist/chunks/{studio-8ezdegcx.js → studio-1hbqnt19.js} +36 -32
  33. package/dist/chunks/{studio-r5xbtctf.js → studio-2zkz8evn.js} +446 -2451
  34. package/dist/chunks/{studio-pkp61mh8.js → studio-4pqqxbrt.js} +23 -15
  35. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  36. package/dist/chunks/{studio-3dkcd4c5.js → studio-5q69gfag.js} +60 -56
  37. package/dist/chunks/{studio-rh1cnamv.js → studio-5rywrkxz.js} +563 -133
  38. package/dist/chunks/{studio-6kv3apa9.js → studio-68n67094.js} +25 -9
  39. package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
  40. package/dist/chunks/{studio-xntfdy1x.js → studio-8swrjbny.js} +31 -18
  41. package/dist/chunks/studio-92ab2jqe.js +282 -0
  42. package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
  43. package/dist/chunks/{studio-ry1vz5sv.js → studio-9c4k4vhv.js} +15 -13
  44. package/dist/chunks/studio-9n4mrmnb.js +184 -0
  45. package/dist/chunks/{studio-tr20m8g8.js → studio-9r6j5ev1.js} +37 -2
  46. package/dist/chunks/{studio-2x9zbxmx.js → studio-ahjfdy6e.js} +9 -7
  47. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  48. package/dist/chunks/{studio-dc8s8heb.js → studio-cb12yz9w.js} +8528 -8780
  49. package/dist/chunks/studio-cft9r4r7.js +119 -0
  50. package/dist/chunks/{studio-hcxzyk3x.js → studio-eenfkt4e.js} +6 -6
  51. package/dist/chunks/studio-eesa1myv.js +498 -0
  52. package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
  53. package/dist/chunks/{studio-7p35254k.js → studio-ex8zyp98.js} +30 -26
  54. package/dist/chunks/studio-gv8vvshc.js +118 -0
  55. package/dist/chunks/studio-hbtp0eh1.js +1315 -0
  56. package/dist/chunks/{studio-a0yp7syn.js → studio-htsf0w6m.js} +29 -114
  57. package/dist/chunks/{studio-j595e748.js → studio-kjjsjak4.js} +139 -63
  58. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  59. package/dist/chunks/{studio-kfpek536.js → studio-kyh9avvv.js} +2 -2
  60. package/dist/chunks/{studio-g5gdqnyd.js → studio-kyk2v92a.js} +14 -14
  61. package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
  62. package/dist/chunks/{studio-tzx3xmvx.js → studio-mmv72a27.js} +8 -8
  63. package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
  64. package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
  65. package/dist/chunks/{studio-p5dja96s.js → studio-p8j86wag.js} +2 -2
  66. package/dist/chunks/{studio-6z5eyjwn.js → studio-pdfjntbk.js} +66 -129
  67. package/dist/chunks/studio-q43a5y9h.js +394 -0
  68. package/dist/chunks/studio-q7v10cg2.js +106 -0
  69. package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
  70. package/dist/chunks/studio-r1r7afxx.js +1066 -0
  71. package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
  72. package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
  73. package/dist/chunks/studio-sf7zjgv1.js +91 -0
  74. package/dist/chunks/studio-t7qb9db8.js +665 -0
  75. package/dist/chunks/studio-tmc5emrw.js +8 -0
  76. package/dist/chunks/{studio-wdt9y92a.js → studio-txwkm6p9.js} +2 -2
  77. package/dist/chunks/{studio-k4jvjcfx.js → studio-v38hmvx1.js} +7 -7
  78. package/dist/chunks/{studio-7ejemhej.js → studio-vadzgkrz.js} +6 -6
  79. package/dist/chunks/studio-wcy8trfx.js +263 -0
  80. package/dist/chunks/{studio-5efrtn1m.js → studio-wx5609xd.js} +21 -17
  81. package/dist/chunks/{studio-qpfq7xhm.js → studio-xrapxwwc.js} +22 -285
  82. package/dist/chunks/{studio-hj2se583.js → studio-yxrkyv8a.js} +41 -31
  83. package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
  84. package/dist/chunks/workspace-xpres40v.js +89 -0
  85. package/dist/iframe-entry.js +108 -24
  86. package/dist/studio.js +2362 -1695
  87. package/package.json +16 -15
  88. package/src/browse/library-pane.ts +57 -3
  89. package/src/canvas/canvas-render.ts +23 -1
  90. package/src/canvas/iframe-patch.ts +47 -21
  91. package/src/canvas/iframe-render.ts +22 -6
  92. package/src/commands/app-commands.ts +4 -0
  93. package/src/commands/command-args.ts +29 -0
  94. package/src/commands/context.ts +15 -1
  95. package/src/commands/defaults.ts +29 -1
  96. package/src/commands/levels.ts +16 -0
  97. package/src/commands/live-context.ts +3 -0
  98. package/src/content/collection-match.ts +267 -0
  99. package/src/content/entry-commands.ts +38 -29
  100. package/src/content/entry-fields.ts +6 -0
  101. package/src/editor/shortcuts.ts +5 -0
  102. package/src/files/file-ops.ts +10 -42
  103. package/src/files/files.ts +485 -59
  104. package/src/files/fs-events.ts +9 -0
  105. package/src/files/gitignore.ts +432 -0
  106. package/src/files/serialize-document.ts +60 -0
  107. package/src/format/convert-file.ts +511 -0
  108. package/src/format/format-choices.ts +199 -0
  109. package/src/grid/grid-view.ts +16 -2
  110. package/src/media/media-open.ts +58 -0
  111. package/src/media/media-pane.ts +310 -0
  112. package/src/new-project/import-tab.ts +332 -160
  113. package/src/new-project/new-project-modal.ts +27 -34
  114. package/src/packages/ensure-deps.ts +14 -7
  115. package/src/packages/jxsuite-update.ts +11 -11
  116. package/src/page-params.ts +10 -46
  117. package/src/panels/activity-bar.ts +45 -16
  118. package/src/panels/ai-chat/attached-context.ts +6 -1
  119. package/src/panels/ai-chat/chat-view.ts +255 -13
  120. package/src/panels/ai-chat/composer.ts +21 -97
  121. package/src/panels/ai-panel.ts +116 -2
  122. package/src/panels/navigator-panels.ts +7 -6
  123. package/src/panels/settings-menu.ts +799 -0
  124. package/src/panels/toolbar.ts +135 -15
  125. package/src/platforms/cloud.ts +20 -0
  126. package/src/platforms/devserver.ts +67 -33
  127. package/src/preview/preview-overlay.ts +226 -0
  128. package/src/services/ai-ask.ts +247 -0
  129. package/src/services/ai-import-tools.ts +494 -0
  130. package/src/services/ai-project-tools.ts +20 -26
  131. package/src/services/ai-system-prompt.ts +88 -2
  132. package/src/services/ai-turn-signal.ts +70 -0
  133. package/src/services/connection.ts +74 -0
  134. package/src/services/context-manager.ts +89 -0
  135. package/src/services/document-assistant.ts +45 -4
  136. package/src/services/import-client.ts +33 -6
  137. package/src/services/import-run.ts +158 -0
  138. package/src/services/import-seed.ts +120 -0
  139. package/src/services/jx-validate.ts +58 -6
  140. package/src/services/project-adoption.ts +71 -0
  141. package/src/services/references.ts +0 -0
  142. package/src/services/settings/definitions.ts +17 -0
  143. package/src/services/tool-executor.ts +34 -2
  144. package/src/settings/contexts-section.ts +45 -3
  145. package/src/settings/dependencies-editor.ts +43 -23
  146. package/src/settings/general-settings.ts +30 -12
  147. package/src/settings/preferences-dialog.ts +18 -41
  148. package/src/settings/preferences-sections.ts +57 -0
  149. package/src/settings/settings-document.ts +65 -9
  150. package/src/studio.ts +33 -6
  151. package/src/tabs/transact.ts +42 -19
  152. package/src/types.ts +103 -5
  153. package/src/ui/ai-model-picker.ts +162 -0
  154. package/src/ui/choice-field.ts +72 -0
  155. package/src/ui/layers.ts +83 -5
  156. package/src/utils/studio-utils.ts +22 -52
  157. package/styles/forced-colors.css +7 -0
  158. package/styles/overlays.css +256 -0
  159. package/styles/panels.css +13 -1
  160. package/styles/shell.css +111 -0
  161. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  162. package/dist/chunks/content-source-7fy9qrm0.js +0 -40
  163. package/dist/chunks/files-xcybanxe.js +0 -70
  164. package/dist/chunks/layers-panel-27essgkc.js +0 -60
  165. package/dist/chunks/publish-panel-09sy2bab.js +0 -22
  166. package/dist/chunks/quick-search-sefb61v8.js +0 -55
  167. package/dist/chunks/right-panel-9r32jd5j.js +0 -39
  168. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  169. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  170. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  171. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  172. package/dist/chunks/studio-c31yb4t6.js +0 -55
  173. package/src/canvas/site-style-css.ts +0 -117
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -52,19 +52,20 @@
52
52
  "lint:styles": "bun run scripts/check-styles.ts",
53
53
  "test": "bun run lint:styles && bun test --isolate",
54
54
  "test:coverage": "bun test --isolate --coverage",
55
- "upgrade": "bunx npm-check-updates -u && bun install"
55
+ "upgrade": "bun update --latest && bun install"
56
56
  },
57
57
  "dependencies": {
58
58
  "@atlaskit/pragmatic-drag-and-drop": "^3.0.0",
59
59
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.1.0",
60
- "@jxsuite/ai": "^0.36.4",
61
- "@jxsuite/collab": "^0.8.3",
62
- "@jxsuite/create": "^1.3.7",
63
- "@jxsuite/formulas": "^0.0.14",
64
- "@jxsuite/markup": "^0.4.7",
65
- "@jxsuite/protocol": "^2.0.0",
66
- "@jxsuite/runtime": "^2.1.0",
67
- "@jxsuite/schema": "^1.9.0",
60
+ "@jxsuite/ai": "^0.36.5",
61
+ "@jxsuite/collab": "^0.8.4",
62
+ "@jxsuite/create": "^1.3.8",
63
+ "@jxsuite/formulas": "^0.0.15",
64
+ "@jxsuite/markup": "^0.4.8",
65
+ "@jxsuite/protocol": "^2.1.0",
66
+ "@jxsuite/runtime": "^3.0.0",
67
+ "@jxsuite/schema": "^2.0.0",
68
+ "@jxsuite/site": "^1.0.0",
68
69
  "@spectrum-web-components/accordion": "^1.12.2",
69
70
  "@spectrum-web-components/action-bar": "1.12.2",
70
71
  "@spectrum-web-components/action-button": "^1.12.2",
@@ -107,15 +108,15 @@
107
108
  "lit": "^3.3.3",
108
109
  "lit-html": "^3.3.3",
109
110
  "monaco-editor": "^0.56.0",
110
- "tabulator-tables": "^6.5",
111
+ "tabulator-tables": "^6.5.2",
111
112
  "yaml": "^2.9.0"
112
113
  },
113
114
  "devDependencies": {
114
- "@happy-dom/global-registrator": "^20.11.6",
115
+ "@happy-dom/global-registrator": "^20.11.8",
115
116
  "@types/mdast": "^4.0.4",
116
- "@webref/css": "^8.7.2",
117
- "@webref/elements": "^2.7.1",
118
- "@webref/idl": "^3.82.2"
117
+ "@webref/css": "^8.7.3",
118
+ "@webref/elements": "^2.8.0",
119
+ "@webref/idl": "^3.83.0"
119
120
  },
120
121
  "//vue-reactivity": "Exact pin required — must match @jxsuite/runtime to avoid cross-boundary proxy bugs"
121
122
  }
@@ -42,6 +42,7 @@ import { invalidateUsages } from "../services/references";
42
42
  import { confirmFileDelete, renamePromptMessage } from "../files/file-ops";
43
43
  import { createFileIn, openFileInTab } from "../files/files";
44
44
  import { createEntry } from "../content/entry-commands";
45
+ import { formatByExtension, formatSerialize } from "../format/format-host";
45
46
  import { entryCollections } from "../content/entry-model";
46
47
  import { uploadAccept, uploadAssets } from "../files/media-upload";
47
48
  import { localeLabel } from "@jxsuite/schema/locale";
@@ -70,6 +71,7 @@ import {
70
71
  tableRowsTpl,
71
72
  } from "./library-layouts";
72
73
  import type { ActivityHandle } from "../panels/activity-panel";
74
+ import type { FormatChoice } from "../files/files";
73
75
  import type { EffectScope } from "@vue/reactivity";
74
76
  import type { LibraryFile, LibraryLayout } from "./library-model";
75
77
  import type { LibrarySource } from "./library-source";
@@ -602,10 +604,60 @@ export function libraryNewEntries(): LibraryNewEntry[] {
602
604
  * `createEntry` is called rather than reimplemented; a second seeder here would drift from the one
603
605
  * the palette's `content.newEntry` uses within a release.
604
606
  *
605
- * A **document kind** keeps `files.ts`'s verbatim name field, deliberately: a page may be a `.md`
606
- * as easily as a `.json`, so the extension the author types is a real choice and forcing one here
607
- * would remove it. A collection's extension is not a choice — it is the collection's.
607
+ * A **document kind** gets the format PICKER, which is the same argument the verbatim name field
608
+ * used to make: a page may be a `.md` as easily as a `.json`, so the extension is a real choice —
609
+ * now offered rather than left to be typed from memory. A collection's extension is not a choice;
610
+ * it is the collection's, and `createEntry` supplies it.
611
+ *
612
+ * **A layout is not a choice either, and that is not a policy.** Both readers of a layout parse it
613
+ * as JSON and neither dispatches through the format registry — `site/layout-resolver.ts` throws and
614
+ * `site-context.ts` returns null — so a `.md` layout is a file the build cannot load. There is no
615
+ * `"layout"` document kind for a format to declare, so this is stated here rather than derived.
616
+ *
617
+ * A **component** keeps the picker: a format whose `$studio.documentMode.componentWhen` promotes a
618
+ * frontmatter `tagName` (Markdown's does) can express one perfectly well. What it cannot do is
619
+ * express one from a `newFileTemplate` that carries no `tagName`, so the seed below supplies it —
620
+ * through the format's own serializer, because this module knows no format's syntax.
621
+ */
622
+ /** How each document kind's extension is settled. Anything else falls back to the full picker. */
623
+ const FORMAT_CHOICE_BY_KIND: Record<string, FormatChoice> = {
624
+ component: { defaultExt: ".json", docKind: "component", kind: "choose" },
625
+ layout: { ext: ".json", kind: "fixed" },
626
+ page: { defaultExt: ".json", docKind: "page", kind: "choose" },
627
+ };
628
+
629
+ /**
630
+ * The body a new component starts as, when the reader picked a format rather than `.json`.
631
+ *
632
+ * `.json` answers `undefined` and takes the shared blank document. A format answers with its OWN
633
+ * serialization of `{ tagName }` — never a hand-written `---\ntagName: …\n---`, which would put one
634
+ * format's syntax in the Library. Without the `tagName` the file satisfies neither
635
+ * `$studio.documentMode.componentWhen` nor the build's tag registration, and is a component nothing
636
+ * loads. A stem that is not a valid custom-element name (no hyphen) cannot carry one, so it falls
637
+ * back to the format's own template and the author names the tag themselves.
608
638
  */
639
+ async function componentSeed(ext: string, fileName: string): Promise<string | undefined> {
640
+ const format = formatByExtension(ext);
641
+ if (!format?.capabilities.serialize) {
642
+ return undefined;
643
+ }
644
+ const tagName = fileName.slice(0, fileName.length - ext.length);
645
+ if (!/^[a-z][\d.a-z]*-[\d.a-z-]*$/.test(tagName)) {
646
+ return undefined;
647
+ }
648
+ try {
649
+ return await formatSerialize(
650
+ format.name,
651
+ { children: [], tagName },
652
+ { frontmatter: true, mode: "roundtrip" },
653
+ );
654
+ } catch {
655
+ // The serializer is the project's, so its failure is not a reason to refuse the creation —
656
+ // The format's own template still produces a file the author can finish by hand.
657
+ return undefined;
658
+ }
659
+ }
660
+
609
661
  export async function createLibraryEntry(key: string): Promise<string | null> {
610
662
  const entry = libraryNewEntries().find((row) => row.key === key);
611
663
  if (!entry) {
@@ -614,7 +666,9 @@ export async function createLibraryEntry(key: string): Promise<string | null> {
614
666
  const created =
615
667
  entry.collection === undefined
616
668
  ? await createFileIn({
669
+ ...(entry.key === "component" ? { content: componentSeed } : {}),
617
670
  dir: entry.dir,
671
+ format: FORMAT_CHOICE_BY_KIND[entry.key] ?? { defaultExt: ".json", kind: "choose" },
618
672
  source: "Library",
619
673
  suggestedName: "untitled",
620
674
  title: `New ${entry.label}`,
@@ -37,10 +37,12 @@ import { attachCursorStyles } from "../collab/monaco-cursors";
37
37
  import type { AwarenessLike } from "../collab/monaco-cursors";
38
38
  import type { BindingAwareness } from "../collab/monaco-binding";
39
39
  import { monacoTheme, shell } from "../shell";
40
- import { parseSourceForPath, serializeDocument } from "../files/file-ops";
40
+ import { parseSourceForPath } from "../files/file-ops";
41
+ import { serializeDocument } from "../files/serialize-document";
41
42
  import { detachGridPanel, gridPanelMounted, renderGridMode } from "../grid/grid-panel";
42
43
  import { detachLibraryPane, libraryPaneMounted, renderLibraryMode } from "../browse/library-pane";
43
44
  import { detachEntryPane, entryPaneMounted, renderEntryMode } from "../content/entry-editor";
45
+ import { detachMediaPane, mediaPaneMounted, renderMediaMode } from "../media/media-pane";
44
46
  import {
45
47
  detachSettingsPane,
46
48
  renderSettingsPane,
@@ -370,6 +372,7 @@ function resetCanvasView(surface: CanvasSurface) {
370
372
  detachGridPanel(paneId);
371
373
  detachLibraryPane(paneId);
372
374
  detachEntryPane(paneId);
375
+ detachMediaPane(paneId);
373
376
  detachSettingsPane(paneId);
374
377
  disposeSourceCollab(surface);
375
378
  disposeSourceEditor(surface);
@@ -883,6 +886,12 @@ function renderCanvasImpl(surface: CanvasSurface) {
883
886
  return;
884
887
  }
885
888
 
889
+ // Media fast-path, same shape: the viewer owns its own effect, so learning an image's dimensions
890
+ // Repaints that panel and nothing reaches the canvas pipeline.
891
+ if (canvasMode === "media" && mediaPaneMounted(surface.paneId, tab) && !modeChanged) {
892
+ return;
893
+ }
894
+
886
895
  // Settings fast-path, same shape: the Project Settings editor subscribes to the section registry
887
896
  // And to its own chosen section, so a mounted editor needs nothing from the canvas pipeline.
888
897
  if (canvasMode === "settings" && settingsPaneMounted(surface) && !modeChanged) {
@@ -953,6 +962,9 @@ function renderCanvasImpl(surface: CanvasSurface) {
953
962
  // Same for the entry form, whose effect scope subscribes to the document's frontmatter
954
963
  detachEntryPane(surface.paneId);
955
964
 
965
+ // And for the media viewer, whose effect scope subscribes to the tab's path
966
+ detachMediaPane(surface.paneId);
967
+
956
968
  // Same for the Project Settings editor, which holds a registry subscription
957
969
  detachSettingsPane(surface.paneId);
958
970
 
@@ -1068,6 +1080,15 @@ function renderCanvasImpl(surface: CanvasSurface) {
1068
1080
  return;
1069
1081
  }
1070
1082
 
1083
+ /* Media mode: an image, a video, a font specimen — a file the studio can SHOW but has no document
1084
+ model for (`media/media-pane.ts`). Same non-iframe-editor pattern as the four above. */
1085
+ if (canvasMode === "media") {
1086
+ canvasWrap.style.padding = "0";
1087
+ canvasWrap.style.display = "block";
1088
+ renderMediaMode(surface, tab);
1089
+ return;
1090
+ }
1091
+
1071
1092
  // Grid mode: spreadsheet editor over the tab's grid source (mirrors the Monaco/stylebook
1072
1093
  // Non-iframe-editor pattern; the panel owns its own reactivity from here).
1073
1094
  if (canvasMode === "grid") {
@@ -1508,6 +1529,7 @@ setSurfaceTeardown((surface) => {
1508
1529
  detachGridPanel(surface.paneId);
1509
1530
  detachLibraryPane(surface.paneId);
1510
1531
  detachEntryPane(surface.paneId);
1532
+ detachMediaPane(surface.paneId);
1511
1533
  detachSettingsPane(surface.paneId);
1512
1534
  attachDocumentHeaderHost(surface.paneId, null);
1513
1535
  disposeSourceCollab(surface);
@@ -56,12 +56,38 @@ export function applyIframePatch(
56
56
  const echoed = new Set(echoPaths.map((p) => serializeJxPath(p)));
57
57
  for (const op of forwardOps) {
58
58
  if (op.op === "set-key" && echoed.has(serializeJxPath(op.path))) {
59
+ // The content is already right; only the model-derived placeholder class can be stale, and
60
+ // Reconciling a class touches neither the caret nor the text under it.
61
+ syncEchoedPlaceholder(shadowDoc, op.path, container);
59
62
  continue;
60
63
  }
61
64
  applyOpToDom(shadowDoc, op, container, ctx);
62
65
  }
63
66
  }
64
67
 
68
+ /**
69
+ * Re-derive the empty-placeholder class of an ECHOED node without writing its content.
70
+ *
71
+ * An echo is the author's own typing coming back as a patch, so the DOM already holds the text —
72
+ * but `empty-text-placeholder` was stamped when the block was empty and nothing else would ever
73
+ * take it off, because the write that would have (patchText) is exactly what echo suppression
74
+ * skips. The CSS asks the DOM whether the block is empty, so nothing is misdrawn in the meantime;
75
+ * this keeps the class itself honest, so the DOM does not carry a claim the document contradicts.
76
+ *
77
+ * Best-effort by design: a missing element is not an error worth escalating a full render (and the
78
+ * caret with it) over a class.
79
+ */
80
+ function syncEchoedPlaceholder(
81
+ doc: JxMutableNode,
82
+ path: (string | number)[],
83
+ container: HTMLElement,
84
+ ): void {
85
+ const el = findElement(container, path);
86
+ if (el) {
87
+ syncPlaceholderClass(el, getNodeAtPath(doc, path));
88
+ }
89
+ }
90
+
65
91
  /** The render context, asserted present for ops that re-render a subtree (the caller always has it). */
66
92
  function requireCtx(ctx: IframeRenderCtx | undefined): IframeRenderCtx {
67
93
  if (!ctx) {
@@ -134,7 +160,7 @@ function insertChild(
134
160
  // Remap existing siblings BEFORE attaching the new subtree so it isn't itself remapped.
135
161
  remapChildPaths(container, parentPath, index, 1);
136
162
  insertAt(parentEl, newEl, domChildReference(parentEl, parentNode, index));
137
- syncContainerPlaceholder(parentEl, parentNode);
163
+ syncPlaceholderClass(parentEl, parentNode);
138
164
  }
139
165
 
140
166
  /** Re-render the node at `docPath` and swap it into the DOM in place (disposing the old subtree). */
@@ -165,7 +191,7 @@ function removeChild(
165
191
  el.remove();
166
192
  remapChildPaths(container, parentPath, index + 1, -1);
167
193
  if (parentEl) {
168
- syncContainerPlaceholder(parentEl, getNodeAtPath(doc, parentPath));
194
+ syncPlaceholderClass(parentEl, getNodeAtPath(doc, parentPath));
169
195
  }
170
196
  }
171
197
 
@@ -202,25 +228,25 @@ function moveChild(
202
228
  // Emptiness may have changed at either end (e.g. moved the last/only child out, or into an empty
203
229
  // Container) — keep both parents' placeholder classes in sync with the folded doc.
204
230
  if (fromParentEl) {
205
- syncContainerPlaceholder(
231
+ syncPlaceholderClass(
206
232
  fromParentEl,
207
233
  getNodeAtPath(doc, elementPath(fromParentEl) ?? fromParentPath),
208
234
  );
209
235
  }
210
- syncContainerPlaceholder(toParentEl, toParentNode);
236
+ syncPlaceholderClass(toParentEl, toParentNode);
211
237
  }
212
238
 
213
239
  /**
214
- * Keep a container's empty-placeholder class in sync with its (folded) doc node after a child
215
- * change.
240
+ * Put `el`'s empty-placeholder class back in step with its (folded) doc node — a container's after
241
+ * a child change, a text block's after its content changed.
216
242
  */
217
- function syncContainerPlaceholder(parentEl: Element, parentNode: JxMutableNode | undefined): void {
243
+ function syncPlaceholderClass(el: Element, node: JxMutableNode | undefined): void {
218
244
  for (const cls of EMPTY_PLACEHOLDER_CLASSES) {
219
- parentEl.classList.remove(cls);
245
+ el.classList.remove(cls);
220
246
  }
221
- const placeholder = parentNode ? computeEmptyPlaceholderClass(parentNode) : null;
247
+ const placeholder = node ? computeEmptyPlaceholderClass(node) : null;
222
248
  if (placeholder) {
223
- parentEl.classList.add(placeholder);
249
+ el.classList.add(placeholder);
224
250
  }
225
251
  }
226
252
 
@@ -321,24 +347,24 @@ function patchText(doc: JxMutableNode, path: (string | number)[], container: HTM
321
347
  const el = requireElement(container, path);
322
348
  const node = getNodeAtPath(doc, path);
323
349
  el.textContent = textDisplayValue(node);
324
- for (const cls of EMPTY_PLACEHOLDER_CLASSES) {
325
- el.classList.remove(cls);
326
- }
327
- const placeholder = node ? computeEmptyPlaceholderClass(node) : null;
328
- if (placeholder) {
329
- el.classList.add(placeholder);
330
- }
350
+ syncPlaceholderClass(el, node);
331
351
  }
332
352
 
333
353
  /** Locate the rendered element for a document path via its stamped `data-jx-path` attribute. */
334
354
  function requireElement(container: HTMLElement, path: (string | number)[]): HTMLElement {
355
+ const el = findElement(container, path);
356
+ if (!el) {
357
+ throw new Error(`iframe-patch-element-not-found:${serializeJxPath(path)}`);
358
+ }
359
+ return el;
360
+ }
361
+
362
+ /** The rendered element for a document path, or null when this render has none. */
363
+ function findElement(container: HTMLElement, path: (string | number)[]): HTMLElement | null {
335
364
  const serialized = serializeJxPath(path);
336
365
  const esc = serialized.replaceAll("\\", String.raw`\\`).replaceAll("'", String.raw`\'`);
337
366
  const el = container.querySelector(`[data-jx-path='${esc}']`);
338
- if (!(el instanceof HTMLElement)) {
339
- throw new Error(`iframe-patch-element-not-found:${serialized}`);
340
- }
341
- return el;
367
+ return el instanceof HTMLElement ? el : null;
342
368
  }
343
369
 
344
370
  /**
@@ -28,7 +28,7 @@ import {
28
28
  import { resolveAssetRef } from "./asset-resolve";
29
29
  import { classifyRenderNode, serializeJxPath } from "./path-mapping";
30
30
  import type { AssetContext } from "./asset-resolve";
31
- import { SITE_STYLE_ID, buildSiteStyleCSS } from "./site-style-css";
31
+ import { SITE_STYLE_ID, buildSiteStyleCSS } from "@jxsuite/site/site-style";
32
32
  import type { CanvasMode } from "./iframe-protocol";
33
33
  import type { JxDocument } from "@jxsuite/schema/types";
34
34
  import type { PathMapCtx } from "./path-mapping";
@@ -105,9 +105,25 @@ export const EDIT_PLACEHOLDER_STYLE_ID = "jx-canvas-edit-css";
105
105
  * iframe-safe fallbacks — the parent theme variables (--fg-dim/--radius/--accent) don't exist in
106
106
  * the iframe document. The placeholder CLASSES are stamped by prepareForEditMode (parent-side
107
107
  * resolution + surgical subtree renders), so preview mode never matches these rules — but the sheet
108
- * is still removed there (belt and braces). The `[contenteditable="true"]:empty` hint is new: it
109
- * marks the caret's empty paragraph (e.g. right after an Enter split) and advertises the slash
110
- * menu.
108
+ * is still removed there (belt and braces). The `[data-jx-active-block]` hint marks the caret's
109
+ * empty paragraph (e.g. right after an Enter split) and advertises the slash menu.
110
+ *
111
+ * `.empty-text-placeholder` is gated on emptiness IN THE DOM because a CLASS cannot tell the truth
112
+ * about a block the author is typing in. The class is stamped from the document, and the first
113
+ * thing typed into an empty block reaches the DOM natively — the model learns of it a commit tick
114
+ * later, and that patch comes back as an ECHO, which `applyIframePatch` deliberately does not
115
+ * re-render (it would destroy the caret). So the class outlived its own meaning, and "Click here to
116
+ * add text..." sat beside the text you had just typed until the next full render. The class now
117
+ * selects WHICH affordance; the DOM answers whether the block is empty.
118
+ *
119
+ * "Empty" is `:empty` OR a lone `<br>`, because emptying a block by editing does not leave it
120
+ * `:empty`: the engine drops in a filler `<br>` to keep the line visible (Chrome 151, verified —
121
+ * `execCommand("delete")` over a `<p>`'s whole content leaves `<p><br></p>`). Without that arm the
122
+ * affordance would go dark exactly when the block became empty again.
123
+ *
124
+ * `:not([data-jx-active-block])` keeps the slash hint winning on an empty block that HAS the caret:
125
+ * both rules match the same element, and the emptiness test broke the specificity tie that used to
126
+ * decide it by source order.
111
127
  */
112
128
  export const EDIT_PLACEHOLDER_CSS = `
113
129
  .empty-media-placeholder {
@@ -123,7 +139,7 @@ export const EDIT_PLACEHOLDER_CSS = `
123
139
  font-size: 0;
124
140
  overflow: hidden;
125
141
  }
126
- .empty-text-placeholder:not([contenteditable])::after {
142
+ .empty-text-placeholder:is(:empty, :has(> br:only-child)):not([data-jx-active-block]):not([contenteditable])::after {
127
143
  content: "Click here to add text...";
128
144
  color: color-mix(in srgb, #808080 40%, transparent);
129
145
  font-style: italic;
@@ -149,7 +165,7 @@ export const EDIT_PLACEHOLDER_CSS = `
149
165
  pointer-events: none;
150
166
  white-space: nowrap;
151
167
  }
152
- [data-jx-active-block]:empty::after {
168
+ [data-jx-active-block]:is(:empty, :has(> br:only-child))::after {
153
169
  content: "Type / for commands";
154
170
  color: color-mix(in srgb, #808080 40%, transparent);
155
171
  font-style: italic;
@@ -61,6 +61,7 @@ import { i18nCommands } from "../i18n/i18n-commands";
61
61
  import { newProjectCommands } from "../new-project/new-project-modal";
62
62
  import { canvasCommands } from "../editor/shortcuts";
63
63
  import { formatCommands, registerSelectionCommands } from "../panels/block-action-bar";
64
+ import { fileFormatCommands } from "../format/convert-file";
64
65
  import { registerTabCommands } from "../workspace/workspace";
65
66
  import { derivationCommands, noopDerivationDeps } from "../workspace/pane-derive";
66
67
  import type { AnyCommand } from "./registry";
@@ -154,6 +155,9 @@ export function appCommandSet(): AnyCommand[] {
154
155
  // That renders them, and projected here so the palette, the level check and the generated
155
156
  // Keyboard sheet all see the first `caret`-scoped chords the app has ever had.
156
157
  ...formatCommands(),
158
+ // The file-format verb: `file.convertFormat`, declared for `context/file` so the Files tree
159
+ // Renders it off the record rather than hand-building a row beside the declared ones.
160
+ ...fileFormatCommands(),
157
161
  /* The canvas keyboard's own nine, plus the clipboard pair they spread.
158
162
  These were the last records the app registered and this projection did not contain, so ⌘C,
159
163
  ⌘X, ⌘V, Enter, the three structural arrows and all three zoom chords were absent from
@@ -216,6 +216,35 @@ export function enumProperty(declared: readonly string[], description: string):
216
216
  return { description, enum: [...declared], type: "string" };
217
217
  }
218
218
 
219
+ /**
220
+ * An enum property whose values are derived WHEN THE SCHEMA IS READ, not when the record is
221
+ * defined.
222
+ *
223
+ * `enumProperty(collections(), …)` looks identical and is wrong for anything a PROJECT supplies,
224
+ * and the difference is invisible in a unit test: a command record is built at module scope —
225
+ * `commands/app-commands.ts` and `studio.ts` both build theirs before a project is open — so the
226
+ * array freezes at `[]`, and `panels/quick-search.ts`'s `paletteArgs` offers an empty choice list
227
+ * forever after. `run` re-derives the list, which is why the programmatic call works and the
228
+ * palette does not: two answers to "what is there", one of them a snapshot of the empty boot
229
+ * state.
230
+ *
231
+ * A getter is the whole fix — the palette reads `property.enum` when it opens the prompt, the AI
232
+ * tool's parameters serialise it when the tool list is built, and `scripts/check-shot-contract.ts`
233
+ * reads it in a bare Bun process where the honest answer really is "none declared".
234
+ */
235
+ export function derivedEnumProperty(
236
+ declared: () => readonly string[],
237
+ description: string,
238
+ ): object {
239
+ return {
240
+ description,
241
+ get enum() {
242
+ return [...declared()];
243
+ },
244
+ type: "string",
245
+ };
246
+ }
247
+
219
248
  /** A number property, optionally bounded — the bounds a control already enforces, written down. */
220
249
  export function numberProperty(
221
250
  description: string,
@@ -55,6 +55,7 @@ export type EditorKind =
55
55
  | "library"
56
56
  | "config"
57
57
  | "entry"
58
+ | "media"
58
59
  | "none";
59
60
 
60
61
  /** The Canvas view axis — one control, three values (§4.2). */
@@ -87,6 +88,10 @@ const EDITOR_KIND_BY_MODE: Readonly<Record<string, EditorKind>> = {
87
88
  "git-diff": "diff",
88
89
  grid: "grid",
89
90
  manage: "library",
91
+ /* The Media viewer — `media/media-pane.ts`. An image, a video, a font specimen: files the studio
92
+ can SHOW but has no document model for, which is why clicking one used to end in "No format
93
+ class imported for …", advice that means nothing about a PNG. */
94
+ media: "media",
90
95
  preview: "canvas",
91
96
  /* Project Settings — `settings/settings-document.ts`'s SETTINGS_MODE. A form over `project.json`,
92
97
  which is a configuration object and not a document tree; `stylebook` (Project Styles) is the
@@ -117,6 +122,7 @@ export const EDITOR_KIND_LABELS: Readonly<Record<EditorKind, string>> = {
117
122
  diff: "Diff",
118
123
  entry: "Entry",
119
124
  grid: "Grid",
125
+ media: "Media",
120
126
  library: "Library",
121
127
  none: "None",
122
128
  };
@@ -232,6 +238,14 @@ export interface CommandContext {
232
238
  ai: {
233
239
  configured: boolean;
234
240
  streaming: boolean;
241
+ /**
242
+ * A turn is suspended on an `ask_user` question.
243
+ *
244
+ * Distinct from `streaming` rather than folded into it: no tokens are moving, so a surface that
245
+ * paints a spinner on `streaming` must not paint one here — but the turn is alive and holding a
246
+ * tool open, so Stop is enabled on the union of the two.
247
+ */
248
+ waiting: boolean;
235
249
  };
236
250
  capability: Record<Capability, boolean>;
237
251
  }
@@ -287,7 +301,7 @@ export function emptyContext(): CommandContext {
287
301
  focus: { region: "pane" },
288
302
  modal: { open: false },
289
303
  collab: { attached: false, readOnly: false, sourceCanonical: false },
290
- ai: { configured: false, streaming: false },
304
+ ai: { configured: false, streaming: false, waiting: false },
291
305
  capability: {
292
306
  gitClone: false,
293
307
  importSite: false,
@@ -102,6 +102,7 @@ export interface CommandDeps {
102
102
  undo: () => void;
103
103
  redo: () => void;
104
104
  openInBrowser: () => void | Promise<void>;
105
+ buildSite: () => void | Promise<void>;
105
106
  closeDocument: () => void;
106
107
  duplicateSelection: () => void;
107
108
  deleteSelection: () => void;
@@ -147,6 +148,7 @@ export function noopCommandDeps(): CommandDeps {
147
148
  undo: () => {},
148
149
  redo: () => {},
149
150
  openInBrowser: () => {},
151
+ buildSite: () => {},
150
152
  closeDocument: () => {},
151
153
  duplicateSelection: () => {},
152
154
  deleteSelection: () => {},
@@ -243,9 +245,35 @@ export function defaultCommands(deps: CommandDeps): AnyCommand[] {
243
245
  group: "2_output",
244
246
  when: (ctx) => ctx.project.isSite,
245
247
  enablement: documentOpen,
246
- requires: "a built page to open",
248
+ /* "A page to preview", not "a built page to open". Nothing is built on the way here any
249
+ more: the working tree is rendered at its real route, so what this waits for is a document
250
+ with a route rather than an output file. */
251
+ requires: "a page to preview",
247
252
  run: () => deps.openInBrowser(),
248
253
  },
254
+ {
255
+ /*
256
+ * The compiler, kept reachable under its own verb.
257
+ *
258
+ * `View: Open in Browser` used to run a full build, which answered "does my site build?" as a
259
+ * side effect of asking "what does this page look like?". Those are different questions and
260
+ * only one of them is worth waiting for a bundler, an image pipeline and every emitter. So
261
+ * the build keeps a command of its own rather than disappearing with the coupling.
262
+ *
263
+ * `commandbar/overflow` rather than `commandbar/primary`: the primary row is budgeted at five
264
+ * and is document-level by frequency (studio-ui-guidelines §12), and a build is neither
265
+ * frequent nor about the document in front of you. No default chord for the same reason.
266
+ */
267
+ id: "project.buildSite",
268
+ title: "Build Site",
269
+ category: "Project",
270
+ level: "project",
271
+ menus: ["commandbar/overflow", "palette"],
272
+ group: "2_output",
273
+ when: (ctx) => ctx.project.isSite,
274
+ requires: "a site project",
275
+ run: () => deps.buildSite(),
276
+ },
249
277
 
250
278
  // ── Edit ──
251
279
  {
@@ -77,6 +77,14 @@ export const PLACEMENTS = [
77
77
  */
78
78
  "blockbar/format",
79
79
  "outline/row",
80
+ /**
81
+ * The rail foot's ⚙ Settings menu — the settings FAMILY, and only it.
82
+ *
83
+ * A separate placement rather than a second use of `commandbar/overflow`, for the reason the
84
+ * status bar is three placements: the ⬢ menu is the chrome's residue for retired controls and
85
+ * this is a named family with its own trigger. Same admits, different surface, different budget.
86
+ */
87
+ "settings/menu",
80
88
  "palette",
81
89
  "never",
82
90
  ] as const;
@@ -119,6 +127,14 @@ export const PLACEMENT_MATRIX: Readonly<Record<Placement, PlacementRule>> = {
119
127
  note: "the bar's inline-format cluster — a range inside the selection is still the selection",
120
128
  },
121
129
  "outline/row": { admits: ["selection"], note: "row actions act on the row's node" },
130
+ "settings/menu": {
131
+ admits: ["application", "project"],
132
+ note:
133
+ "the rail foot's gear menu — the settings family. It is a MENU, not a pinned slot, so it " +
134
+ "may host two levels for the reason commandbar/overflow does: a menu prints each row's own " +
135
+ "name, chord and gate, and draws a divider where the level changes. The rail's PINNED " +
136
+ "groups stay single-level, in PANEL_PLACEMENT_MATRIX",
137
+ },
122
138
  palette: {
123
139
  admits: ["application", "project", "document", "selection"],
124
140
  note: "the level-agnostic surface; it groups rows by level",
@@ -72,6 +72,8 @@ export interface LiveContextSources {
72
72
  * private, so there is nothing to read yet; the caller passes a probe when one exists.
73
73
  */
74
74
  aiStreaming?: () => boolean;
75
+ /** Whether a turn is suspended on an `ask_user` question (`panels/ai-panel.ts`). */
76
+ aiWaiting?: () => boolean;
75
77
  }
76
78
 
77
79
  /** `capability.*` is presence-of-method on the PAL — the check every call site does by hand today. */
@@ -187,6 +189,7 @@ export function createLiveContext(sources: LiveContextSources): () => CommandCon
187
189
 
188
190
  ctx.ai.configured = sources.aiConfigured();
189
191
  ctx.ai.streaming = sources.aiStreaming?.() ?? false;
192
+ ctx.ai.waiting = sources.aiWaiting?.() ?? false;
190
193
  ctx.capability = capabilities(sources.platform());
191
194
  return ctx;
192
195
  };