@jxsuite/studio 2.4.3 → 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 (208) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
  3. package/dist/chunks/cf-settings-45fgyx8y.js +21 -0
  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-k10dvcc7.js → convert-to-repeater-dhw1nrz9.js} +27 -18
  7. package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
  8. package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-3ydgjbdy.js} +32 -23
  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-wewba4nq.js → format-host-tgcmfs4p.js} +3 -3
  12. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  13. package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-0yywyk76.js} +21 -15
  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-f5yyvqxv.js → studio-1hbqnt19.js} +37 -31
  33. package/dist/chunks/studio-2zkz8evn.js +3163 -0
  34. package/dist/chunks/{studio-0b6fs05q.js → studio-4pqqxbrt.js} +23 -15
  35. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  36. package/dist/chunks/{studio-ef8t53vc.js → studio-5q69gfag.js} +60 -56
  37. package/dist/chunks/{studio-v2c5pmjj.js → studio-5rywrkxz.js} +565 -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-hyn3vhwr.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-28xc8ws4.js → studio-9c4k4vhv.js} +14 -10
  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-aweyr4v1.js → studio-ahjfdy6e.js} +9 -7
  47. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  48. package/dist/chunks/{studio-26sv44br.js → studio-cb12yz9w.js} +8543 -8784
  49. package/dist/chunks/studio-cft9r4r7.js +119 -0
  50. package/dist/chunks/{studio-rqemfhz6.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-ke244pam.js → studio-ex8zyp98.js} +51 -25
  54. package/dist/chunks/studio-gv8vvshc.js +118 -0
  55. package/dist/chunks/studio-hbtp0eh1.js +1315 -0
  56. package/dist/chunks/{studio-cz2j1c0g.js → studio-htsf0w6m.js} +75 -123
  57. package/dist/chunks/{studio-pyrw8h0w.js → studio-kjjsjak4.js} +528 -212
  58. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  59. package/dist/chunks/studio-kyh9avvv.js +28 -0
  60. package/dist/chunks/{studio-jnakhg87.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-sccge2wf.js → studio-p8j86wag.js} +2 -2
  66. package/dist/chunks/{studio-v1va0vhd.js → studio-pdfjntbk.js} +75 -139
  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-dgj7gq44.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-hb7c46jy.js → studio-wx5609xd.js} +21 -15
  81. package/dist/chunks/studio-xrapxwwc.js +1539 -0
  82. package/dist/chunks/{studio-ws6ef6js.js → studio-yxrkyv8a.js} +41 -29
  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 +450 -48
  86. package/dist/studio.js +2455 -1761
  87. package/package.json +16 -15
  88. package/src/browse/library-layouts.ts +3 -3
  89. package/src/browse/library-pane.ts +59 -5
  90. package/src/browse/library-preview.ts +19 -3
  91. package/src/canvas/asset-refs.ts +119 -0
  92. package/src/canvas/asset-resolve.ts +256 -0
  93. package/src/canvas/canvas-live-render.ts +23 -15
  94. package/src/canvas/canvas-render.ts +23 -1
  95. package/src/canvas/iframe-entry.ts +1 -0
  96. package/src/canvas/iframe-host.ts +4 -0
  97. package/src/canvas/iframe-patch.ts +47 -21
  98. package/src/canvas/iframe-protocol.ts +9 -0
  99. package/src/canvas/iframe-render.ts +50 -16
  100. package/src/commands/app-commands.ts +4 -0
  101. package/src/commands/command-args.ts +29 -0
  102. package/src/commands/context.ts +15 -1
  103. package/src/commands/defaults.ts +29 -1
  104. package/src/commands/levels.ts +16 -0
  105. package/src/commands/live-context.ts +3 -0
  106. package/src/content/collection-match.ts +267 -0
  107. package/src/content/entry-commands.ts +38 -29
  108. package/src/content/entry-fields.ts +6 -0
  109. package/src/editor/file-drop-action.ts +2 -1
  110. package/src/editor/shortcuts.ts +5 -0
  111. package/src/files/file-ops.ts +10 -42
  112. package/src/files/files.ts +487 -61
  113. package/src/files/fs-events.ts +9 -0
  114. package/src/files/gitignore.ts +432 -0
  115. package/src/files/media-paths.ts +37 -14
  116. package/src/files/media-upload.ts +47 -6
  117. package/src/files/serialize-document.ts +60 -0
  118. package/src/format/convert-file.ts +511 -0
  119. package/src/format/format-choices.ts +199 -0
  120. package/src/grid/grid-idle.ts +45 -0
  121. package/src/grid/grid-view.ts +35 -0
  122. package/src/grid/schema-columns.ts +2 -1
  123. package/src/grid/sources/content-source.ts +24 -3
  124. package/src/media/media-open.ts +58 -0
  125. package/src/media/media-pane.ts +310 -0
  126. package/src/new-project/import-tab.ts +333 -160
  127. package/src/new-project/new-project-modal.ts +27 -34
  128. package/src/packages/ensure-deps.ts +14 -7
  129. package/src/packages/jxsuite-update.ts +11 -11
  130. package/src/page-params.ts +10 -46
  131. package/src/panels/activity-bar.ts +45 -16
  132. package/src/panels/ai-chat/attached-context.ts +6 -1
  133. package/src/panels/ai-chat/chat-view.ts +255 -13
  134. package/src/panels/ai-chat/composer.ts +21 -80
  135. package/src/panels/ai-panel.ts +116 -2
  136. package/src/panels/frontmatter-fields.ts +6 -2
  137. package/src/panels/navigator-panels.ts +7 -6
  138. package/src/panels/properties-panel.ts +8 -2
  139. package/src/panels/seo-modal.ts +5 -6
  140. package/src/panels/settings-menu.ts +799 -0
  141. package/src/panels/signals-panel.ts +2 -1
  142. package/src/panels/toolbar.ts +135 -15
  143. package/src/platforms/cloud.ts +34 -3
  144. package/src/platforms/devserver.ts +72 -34
  145. package/src/preview/preview-overlay.ts +226 -0
  146. package/src/services/ai-ask.ts +247 -0
  147. package/src/services/ai-import-tools.ts +494 -0
  148. package/src/services/ai-models.ts +101 -11
  149. package/src/services/ai-project-tools.ts +20 -26
  150. package/src/services/ai-settings.ts +69 -71
  151. package/src/services/ai-system-prompt.ts +88 -2
  152. package/src/services/ai-turn-signal.ts +70 -0
  153. package/src/services/cf-settings.ts +19 -37
  154. package/src/services/connection.ts +74 -0
  155. package/src/services/context-manager.ts +89 -0
  156. package/src/services/document-assistant.ts +49 -7
  157. package/src/services/idle.ts +6 -1
  158. package/src/services/import-client.ts +33 -6
  159. package/src/services/import-run.ts +158 -0
  160. package/src/services/import-seed.ts +120 -0
  161. package/src/services/jx-validate.ts +58 -6
  162. package/src/services/project-adoption.ts +71 -0
  163. package/src/services/references.ts +0 -0
  164. package/src/services/settings/definitions.ts +181 -0
  165. package/src/services/settings/kernel.ts +335 -0
  166. package/src/services/settings/write-queue.ts +116 -0
  167. package/src/services/tool-executor.ts +34 -2
  168. package/src/settings/contexts-section.ts +45 -3
  169. package/src/settings/dependencies-editor.ts +43 -23
  170. package/src/settings/general-settings.ts +30 -12
  171. package/src/settings/preferences-accounts.ts +6 -19
  172. package/src/settings/preferences-dialog.ts +18 -41
  173. package/src/settings/preferences-keymap.ts +8 -18
  174. package/src/settings/preferences-sections.ts +57 -0
  175. package/src/settings/schema-field-ui.ts +2 -2
  176. package/src/settings/settings-document.ts +65 -9
  177. package/src/shell.ts +6 -14
  178. package/src/studio.ts +65 -7
  179. package/src/tabs/transact.ts +42 -19
  180. package/src/types.ts +164 -8
  181. package/src/ui/ai-credentials-form.ts +52 -27
  182. package/src/ui/ai-model-picker.ts +162 -0
  183. package/src/ui/choice-field.ts +72 -0
  184. package/src/ui/layers.ts +83 -5
  185. package/src/ui/media-picker.ts +6 -11
  186. package/src/utils/studio-utils.ts +43 -31
  187. package/styles/forced-colors.css +7 -0
  188. package/styles/overlays.css +256 -0
  189. package/styles/panels.css +13 -1
  190. package/styles/shell.css +111 -0
  191. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  192. package/dist/chunks/cf-settings-g5fbf2zp.js +0 -17
  193. package/dist/chunks/content-source-k9724vcv.js +0 -39
  194. package/dist/chunks/files-yew27sn9.js +0 -69
  195. package/dist/chunks/layers-panel-qnsghwwd.js +0 -59
  196. package/dist/chunks/publish-panel-a0pdft5w.js +0 -21
  197. package/dist/chunks/quick-search-9avyc4pc.js +0 -54
  198. package/dist/chunks/right-panel-kcnhtc5j.js +0 -38
  199. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  200. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  201. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  202. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  203. package/dist/chunks/studio-a7pzveq1.js +0 -110
  204. package/dist/chunks/studio-c31yb4t6.js +0 -55
  205. package/dist/chunks/studio-ra7ar50f.js +0 -6682
  206. package/src/canvas/content-assets.ts +0 -263
  207. package/src/canvas/site-style-css.ts +0 -121
  208. package/src/services/settings-store.ts +0 -99
@@ -20,6 +20,7 @@ import {
20
20
  resolveLayoutDoc,
21
21
  } from "../site-context";
22
22
  import { documentBase } from "./canvas-origin";
23
+ import { getPlatform, hasPlatform } from "../platform";
23
24
  import { componentRegistry, computeRelativePath } from "../files/components";
24
25
  import { prepareForEditMode } from "../utils/edit-display";
25
26
  import {
@@ -28,11 +29,11 @@ import {
28
29
  substitutePreviewParams,
29
30
  } from "../page-params";
30
31
  import { isComponentDoc, substitutePreviewProps } from "../component-props";
31
- import { rewriteContentAssets } from "./content-assets";
32
+ import { assetContextFor } from "./asset-refs";
32
33
 
34
+ import type { AssetContext } from "./asset-refs";
33
35
  import type { JxElement, JxMutableNode, JxPath } from "@jxsuite/schema/types";
34
36
  import type { ComponentEntry } from "../files/components.js";
35
- import type { ContentSectionEntry } from "../types";
36
37
  import type { LayoutMarker } from "./path-mapping";
37
38
  import type { Tab } from "../tabs/tab";
38
39
  import type { WireMapperCtx } from "./iframe-protocol";
@@ -138,6 +139,8 @@ export async function resolveCanvasDocument(
138
139
  ): Promise<{
139
140
  renderDoc: JxMutableNode;
140
141
  docBase: string | undefined;
142
+ /** How the canvas must address project media, or null when its origin serves the site URL space. */
143
+ assets: AssetContext | null;
141
144
  mapperCtx: WireMapperCtx;
142
145
  siteStyle: Record<string, unknown> | null;
143
146
  }> {
@@ -179,9 +182,23 @@ export async function resolveCanvasDocument(
179
182
  }
180
183
  }
181
184
 
182
- const docBase = S.documentPath
183
- ? new URL(S.documentPath, documentBase(projectState?.projectRoot)).href
184
- : undefined;
185
+ const fileBase = documentBase(projectState?.projectRoot);
186
+ const docBase = S.documentPath ? new URL(S.documentPath, fileBase).href : undefined;
187
+ /*
188
+ * How this render addresses media, as PLAIN DATA — the resolver itself is a function and cannot
189
+ * cross into the canvas realm, so the iframe rebuilds it from this.
190
+ *
191
+ * It replaces a walk over the render document that used to sit further down this function. A walk
192
+ * can only ever see LITERAL values: `applyAttributes` resolves a `{"$ref": …}` or `"${…}"` src
193
+ * inside a reactive effect, so at walk time a bound image src is not a string at all. That is why
194
+ * a collection listing of thirty bound card images broke all at once in preview and nowhere else
195
+ * — design and edit swap every bound src for a transparent pixel, so they never reached the
196
+ * network to fail.
197
+ */
198
+ const assets = assetContextFor(S.documentPath, {
199
+ fileBaseUrl: fileBase,
200
+ ...(hasPlatform() ? { space: getPlatform().assetSpace } : {}),
201
+ });
185
202
 
186
203
  // Substitute chosen dynamic route params ({$ref: "#/$params/x"} → literal), inject state.$page,
187
204
  // And bake the substituted class-prototype state entries via the backend resolver — in every
@@ -210,16 +227,6 @@ export async function resolveCanvasDocument(
210
227
  }
211
228
  }
212
229
 
213
- // A content entry references its media relative to ITSELF; the built site serves those files from
214
- // The content type's asset mount. Studio opens the entry standalone, so the collection loader that
215
- // Normally performs that mapping never runs — do it here, on the RENDER doc only, so the canvas
216
- // Previews the URL production serves while the source doc keeps the authored relative ref.
217
- renderDoc = rewriteContentAssets(
218
- renderDoc,
219
- S.documentPath,
220
- projectState?.projectConfig?.content as Record<string, ContentSectionEntry> | undefined,
221
- );
222
-
223
230
  const arrayPaths = new Set<string>();
224
231
  if (canvasMode === "design" || canvasMode === "edit") {
225
232
  (function findArrayPaths(node: JxMutableNode, path: (string | number)[]) {
@@ -321,6 +328,7 @@ export async function resolveCanvasDocument(
321
328
  }
322
329
 
323
330
  return {
331
+ assets,
324
332
  docBase,
325
333
  mapperCtx: {
326
334
  arrayPaths: [...arrayPaths],
@@ -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);
@@ -859,6 +859,7 @@ export function startCanvasIframe(opts: {
859
859
  stopDataScopeWatch = null;
860
860
  handle?.dispose();
861
861
  handle = await renderResolvedDocument({
862
+ assets: msg.assets ?? null,
862
863
  container,
863
864
  doc: msg.doc as JxDocument,
864
865
  docBase: msg.docBase,
@@ -2775,6 +2775,7 @@ function preparePassRender(
2775
2775
  const formatElements = formatByName(renderTab?.doc.sourceFormat)?.studio?.elements;
2776
2776
  const editableTags = formatElements ? formatEditableVerdicts(formatElements) : undefined;
2777
2777
  return {
2778
+ assets: resolved.assets,
2778
2779
  doc: cloneableDoc,
2779
2780
  docBase: resolved.docBase ?? `${canvasBaseOrigin()}/`,
2780
2781
  mapperCtx: resolved.mapperCtx,
@@ -2898,6 +2899,9 @@ export function mountStylebookCanvas(
2898
2899
  const message: ParentToIframe = {
2899
2900
  // The specimen carries no tab identity, but the STAGE it is mounted into does: a stylebook is
2900
2901
  // Opened as a tab like anything else, and its `previewColorScheme` is that tab's.
2902
+ /* A stylebook specimen is generated, not a project document, so it references no project
2903
+ media and needs no resolution. */
2904
+ assets: null,
2901
2905
  colorScheme: schemeWireFor(tabOfContainer(canvasEl)),
2902
2906
  doc: cloneableDoc,
2903
2907
  docBase: `${canvasBaseOrigin()}/`,
@@ -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
  /**
@@ -9,6 +9,7 @@
9
9
  * the cross-frame analog of the legacy renderer's `renderGeneration` staleness guard.
10
10
  */
11
11
 
12
+ import type { AssetContext } from "./asset-resolve";
12
13
  import type { JxDocOp } from "../tabs/patch-ops";
13
14
  import type { JxContentResult, SlashCommand } from "../editor/inline-edit";
14
15
  import type { JxExpressionNode, JxMutableNode } from "@jxsuite/schema/types";
@@ -88,6 +89,14 @@ export type ParentToIframe =
88
89
  */
89
90
  allowAutoRequests?: boolean;
90
91
  docBase: string;
92
+ /**
93
+ * How this host addresses project media, or null/absent when the canvas origin serves the
94
+ * site's own URL space (desktop, `jx dev`) and no reference needs resolving.
95
+ *
96
+ * Plain data because the resolver is a FUNCTION and functions do not cross a realm: the
97
+ * iframe rebuilds the resolver from this and installs it on the runtime.
98
+ */
99
+ assets?: AssetContext | null;
91
100
  mapperCtx: WireMapperCtx;
92
101
  siteStyle: Record<string, unknown> | null;
93
102
  // Forced color-scheme preview (spec §9.5): "light"/"dark" sets data-color-scheme on the
@@ -13,19 +13,22 @@
13
13
 
14
14
  import {
15
15
  buildScope,
16
+ canvasStyleValue,
16
17
  defineElement,
17
18
  renderNode,
18
19
  runScoped,
20
+ setCanvasAssetResolver,
19
21
  setCanvasDelinkAnchors,
20
22
  setCanvasViewportTranspose,
21
23
  setRootMedia,
22
24
  setSkipAutoRequests,
23
25
  setSkipServerFunctions,
24
26
  setStampPropBindings,
25
- transposeCanvasUnits,
26
27
  } from "@jxsuite/runtime";
28
+ import { resolveAssetRef } from "./asset-resolve";
27
29
  import { classifyRenderNode, serializeJxPath } from "./path-mapping";
28
- import { SITE_STYLE_ID, buildSiteStyleCSS } from "./site-style-css";
30
+ import type { AssetContext } from "./asset-resolve";
31
+ import { SITE_STYLE_ID, buildSiteStyleCSS } from "@jxsuite/site/site-style";
29
32
  import type { CanvasMode } from "./iframe-protocol";
30
33
  import type { JxDocument } from "@jxsuite/schema/types";
31
34
  import type { PathMapCtx } from "./path-mapping";
@@ -102,9 +105,25 @@ export const EDIT_PLACEHOLDER_STYLE_ID = "jx-canvas-edit-css";
102
105
  * iframe-safe fallbacks — the parent theme variables (--fg-dim/--radius/--accent) don't exist in
103
106
  * the iframe document. The placeholder CLASSES are stamped by prepareForEditMode (parent-side
104
107
  * resolution + surgical subtree renders), so preview mode never matches these rules — but the sheet
105
- * is still removed there (belt and braces). The `[contenteditable="true"]:empty` hint is new: it
106
- * marks the caret's empty paragraph (e.g. right after an Enter split) and advertises the slash
107
- * 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.
108
127
  */
109
128
  export const EDIT_PLACEHOLDER_CSS = `
110
129
  .empty-media-placeholder {
@@ -120,7 +139,7 @@ export const EDIT_PLACEHOLDER_CSS = `
120
139
  font-size: 0;
121
140
  overflow: hidden;
122
141
  }
123
- .empty-text-placeholder:not([contenteditable])::after {
142
+ .empty-text-placeholder:is(:empty, :has(> br:only-child)):not([data-jx-active-block]):not([contenteditable])::after {
124
143
  content: "Click here to add text...";
125
144
  color: color-mix(in srgb, #808080 40%, transparent);
126
145
  font-style: italic;
@@ -146,7 +165,7 @@ export const EDIT_PLACEHOLDER_CSS = `
146
165
  pointer-events: none;
147
166
  white-space: nowrap;
148
167
  }
149
- [data-jx-active-block]:empty::after {
168
+ [data-jx-active-block]:is(:empty, :has(> br:only-child))::after {
150
169
  content: "Type / for commands";
151
170
  color: color-mix(in srgb, #808080 40%, transparent);
152
171
  font-style: italic;
@@ -409,7 +428,9 @@ export function applySiteStyle(
409
428
  existing?.remove();
410
429
  return;
411
430
  }
412
- const css = buildSiteStyleCSS(siteStyle, mediaQueries, transposeCanvasUnits);
431
+ /* `canvasStyleValue`, not `transposeCanvasUnits`: the site style block is CSS the canvas emits
432
+ itself, so a `url()` in it needs the same asset resolution every other declaration gets. */
433
+ const css = buildSiteStyleCSS(siteStyle, mediaQueries, canvasStyleValue);
413
434
  if (existing) {
414
435
  existing.textContent = css;
415
436
  return;
@@ -434,7 +455,7 @@ export function applyPreviewColorScheme(doc: Document, scheme: "light" | "dark"
434
455
  }
435
456
 
436
457
  /** Inject the document's `$head` (link/meta/script) into the iframe's <head>, de-duped by href/src. */
437
- export function injectHead(doc: JxDocument): void {
458
+ export function injectHead(doc: JxDocument, assets: AssetContext | null = null): void {
438
459
  const head = (doc as { $head?: HeadEntry[] }).$head;
439
460
  if (!Array.isArray(head)) {
440
461
  return;
@@ -451,14 +472,16 @@ export function injectHead(doc: JxDocument): void {
451
472
  const attrs = { ...entry.attributes } as Record<string, unknown>;
452
473
  for (const key of ["href", "src"]) {
453
474
  const val = attrs[key];
454
- if (
455
- typeof val === "string" &&
456
- !val.startsWith("/") &&
457
- !val.startsWith(".") &&
458
- !val.startsWith("http")
459
- ) {
475
+ if (typeof val !== "string" || val === "") {
476
+ continue;
477
+ }
478
+ if (!val.startsWith("/") && !val.startsWith(".") && !val.startsWith("http")) {
479
+ /* The bare-specifier lane owns `/node_modules/<pkg>` — the HOST's URL space, not the
480
+ project's. A project resolver has nothing to resolve it to, so it never sees it. */
460
481
  attrs[key] = `/node_modules/${val}`;
482
+ continue;
461
483
  }
484
+ attrs[key] = resolveAssetRef(val, assets) ?? val;
462
485
  }
463
486
  const sel = `${tag}${attrs.href ? `[href="${String(attrs.href)}"]` : ""}${attrs.src ? `[src="${String(attrs.src)}"]` : ""}`;
464
487
  if (sel !== tag && document.head.querySelector(sel)) {
@@ -620,9 +643,20 @@ export async function renderResolvedDocument(opts: {
620
643
  mode: CanvasMode;
621
644
  mapperCtx: PathMapCtx;
622
645
  siteStyle?: Record<string, unknown> | null;
646
+ /**
647
+ * How this host addresses project media, or null when the canvas origin serves the site's own URL
648
+ * space (desktop, `jx dev`) and references need no resolution at all.
649
+ */
650
+ assets?: AssetContext | null;
623
651
  /** This render may fetch automatic `Request` entries even outside preview (Data-panel Refresh). */
624
652
  allowAutoRequests?: boolean;
625
653
  }): Promise<RenderHandle> {
654
+ /* FIRST, before anything emits CSS or an attribute. The resolver is module-global in the runtime,
655
+ so it is set on every render — including to null — or a previous document's context would
656
+ resolve this one's references. It cannot cross the realm as a function, which is why the parent
657
+ posts the plain-data context and the resolver is closed over it here. */
658
+ const assets = opts.assets ?? null;
659
+ setCanvasAssetResolver(assets ? (value) => resolveAssetRef(value, assets) : null);
626
660
  setSkipServerFunctions(opts.mode !== "preview");
627
661
  // Same gate for automatic `$prototype: "Request"` state entries. `buildScope` re-resolves every
628
662
  // State entry on each full render, so without this an escalating authoring action (a signals-panel
@@ -644,7 +678,7 @@ export async function renderResolvedDocument(opts: {
644
678
  // Component internals get stamped).
645
679
  setStampPropBindings(opts.mode === "design" || opts.mode === "edit");
646
680
  applySiteStyle(opts.siteStyle, (opts.doc as { $media?: Record<string, string> }).$media ?? {});
647
- injectHead(opts.doc);
681
+ injectHead(opts.doc, assets);
648
682
  syncEditModeCss(opts.container.ownerDocument, opts.mode);
649
683
  syncPreviewShell(opts.container.ownerDocument, opts.mode);
650
684
  syncStylebookCss(opts.container.ownerDocument, opts.mode);
@@ -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",