@jxsuite/studio 2.0.1 → 2.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 (419) hide show
  1. package/canvas.html +21 -0
  2. package/dist/chunks/Overlay.dev-qfjzkms8.js +23 -0
  3. package/dist/chunks/{Overlay.dev-cszx7yn5.js.map → Overlay.dev-qfjzkms8.js.map} +1 -1
  4. package/dist/chunks/{adapter-z8fkmbmv.js → adapter-qfnesn5v.js} +2 -2
  5. package/dist/chunks/{cf-settings-mmyv436d.js → cf-settings-73sh5z4p.js} +3 -3
  6. package/dist/chunks/{components-799jpvhm.js → components-2bpqp4mq.js} +5 -7
  7. package/dist/chunks/{components-799jpvhm.js.map → components-2bpqp4mq.js.map} +1 -1
  8. package/dist/chunks/content-source-34gnkbnn.js +38 -0
  9. package/dist/chunks/content-source-34gnkbnn.js.map +9 -0
  10. package/dist/chunks/convert-to-repeater-t8kxre5s.js +258 -0
  11. package/dist/chunks/convert-to-repeater-t8kxre5s.js.map +10 -0
  12. package/dist/chunks/extension-sections-6mqydz35.js +301 -0
  13. package/dist/chunks/extension-sections-6mqydz35.js.map +11 -0
  14. package/dist/chunks/files-8kn321h1.js +70 -0
  15. package/dist/chunks/files-8kn321h1.js.map +9 -0
  16. package/dist/chunks/{format-host-8jxwts2p.js → format-host-mkwsmt8d.js} +3 -3
  17. package/dist/chunks/git-panel-tjbr191d.js +51 -0
  18. package/dist/chunks/git-panel-tjbr191d.js.map +9 -0
  19. package/dist/chunks/{index-baeg2x3y.js → index-7pxchcce.js} +4 -3
  20. package/dist/chunks/{index-baeg2x3y.js.map → index-7pxchcce.js.map} +3 -3
  21. package/dist/chunks/{jx-validate-qx8degj7.js → jx-validate-v6qgknrs.js} +3 -3
  22. package/dist/chunks/layers-panel-87pb25km.js +58 -0
  23. package/dist/chunks/layers-panel-87pb25km.js.map +9 -0
  24. package/dist/chunks/{monaco-setup-zjdp9zdw.js → monaco-setup-e0yh25j4.js} +4 -75
  25. package/dist/chunks/{monaco-setup-zjdp9zdw.js.map → monaco-setup-e0yh25j4.js.map} +3 -4
  26. package/dist/chunks/publish-panel-m0fcgdrt.js +21 -0
  27. package/dist/chunks/publish-panel-m0fcgdrt.js.map +9 -0
  28. package/dist/chunks/quick-search-rhs6cbsb.js +53 -0
  29. package/dist/chunks/quick-search-rhs6cbsb.js.map +9 -0
  30. package/dist/chunks/right-panel-zpdxd98t.js +37 -0
  31. package/dist/chunks/right-panel-zpdxd98t.js.map +9 -0
  32. package/dist/chunks/section-registry-zmckaxf4.js +30 -0
  33. package/dist/chunks/section-registry-zmckaxf4.js.map +9 -0
  34. package/dist/chunks/sp-overlay.dev-9ran0sfq.js +18 -0
  35. package/dist/chunks/sp-overlay.dev-9ran0sfq.js.map +9 -0
  36. package/dist/chunks/sp-popover.dev-rebmdncr.js +9 -0
  37. package/dist/chunks/sp-popover.dev-rebmdncr.js.map +9 -0
  38. package/dist/chunks/sp-progress-circle.dev-5vkppfkt.js +15 -0
  39. package/dist/chunks/sp-progress-circle.dev-5vkppfkt.js.map +9 -0
  40. package/dist/chunks/sp-tooltip.dev-ykqrg8h5.js +15 -0
  41. package/dist/chunks/sp-tooltip.dev-ykqrg8h5.js.map +9 -0
  42. package/dist/chunks/{sp-tray.dev-pb2fsd65.js → sp-tray.dev-ey59vfh7.js} +10 -8
  43. package/dist/chunks/{sp-tray.dev-pb2fsd65.js.map → sp-tray.dev-ey59vfh7.js.map} +2 -2
  44. package/dist/chunks/{studio-9qr6fq7p.js → studio-0rqqhrdy.js} +10 -378
  45. package/dist/chunks/{studio-9qr6fq7p.js.map → studio-0rqqhrdy.js.map} +4 -14
  46. package/dist/chunks/{studio-5x6tqqec.js → studio-2sj1jj3v.js} +10 -8
  47. package/dist/chunks/{studio-5x6tqqec.js.map → studio-2sj1jj3v.js.map} +2 -2
  48. package/dist/chunks/{studio-h3fw0wn8.js → studio-3fk1ac9z.js} +6 -4
  49. package/dist/chunks/{studio-h3fw0wn8.js.map → studio-3fk1ac9z.js.map} +2 -2
  50. package/dist/chunks/studio-41exgzhq.js +6690 -0
  51. package/dist/chunks/studio-41exgzhq.js.map +38 -0
  52. package/dist/chunks/{studio-7v2bpweq.js → studio-4mqb9a0j.js} +6 -4
  53. package/dist/chunks/{studio-7v2bpweq.js.map → studio-4mqb9a0j.js.map} +2 -2
  54. package/dist/chunks/studio-50k77cca.js +1787 -0
  55. package/dist/chunks/studio-50k77cca.js.map +18 -0
  56. package/dist/chunks/{studio-56v3zwk6.js → studio-6a0h7sne.js} +10 -8
  57. package/dist/chunks/{studio-56v3zwk6.js.map → studio-6a0h7sne.js.map} +2 -2
  58. package/dist/chunks/studio-6f0am8f2.js +53 -0
  59. package/dist/chunks/studio-6f0am8f2.js.map +10 -0
  60. package/dist/chunks/studio-6nmjc5qk.js +7002 -0
  61. package/dist/chunks/studio-6nmjc5qk.js.map +32 -0
  62. package/dist/chunks/studio-7afd21nw.js +67 -0
  63. package/dist/chunks/studio-7afd21nw.js.map +10 -0
  64. package/dist/chunks/{studio-xze4zrs1.js → studio-7bmdvzne.js} +2 -2
  65. package/dist/chunks/studio-7bmdvzne.js.map +10 -0
  66. package/dist/chunks/studio-8jpzqk5s.js +128 -0
  67. package/dist/chunks/studio-8jpzqk5s.js.map +10 -0
  68. package/dist/chunks/studio-94m77tgk.js +1482 -0
  69. package/dist/chunks/studio-94m77tgk.js.map +13 -0
  70. package/dist/chunks/studio-9je6w5sq.js +1000 -0
  71. package/dist/chunks/studio-9je6w5sq.js.map +16 -0
  72. package/dist/chunks/{studio-w7c6kvk7.js → studio-a7pzveq1.js} +2 -2
  73. package/dist/chunks/{studio-gs6kqdt1.js → studio-adn0z1k3.js} +14 -8
  74. package/dist/chunks/{studio-gs6kqdt1.js.map → studio-adn0z1k3.js.map} +3 -3
  75. package/dist/chunks/{studio-kr88ryr7.js → studio-bsxxx3x2.js} +17 -15
  76. package/dist/chunks/{studio-kr88ryr7.js.map → studio-bsxxx3x2.js.map} +7 -6
  77. package/dist/chunks/studio-bw5dz6km.js +608 -0
  78. package/dist/chunks/studio-bw5dz6km.js.map +21 -0
  79. package/dist/chunks/studio-c6s5getc.js +4232 -0
  80. package/dist/chunks/studio-c6s5getc.js.map +39 -0
  81. package/dist/chunks/studio-cjm7fdjf.js +451 -0
  82. package/dist/chunks/studio-cjm7fdjf.js.map +14 -0
  83. package/dist/chunks/{studio-cx91mnh8.js → studio-ckwz4d3x.js} +53 -53
  84. package/dist/chunks/{studio-cx91mnh8.js.map → studio-ckwz4d3x.js.map} +5 -5
  85. package/dist/chunks/studio-dg7zyt5x.js +28 -0
  86. package/dist/chunks/studio-dg7zyt5x.js.map +10 -0
  87. package/dist/chunks/studio-dzcyvvvz.js +166 -0
  88. package/dist/chunks/studio-dzcyvvvz.js.map +10 -0
  89. package/dist/chunks/{studio-7kqe41sa.js → studio-evkr5d3b.js} +15 -7
  90. package/dist/chunks/{studio-7kqe41sa.js.map → studio-evkr5d3b.js.map} +5 -4
  91. package/dist/chunks/studio-f12h6177.js +411 -0
  92. package/dist/chunks/studio-f12h6177.js.map +13 -0
  93. package/dist/chunks/studio-hbp1s8m9.js +1817 -0
  94. package/dist/chunks/studio-hbp1s8m9.js.map +14 -0
  95. package/dist/chunks/studio-k2sxjph5.js +15295 -0
  96. package/dist/chunks/studio-k2sxjph5.js.map +235 -0
  97. package/dist/chunks/studio-myje51t3.js +385 -0
  98. package/dist/chunks/studio-myje51t3.js.map +12 -0
  99. package/dist/chunks/studio-na6rwzhw.js +353 -0
  100. package/dist/chunks/studio-na6rwzhw.js.map +11 -0
  101. package/dist/chunks/{studio-4k0m4fmp.js → studio-p471gg5n.js} +126 -3
  102. package/dist/chunks/studio-p471gg5n.js.map +14 -0
  103. package/dist/chunks/{studio-9bjwf0d4.js → studio-rgwcpast.js} +5 -46
  104. package/dist/chunks/{studio-9bjwf0d4.js.map → studio-rgwcpast.js.map} +4 -5
  105. package/dist/chunks/{studio-esrewxap.js → studio-s519zc99.js} +3 -3
  106. package/dist/chunks/studio-sas7mt3k.js +75 -0
  107. package/dist/chunks/studio-sas7mt3k.js.map +10 -0
  108. package/dist/chunks/studio-t5wqwzdh.js +74 -0
  109. package/dist/chunks/studio-t5wqwzdh.js.map +10 -0
  110. package/dist/chunks/{studio-9qpspwfn.js → studio-temv5bw4.js} +5 -5
  111. package/dist/chunks/{studio-9qpspwfn.js.map → studio-temv5bw4.js.map} +1 -1
  112. package/dist/chunks/{studio-qkvt37c0.js → studio-tr20m8g8.js} +669 -18
  113. package/dist/chunks/studio-tr20m8g8.js.map +9 -0
  114. package/dist/chunks/{studio-v7jkjqng.js → studio-v96qfnww.js} +5514 -4291
  115. package/dist/chunks/studio-v96qfnww.js.map +54 -0
  116. package/dist/chunks/studio-vcetc91k.js +659 -0
  117. package/dist/chunks/studio-vcetc91k.js.map +10 -0
  118. package/dist/chunks/{studio-jm0z592w.js → studio-vmymjefp.js} +2972 -4937
  119. package/dist/chunks/studio-vmymjefp.js.map +98 -0
  120. package/dist/chunks/{studio-bghqfmea.js → studio-wwseqyw0.js} +2 -2
  121. package/dist/chunks/studio-x9hmy5kr.js +390 -0
  122. package/dist/chunks/studio-x9hmy5kr.js.map +10 -0
  123. package/dist/chunks/studio-xa0ce0n7.js +335 -0
  124. package/dist/chunks/studio-xa0ce0n7.js.map +10 -0
  125. package/dist/chunks/studio-xm3dbxvn.js +378 -0
  126. package/dist/chunks/studio-xm3dbxvn.js.map +19 -0
  127. package/dist/chunks/{studio-nqw433kv.js → studio-xmvbhg31.js} +2 -2
  128. package/dist/chunks/studio-zv37yrfa.js +1054 -0
  129. package/dist/chunks/studio-zv37yrfa.js.map +13 -0
  130. package/dist/chunks/{ws-client-q7hkz3j9.js → ws-client-654ts66j.js} +6 -4
  131. package/dist/chunks/ws-client-654ts66j.js.map +11 -0
  132. package/dist/iframe-entry.js +668 -230
  133. package/dist/studio.js +21080 -32585
  134. package/index.html +35 -4685
  135. package/package.json +17 -15
  136. package/src/about/about-modal.ts +42 -11
  137. package/src/browse/library-commands.ts +240 -0
  138. package/src/browse/library-layouts.ts +369 -0
  139. package/src/browse/library-model.ts +396 -0
  140. package/src/browse/library-pane.ts +1049 -0
  141. package/src/browse/library-preview.ts +252 -0
  142. package/src/browse/library-source.ts +150 -0
  143. package/src/canvas/canvas-helpers.ts +33 -9
  144. package/src/canvas/canvas-live-render.ts +45 -29
  145. package/src/canvas/canvas-patcher.ts +151 -31
  146. package/src/canvas/canvas-perf.ts +46 -7
  147. package/src/canvas/canvas-render.ts +1092 -220
  148. package/src/canvas/canvas-surface.ts +240 -0
  149. package/src/canvas/canvas-utils.ts +1166 -124
  150. package/src/canvas/iframe-drop.ts +2 -1
  151. package/src/canvas/iframe-entry.ts +241 -4
  152. package/src/canvas/iframe-host.ts +1226 -143
  153. package/src/canvas/iframe-inline-edit.ts +31 -0
  154. package/src/canvas/iframe-interaction.ts +76 -16
  155. package/src/canvas/iframe-keys.ts +107 -45
  156. package/src/canvas/iframe-overlay.ts +38 -1
  157. package/src/canvas/iframe-protocol.ts +137 -2
  158. package/src/canvas/iframe-render.ts +70 -2
  159. package/src/canvas/iframe-slash.ts +6 -0
  160. package/src/canvas/path-mapping.ts +56 -6
  161. package/src/canvas/surface-registry.ts +292 -0
  162. package/src/collab/collab-commands.ts +244 -0
  163. package/src/collab/collab-session.ts +198 -32
  164. package/src/collab/collab-state.ts +28 -2
  165. package/src/collab/presence-chips.ts +100 -9
  166. package/src/commands/active-registry.ts +42 -0
  167. package/src/commands/app-commands.ts +176 -0
  168. package/src/commands/budget.ts +158 -0
  169. package/src/commands/command-args.ts +253 -0
  170. package/src/commands/context.ts +387 -0
  171. package/src/commands/defaults.ts +580 -0
  172. package/src/commands/keymap.ts +571 -0
  173. package/src/commands/levels.ts +345 -0
  174. package/src/commands/live-context.ts +193 -0
  175. package/src/commands/reference.ts +227 -0
  176. package/src/commands/registry.ts +293 -0
  177. package/src/component-props.ts +3 -2
  178. package/src/content/draft-state.ts +106 -0
  179. package/src/content/entry-commands.ts +282 -0
  180. package/src/content/entry-editor.ts +327 -0
  181. package/src/content/entry-fields.ts +116 -0
  182. package/src/content/entry-model.ts +175 -0
  183. package/src/editor/canvas-context-menu.ts +17 -11
  184. package/src/editor/canvas-slash-bridge.ts +11 -4
  185. package/src/editor/context-menu.ts +804 -204
  186. package/src/editor/convert-to-component.ts +31 -10
  187. package/src/editor/convert-to-repeater.ts +3 -2
  188. package/src/editor/inline-edit-apply.ts +5 -5
  189. package/src/editor/inline-edit.ts +86 -50
  190. package/src/editor/insert-zone-action.ts +1 -1
  191. package/src/editor/shortcuts.ts +1043 -329
  192. package/src/editor/slash-menu.ts +1 -0
  193. package/src/files/elements.ts +166 -0
  194. package/src/files/file-ops.ts +178 -31
  195. package/src/files/files.ts +898 -268
  196. package/src/files/fs-events.ts +21 -0
  197. package/src/files/media-meta.ts +203 -0
  198. package/src/files/media-paths.ts +141 -0
  199. package/src/files/media-upload.ts +7 -3
  200. package/src/files/media-usage.ts +189 -0
  201. package/src/github/github-auth.ts +235 -33
  202. package/src/github/github-publish.ts +80 -21
  203. package/src/grid/grid-controller.ts +152 -12
  204. package/src/grid/grid-layout.ts +225 -20
  205. package/src/grid/grid-open.ts +120 -0
  206. package/src/grid/grid-panel.ts +554 -39
  207. package/src/grid/grid-source.ts +27 -1
  208. package/src/grid/redirects-grid.ts +599 -0
  209. package/src/grid/redirects.ts +398 -0
  210. package/src/grid/sources/content-source.ts +108 -2
  211. package/src/i18n/i18n-commands.ts +322 -0
  212. package/src/new-project/add-repo-modal.ts +8 -10
  213. package/src/new-project/import-tab.ts +7 -0
  214. package/src/new-project/location-fields.ts +9 -1
  215. package/src/new-project/new-project-modal.ts +221 -245
  216. package/src/packages/jxsuite-update.ts +97 -48
  217. package/src/packages/pull-package-sync.ts +18 -9
  218. package/src/page-params.ts +2 -1
  219. package/src/panels/activity-bar.ts +152 -91
  220. package/src/panels/activity-panel.ts +481 -0
  221. package/src/panels/ai-chat/chat-markdown.ts +8 -3
  222. package/src/panels/ai-chat/chat-view.ts +245 -18
  223. package/src/panels/ai-chat/composer.ts +31 -5
  224. package/src/panels/ai-chat/sessions-view.ts +17 -10
  225. package/src/panels/ai-panel.ts +279 -53
  226. package/src/panels/block-action-bar.ts +1115 -224
  227. package/src/panels/bottom-dock.ts +337 -0
  228. package/src/panels/canvas-dnd-bridge.ts +37 -8
  229. package/src/panels/chat-panel.ts +29 -12
  230. package/src/panels/data-explorer.ts +254 -112
  231. package/src/panels/data-grid.ts +10 -4
  232. package/src/panels/dnd.ts +28 -51
  233. package/src/panels/editors.ts +434 -107
  234. package/src/panels/elements-panel.ts +66 -2
  235. package/src/panels/empty-state.ts +112 -0
  236. package/src/panels/events-panel.ts +596 -82
  237. package/src/panels/formula-workspace.ts +408 -218
  238. package/src/panels/frontmatter-fields.ts +51 -10
  239. package/src/panels/frontmatter-panel.ts +357 -79
  240. package/src/panels/git-panel.ts +375 -164
  241. package/src/panels/head-panel.ts +791 -85
  242. package/src/panels/i18n-panel.ts +592 -0
  243. package/src/panels/imports-panel.ts +95 -85
  244. package/src/panels/jump-bar.ts +584 -0
  245. package/src/panels/layers-panel.ts +1106 -328
  246. package/src/panels/left-panel.ts +109 -204
  247. package/src/panels/navigator-panels.ts +131 -0
  248. package/src/panels/overlays.ts +7 -2
  249. package/src/panels/pane-context.ts +1477 -0
  250. package/src/panels/pane-grid.ts +406 -0
  251. package/src/panels/panel-registry.ts +340 -0
  252. package/src/panels/panel-scheduler.ts +75 -0
  253. package/src/panels/problems-panel.ts +234 -0
  254. package/src/panels/properties-panel.ts +946 -1139
  255. package/src/panels/provenance.ts +258 -0
  256. package/src/panels/quick-search.ts +791 -122
  257. package/src/panels/right-panel.ts +228 -102
  258. package/src/panels/seo-modal.ts +395 -0
  259. package/src/panels/settings-pane.ts +203 -0
  260. package/src/panels/signals-panel.ts +354 -137
  261. package/src/panels/statement-editor.ts +41 -25
  262. package/src/panels/statusbar.ts +342 -106
  263. package/src/panels/style-panel.ts +974 -463
  264. package/src/panels/style-utils.ts +3 -3
  265. package/src/panels/stylebook-doc.ts +4 -2
  266. package/src/panels/stylebook-layers-panel.ts +3 -2
  267. package/src/panels/stylebook-panel.ts +51 -26
  268. package/src/panels/tab-strip.ts +1058 -78
  269. package/src/panels/target-line.ts +274 -0
  270. package/src/panels/toolbar.ts +631 -484
  271. package/src/panels/welcome-screen.ts +301 -198
  272. package/src/platform-errors.ts +26 -3
  273. package/src/platform.ts +96 -2
  274. package/src/platforms/cloud.ts +71 -8
  275. package/src/platforms/devserver.ts +92 -29
  276. package/src/publish/deploy-checklist.ts +383 -0
  277. package/src/publish/pages-service.ts +10 -1
  278. package/src/publish/publish-commands.ts +218 -0
  279. package/src/publish/publish-panel.ts +110 -16
  280. package/src/recent-projects.ts +3 -2
  281. package/src/services/a11y-report.ts +505 -0
  282. package/src/services/ai-models.ts +30 -1
  283. package/src/services/ai-project-tools.ts +29 -10
  284. package/src/services/ai-system-prompt.ts +53 -14
  285. package/src/services/ai-tools.ts +39 -3
  286. package/src/services/ai-writes.ts +154 -0
  287. package/src/services/announce.ts +97 -0
  288. package/src/services/automation.ts +424 -175
  289. package/src/services/cem-export.ts +9 -4
  290. package/src/services/clock.ts +61 -0
  291. package/src/services/document-assistant.ts +36 -8
  292. package/src/services/idle.ts +179 -0
  293. package/src/services/import-client.ts +26 -0
  294. package/src/services/monaco-buffer.ts +627 -0
  295. package/src/services/monaco-lazy.ts +36 -0
  296. package/src/services/notify.ts +271 -0
  297. package/src/services/profile.ts +155 -0
  298. package/src/services/references.ts +0 -0
  299. package/src/services/token-lint.ts +3 -2
  300. package/src/services/tool-executor.ts +53 -7
  301. package/src/services/trusted-types.ts +123 -0
  302. package/src/settings/contexts-section.ts +427 -0
  303. package/src/settings/contributed-section.ts +149 -11
  304. package/src/settings/css-vars-editor.ts +306 -349
  305. package/src/settings/defs-editor.ts +93 -34
  306. package/src/settings/dependencies-editor.ts +7 -7
  307. package/src/settings/extension-sections.ts +37 -5
  308. package/src/settings/general-settings.ts +204 -121
  309. package/src/settings/locales-section.ts +347 -0
  310. package/src/settings/preferences-accounts.ts +140 -0
  311. package/src/settings/preferences-dialog.ts +620 -0
  312. package/src/settings/preferences-keymap.ts +270 -0
  313. package/src/settings/project-sections.ts +260 -0
  314. package/src/settings/section-registry.ts +169 -0
  315. package/src/settings/settings-document.ts +385 -0
  316. package/src/shell.ts +1549 -0
  317. package/src/site-context.ts +45 -20
  318. package/src/state.ts +14 -275
  319. package/src/store.ts +88 -43
  320. package/src/studio.ts +790 -346
  321. package/src/style/live-preview.ts +36 -0
  322. package/src/style/project-styles.ts +349 -0
  323. package/src/style/token-ref.ts +123 -0
  324. package/src/tabs/patch-ops.ts +16 -4
  325. package/src/tabs/project-config.ts +416 -0
  326. package/src/tabs/selection.ts +323 -0
  327. package/src/tabs/tab.ts +182 -47
  328. package/src/tabs/transact.ts +209 -28
  329. package/src/types.ts +68 -13
  330. package/src/ui/ai-credentials-form.ts +39 -35
  331. package/src/ui/ai-managed-connect.ts +7 -1
  332. package/src/ui/dynamic-slot.ts +224 -81
  333. package/src/ui/expression-editor.ts +71 -63
  334. package/src/ui/field-input.ts +4 -6
  335. package/src/ui/field-row.ts +215 -38
  336. package/src/ui/form-controls.ts +194 -7
  337. package/src/ui/layers.ts +492 -69
  338. package/src/ui/media-picker.ts +115 -25
  339. package/src/ui/panel-resize.ts +109 -91
  340. package/src/ui/progress-modal.ts +131 -50
  341. package/src/ui/regions.ts +462 -0
  342. package/src/ui/schema-form.ts +229 -45
  343. package/src/ui/spectrum.ts +24 -8
  344. package/src/ui/timing.ts +49 -0
  345. package/src/ui/value-selector.ts +20 -0
  346. package/src/ui/value-source.ts +348 -0
  347. package/src/ui/virtual-window.ts +400 -0
  348. package/src/utils/edit-display.ts +3 -2
  349. package/src/utils/inherited-style.ts +67 -37
  350. package/src/view.ts +64 -78
  351. package/src/workspace/pane-derive.ts +1258 -0
  352. package/src/workspace/session.ts +317 -0
  353. package/src/workspace/workspace.ts +1345 -23
  354. package/styles/canvas.css +653 -0
  355. package/styles/forced-colors.css +106 -0
  356. package/styles/inspector.css +1454 -0
  357. package/styles/overlays.css +2704 -0
  358. package/styles/panels.css +1701 -0
  359. package/styles/shell.css +1423 -0
  360. package/styles/tokens.css +151 -0
  361. package/dist/chunks/Overlay.dev-cszx7yn5.js +0 -22
  362. package/dist/chunks/extension-sections-qr3drkkw.js +0 -531
  363. package/dist/chunks/extension-sections-qr3drkkw.js.map +0 -12
  364. package/dist/chunks/files-j8a1t06k.js +0 -50
  365. package/dist/chunks/files-j8a1t06k.js.map +0 -9
  366. package/dist/chunks/git-panel-xtq5z81x.js +0 -28
  367. package/dist/chunks/git-panel-xtq5z81x.js.map +0 -9
  368. package/dist/chunks/layers-panel-ancn2wpq.js +0 -26
  369. package/dist/chunks/layers-panel-ancn2wpq.js.map +0 -9
  370. package/dist/chunks/settings-modal-17ttr96g.js +0 -29
  371. package/dist/chunks/settings-modal-17ttr96g.js.map +0 -9
  372. package/dist/chunks/sp-overlay.dev-earn5as8.js +0 -17
  373. package/dist/chunks/sp-overlay.dev-earn5as8.js.map +0 -9
  374. package/dist/chunks/sp-popover.dev-frxtfejn.js +0 -8
  375. package/dist/chunks/sp-popover.dev-frxtfejn.js.map +0 -9
  376. package/dist/chunks/sp-progress-circle.dev-7tvw34h8.js +0 -14
  377. package/dist/chunks/sp-progress-circle.dev-7tvw34h8.js.map +0 -9
  378. package/dist/chunks/sp-tooltip.dev-a9fywesm.js +0 -21
  379. package/dist/chunks/sp-tooltip.dev-a9fywesm.js.map +0 -10
  380. package/dist/chunks/studio-0xgs7xrs.js +0 -429
  381. package/dist/chunks/studio-0xgs7xrs.js.map +0 -13
  382. package/dist/chunks/studio-4d8hzcz5.js +0 -3427
  383. package/dist/chunks/studio-4d8hzcz5.js.map +0 -26
  384. package/dist/chunks/studio-4k0m4fmp.js.map +0 -13
  385. package/dist/chunks/studio-9smfzqhc.js +0 -19
  386. package/dist/chunks/studio-9smfzqhc.js.map +0 -10
  387. package/dist/chunks/studio-jm0z592w.js.map +0 -115
  388. package/dist/chunks/studio-m2t4mdg5.js +0 -1854
  389. package/dist/chunks/studio-m2t4mdg5.js.map +0 -18
  390. package/dist/chunks/studio-n4p11dfy.js +0 -853
  391. package/dist/chunks/studio-n4p11dfy.js.map +0 -12
  392. package/dist/chunks/studio-qkvt37c0.js.map +0 -9
  393. package/dist/chunks/studio-sjh1wt1a.js +0 -3744
  394. package/dist/chunks/studio-sjh1wt1a.js.map +0 -22
  395. package/dist/chunks/studio-v7jkjqng.js.map +0 -46
  396. package/dist/chunks/studio-xq0t7n5t.js +0 -140
  397. package/dist/chunks/studio-xq0t7n5t.js.map +0 -10
  398. package/dist/chunks/studio-xze4zrs1.js.map +0 -10
  399. package/dist/chunks/studio-yfcstdzy.js +0 -399
  400. package/dist/chunks/studio-yfcstdzy.js.map +0 -12
  401. package/dist/chunks/studio-zp7jncs8.js +0 -469
  402. package/dist/chunks/studio-zp7jncs8.js.map +0 -14
  403. package/dist/chunks/ws-client-q7hkz3j9.js.map +0 -11
  404. package/src/browse/browse-modal.ts +0 -69
  405. package/src/browse/browse.ts +0 -904
  406. package/src/canvas/canvas-diff.ts +0 -216
  407. package/src/canvas/nested-site-style.ts +0 -58
  408. package/src/new-project/design-fields.ts +0 -264
  409. package/src/new-project/templates.ts +0 -61
  410. package/src/panels/tab-bar.ts +0 -548
  411. package/src/settings/settings-modal.ts +0 -203
  412. /package/dist/chunks/{adapter-z8fkmbmv.js.map → adapter-qfnesn5v.js.map} +0 -0
  413. /package/dist/chunks/{cf-settings-mmyv436d.js.map → cf-settings-73sh5z4p.js.map} +0 -0
  414. /package/dist/chunks/{format-host-8jxwts2p.js.map → format-host-mkwsmt8d.js.map} +0 -0
  415. /package/dist/chunks/{jx-validate-qx8degj7.js.map → jx-validate-v6qgknrs.js.map} +0 -0
  416. /package/dist/chunks/{studio-w7c6kvk7.js.map → studio-a7pzveq1.js.map} +0 -0
  417. /package/dist/chunks/{studio-esrewxap.js.map → studio-s519zc99.js.map} +0 -0
  418. /package/dist/chunks/{studio-bghqfmea.js.map → studio-wwseqyw0.js.map} +0 -0
  419. /package/dist/chunks/{studio-nqw433kv.js.map → studio-xmvbhg31.js.map} +0 -0
@@ -1,28 +1,65 @@
1
1
  /// <reference lib="dom" />
2
2
  // ─── Clipboard & Context Menu ─────────────────────────────────────────────────
3
- import { html } from "lit-html";
3
+ /**
4
+ * The element context menu is a RENDERING of the command registry (UX-REDESIGN-PLAN §5.5).
5
+ *
6
+ * This file used to hold an 18-item literal of `{ label, action, danger }` records — a fourth
7
+ * hand-maintained copy of verbs that also exist in the toolbar, the block action bar and the
8
+ * keymap, with bare labels that taught no chords and hand-set danger styling. It now holds the
9
+ * command RECORDS (next to their implementations, which have always lived here) and a renderer that
10
+ * asks `registry.forPlacement("context/element")` what to draw.
11
+ *
12
+ * Everything a row prints is derived:
13
+ *
14
+ * - The `title` — the record is the only place the action is named;
15
+ * - The chord, via `keymap.formatBinding(id)`, so ⌘C is finally taught where it is used;
16
+ * - `destructive` styling, from the record rather than a per-row `danger: true`;
17
+ * - `disabledReason(id)` as a greyed subtitle, so an inapplicable verb explains itself instead of
18
+ * vanishing and leaving the author to guess the menu is state-dependent at all.
19
+ *
20
+ * Group ordering and `when` filtering are the registry's; dividers fall where `group` changes. What
21
+ * is left here is positioning, popover rendering, and the menu keyboard contract.
22
+ */
23
+ import { html, nothing } from "lit-html";
24
+ import { displayTagName } from "@jxsuite/schema/guards";
4
25
  import { jsonClone } from "../utils/studio-utils";
5
26
  import { ref } from "lit-html/directives/ref.js";
6
27
  import { htmlToJx } from "@jxsuite/markup/html-to-jx";
7
28
  import { childIndex, getNodeAtPath, parentElementPath } from "../store";
8
29
  import { activeTab, workspace } from "../workspace/workspace";
30
+ import { isSelected, isSpliceablePath, primarySelection } from "../tabs/selection";
9
31
  import {
10
32
  mutateDuplicateNode,
11
33
  mutateInsertNode,
12
34
  mutateRemoveNode,
35
+ mutateRemoveNodes,
13
36
  mutateReplaceStyle,
14
37
  mutateWrapNode,
15
38
  transactDoc,
16
39
  } from "../tabs/transact";
17
- import { statusMessage } from "../panels/statusbar";
18
- import { convertToComponent } from "./convert-to-component";
19
- import { convertToRepeater } from "./convert-to-repeater";
40
+ import { notify } from "../services/notify";
20
41
  import { componentRegistry } from "../files/components";
21
42
  import { renderPopover } from "../ui/layers";
22
43
  import { rectOf } from "../utils/geometry";
44
+ import { createCommandRegistry } from "../commands/registry";
45
+ import { registerSelectionCommands } from "../panels/block-action-bar";
46
+ import { convertToComponent } from "./convert-to-component";
47
+ import { activeRegistry } from "../commands/active-registry";
48
+ import { defaultCommands, noopCommandDeps } from "../commands/defaults";
49
+ import { inspectorCommands } from "../panels/properties-panel";
50
+ import { usagesSupported } from "../services/references";
51
+ import {
52
+ editorKindForMode,
53
+ hasElementSelection,
54
+ hasSelection,
55
+ makeContext,
56
+ } from "../commands/context";
23
57
 
58
+ import type { AnyCommand, CommandRegistry } from "../commands/registry";
59
+ import type { CommandContext } from "../commands/context";
60
+ import type { Placement } from "../commands/levels";
24
61
  import type { JxPath } from "../state";
25
- import type { JxMutableNode } from "@jxsuite/schema/types";
62
+ import type { JxMutableNode, JxStyle } from "@jxsuite/schema/types";
26
63
 
27
64
  type JxNode = JxMutableNode;
28
65
 
@@ -35,7 +72,10 @@ function nodeToHtml(node: JxNode | string): string {
35
72
  if (typeof node === "string") {
36
73
  return node;
37
74
  }
38
- const tag = node.tagName || "div";
75
+ /* A chosen tag cannot be serialised to one HTML string, so the copy takes the branch the
76
+ candidates list first — clipboard HTML is a lossy export by nature (it drops bindings too),
77
+ and emitting `<[object Object]>` would be worse than lossy. */
78
+ const tag = displayTagName(node.tagName).split("|")[0] || "div";
39
79
  let attrs = "";
40
80
  if (node.attributes) {
41
81
  for (const [k, v] of Object.entries(node.attributes)) {
@@ -144,32 +184,43 @@ async function readFromClipboard() {
144
184
 
145
185
  export async function copyNode() {
146
186
  const tab = activeTab.value;
147
- if (!tab?.session.selection) {
187
+ const selected = primarySelection(tab?.session.selection);
188
+ if (!tab || !selected) {
148
189
  return;
149
190
  }
150
- const node = getNodeAtPath(tab.doc.document, tab.session.selection);
191
+ const node = getNodeAtPath(tab.doc.document, selected);
151
192
  if (!node) {
152
193
  return;
153
194
  }
154
195
  const json = jsonClone(node);
155
196
  await writeToClipboard(json);
156
- statusMessage("Copied");
197
+ notify.success("Copied", { key: "clipboard" });
157
198
  }
158
199
 
159
200
  export async function cutNode() {
160
201
  const tab = activeTab.value;
161
- if (!tab?.session.selection || tab.session.selection.length < 2) {
202
+ const sel = primarySelection(tab?.session.selection);
203
+ if (!tab || !sel || sel.length < 2) {
162
204
  return;
163
205
  }
164
- const sel = tab.session.selection;
165
206
  const node = getNodeAtPath(tab.doc.document, sel);
166
207
  if (!node) {
167
208
  return;
168
209
  }
169
210
  const json = jsonClone(node);
170
211
  await writeToClipboard(json);
171
- transactDoc(tab, (t) => mutateRemoveNode(t, sel));
172
- statusMessage("Cut");
212
+ // The clipboard holds the primary node; the cut removes everything selected, in one undo step.
213
+ const cutting = tab.session.selection.filter((path) => path.length >= 2);
214
+ /* AND THE REMOVAL CAN BE REFUSED, which makes this a copy rather than a cut.
215
+ `transactDoc` consults the collab gate, which pauses structural editing for the whole room
216
+ while source is canonical. Reporting "Cut" anyway claims a removal that did not happen — and
217
+ the toast's action is `edit.undo`, so the one recovery it offered was aimed at whatever edit
218
+ came BEFORE this one. The gate raises its own keyed toast saying why; this one stays silent
219
+ rather than adding a false claim beside it. */
220
+ if (!transactDoc(tab, (t) => mutateRemoveNodes(t, cutting))) {
221
+ return;
222
+ }
223
+ notify.success("Cut", { action: "edit.undo", key: "clipboard" });
173
224
  }
174
225
 
175
226
  export async function pasteNode() {
@@ -183,15 +234,16 @@ export async function pasteNode() {
183
234
  return;
184
235
  }
185
236
 
186
- const pPath = tab.session.selection || [];
237
+ const selected = primarySelection(tab.session.selection);
238
+ const pPath = selected ?? [];
187
239
  const parent = getNodeAtPath(tab.doc.document, pPath);
188
240
  if (!parent) {
189
241
  return;
190
242
  }
191
243
 
192
- if (tab.session.selection && tab.session.selection.length >= 2) {
193
- const pp = parentElementPath(tab.session.selection) as JxPath;
194
- const idx = childIndex(tab.session.selection) as number;
244
+ if (selected && selected.length >= 2) {
245
+ const pp = parentElementPath(selected) as JxPath;
246
+ const idx = childIndex(selected) as number;
195
247
  transactDoc(tab, (t) => {
196
248
  for (let i = 0; i < nodes.length; i++) {
197
249
  mutateInsertNode(t, pp, idx + 1 + i, nodes[i]!);
@@ -205,20 +257,21 @@ export async function pasteNode() {
205
257
  }
206
258
  });
207
259
  }
208
- statusMessage("Pasted");
260
+ notify.success("Pasted", { action: "edit.undo", key: "clipboard" });
209
261
  }
210
262
 
211
263
  export function copyStyles() {
212
264
  const tab = activeTab.value;
213
- if (!tab?.session.selection) {
265
+ const selected = primarySelection(tab?.session.selection);
266
+ if (!tab || !selected) {
214
267
  return;
215
268
  }
216
- const node = getNodeAtPath(tab.doc.document, tab.session.selection);
269
+ const node = getNodeAtPath(tab.doc.document, selected);
217
270
  if (!node?.style) {
218
271
  return;
219
272
  }
220
273
  workspace.styleClipboard = jsonClone(node.style);
221
- statusMessage("Styles copied");
274
+ notify.success("Styles copied", { key: "clipboard" });
222
275
  }
223
276
 
224
277
  export function pasteStyles() {
@@ -226,38 +279,735 @@ export function pasteStyles() {
226
279
  return;
227
280
  }
228
281
  const tab = activeTab.value;
229
- if (!tab?.session.selection) {
282
+ const targets = tab?.session.selection ?? [];
283
+ if (!tab || targets.length === 0) {
230
284
  return;
231
285
  }
232
286
  const style = jsonClone(workspace.styleClipboard);
233
- const sel = tab.session.selection as JxPath;
234
- transactDoc(tab, (t) => mutateReplaceStyle(t, sel, style));
235
- statusMessage("Styles pasted");
287
+ // Pasting one style onto six cards is the §6.5 example, and it is one undo step: `transactDoc`
288
+ // Records everything its callback mutates as a single history entry.
289
+ transactDoc(tab, (t) => {
290
+ for (const sel of targets) {
291
+ mutateReplaceStyle(t, sel, jsonClone(style));
292
+ }
293
+ });
294
+ notify.success(
295
+ targets.length === 1 ? "Styles pasted" : `Styles pasted to ${targets.length} elements`,
296
+ { action: "edit.undo", key: "clipboard" },
297
+ );
298
+ }
299
+
300
+ // ─── The menu target ─────────────────────────────────────────────────────────
301
+
302
+ /**
303
+ * The node an open element menu addresses, plus the per-invocation hooks its rows need.
304
+ *
305
+ * The registry is module-level and long-lived; the target is per-right-click. Every record's `when`
306
+ * / `enablement` / `run` reaches the node through {@link ElementCommandDeps.target}, which is what
307
+ * lets one set of records serve every right-click without being rebuilt.
308
+ */
309
+ export interface ElementMenuTarget {
310
+ path: JxPath;
311
+ node: JxMutableNode;
312
+ /* `onEditComponent` was here, read only by this file's own `selection.editComponent`. That verb
313
+ is `block-action-bar.ts`'s now, and it navigates through the same `navigateToComponent` both
314
+ hosts were already passing in — so the per-target hook was one more way to say one thing. */
315
+ /** Re-render the surface that opened the menu — the outline's inline title editor needs it. */
316
+ rerender?: (() => void) | undefined;
317
+ }
318
+
319
+ /** What the element command records read from the rest of Studio. */
320
+ export interface ElementCommandDeps {
321
+ /** The node the open menu addresses, or `null` when no menu is open. */
322
+ target: () => ElementMenuTarget | null;
323
+ /** The style payload available to paste, or `null` when nothing has been copied. */
324
+ styleClipboard: () => JxStyle | null;
325
+ /** The component document's path when `tagName` names a registered component, else `null`. */
326
+ componentPathFor: (tagName: string) => string | null;
327
+ }
328
+
329
+ /** Whether the node is a repeater (an `Array` prototype node), whose one child is its template. */
330
+ function isArrayNode(node: JxMutableNode): boolean {
331
+ return node.$prototype === "Array";
332
+ }
333
+
334
+ // ─── The element command records ─────────────────────────────────────────────
335
+
336
+ /**
337
+ * The commands the element context menu contributes.
338
+ *
339
+ * `selection.duplicate`, `selection.delete` and `selection.repeat` are deliberately NOT here: they
340
+ * are defined in `commands/defaults.ts` and already declare `context/element`, and a second
341
+ * definition site is the exact failure the registry exists to prevent.
342
+ *
343
+ * `selection.repeat` was here, in this file's PRIVATE registry, which is why the two
344
+ * `repeat-dialog` shots were quarantined: the app registry never held it, so the palette could not
345
+ * list it, no chord could reach it, the AI could not call it, and
346
+ * `__jxAutomation.run("selection.repeat")` answered "unknown command" — the Repeat dialog was
347
+ * reachable by right-click and by nothing else.
348
+ */
349
+ export function elementCommands(deps: ElementCommandDeps): AnyCommand[] {
350
+ /** A menu is open over some node — the precondition every row in this file shares. */
351
+ const hasTarget = () => deps.target() !== null;
352
+ /**
353
+ * The document under the menu is one whose ELEMENT TREE may be restructured.
354
+ *
355
+ * `liveContext` below already computes the honest editor kind, and its own comment says a verb
356
+ * reading it "would have been dead in this menu and live over a settings document, which is
357
+ * exactly backwards" — but no record here read it, so the menu got the backwards half. The menu
358
+ * opens from the canvas AND from the Outline, and the Outline renders whatever the active tab's
359
+ * document is: with Project Settings open that is `project.json` drawn as a layer tree. Delete
360
+ * and Duplicate, inherited from `commands/defaults.ts`, carry `editor.kind === "canvas"` and were
361
+ * correctly filtered out there — while Cut, Paste after, Paste inside, Insert before, Insert
362
+ * after, Wrap, Set Title and Convert to Component all rendered and ran, each transacting an
363
+ * element splice into the file that defines the project. One menu, one target, two rules, and the
364
+ * loose ones were the mutating majority.
365
+ */
366
+ const structurallyEditable = () => {
367
+ const tab = activeTab.value;
368
+ return Boolean(tab) && editorKindForMode(tab!.session.ui.canvasMode) === "canvas";
369
+ };
370
+ /** The target sits in a splice coordinate, so structural verbs can address it. */
371
+ const spliceable = () => {
372
+ const target = deps.target();
373
+ return target !== null && isSpliceablePath(target.path) && structurallyEditable();
374
+ };
375
+ /** Not the repeater itself: its content is the single `map` template, not a child list. */
376
+ const notRepeater = () => {
377
+ const target = deps.target();
378
+ return target !== null && !isArrayNode(target.node);
379
+ };
380
+ const NOT_STRUCTURAL =
381
+ "an element with a sibling position — not the page root or a repeater item";
382
+
383
+ return [
384
+ /* COPY AND CUT LIVE IN `editor/shortcuts.ts`, which owns the chord table.
385
+ Two records with this id existed — same title, same `mod+c`, both calling the `copyNode()`
386
+ exported from THIS file — because the menu's registry and the app's never met. They meet now,
387
+ and the menu inherits them by `menus: ["context/element"]` like Duplicate and Delete. */
388
+ {
389
+ id: "edit.pasteAfter",
390
+ title: "Paste after",
391
+ category: "Edit",
392
+ level: "selection",
393
+ keyScope: "canvas",
394
+ /* NO CHORD. ⌘V is `edit.paste`'s (`editor/shortcuts.ts`), and `pasteNode()` — the function it
395
+ runs — already inserts after the selection when there is one. This record claimed the same
396
+ chord in the same scope, which the keymap refuses; the two registries never met, so the
397
+ conflict was latent until the element family joined the app's. The row is kept because it
398
+ is the specific verb with the specific refusal sentence, but the key belongs to the general
399
+ one, and printing a chord this record does not own would be a lie in the menu. */
400
+ menus: ["context/element", "palette"],
401
+ group: "1_clipboard",
402
+ undo: "document",
403
+ when: () => hasTarget() && structurallyEditable(),
404
+ enablement: () => spliceable() && notRepeater(),
405
+ requires: NOT_STRUCTURAL,
406
+ run: async () => {
407
+ const target = deps.target();
408
+ const nodes = await readFromClipboard();
409
+ if (!target || !nodes || nodes.length === 0) {
410
+ return;
411
+ }
412
+ const parent = parentElementPath(target.path) as JxPath;
413
+ const idx = childIndex(target.path) as number;
414
+ transactDoc(activeTab.value, (t) => {
415
+ for (const [offset, node] of nodes.entries()) {
416
+ mutateInsertNode(t, parent, idx + 1 + offset, node);
417
+ }
418
+ });
419
+ notify.success("Pasted", { action: "edit.undo", key: "clipboard" });
420
+ },
421
+ },
422
+ {
423
+ id: "edit.pasteInside",
424
+ title: "Paste inside",
425
+ category: "Edit",
426
+ level: "selection",
427
+ menus: ["context/element", "palette"],
428
+ group: "1_clipboard",
429
+ undo: "document",
430
+ when: () => hasTarget() && structurallyEditable(),
431
+ // A repeater has no child list to paste into, and the document root is addressed by the
432
+ // Canvas itself rather than by an element menu.
433
+ enablement: () => {
434
+ const target = deps.target();
435
+ return target !== null && target.path.length >= 2 && notRepeater();
436
+ },
437
+ requires: "a container element that is not a repeater",
438
+ run: async () => {
439
+ const target = deps.target();
440
+ const nodes = await readFromClipboard();
441
+ if (!target || !nodes || nodes.length === 0) {
442
+ return;
443
+ }
444
+ const idx = Array.isArray(target.node.children) ? target.node.children.length : 0;
445
+ transactDoc(activeTab.value, (t) => {
446
+ for (const [offset, node] of nodes.entries()) {
447
+ mutateInsertNode(t, target.path, idx + offset, node);
448
+ }
449
+ });
450
+ notify.success("Pasted", { action: "edit.undo", key: "clipboard" });
451
+ },
452
+ },
453
+
454
+ // ── Styles ──
455
+ {
456
+ id: "edit.copyStyles",
457
+ title: "Copy styles",
458
+ category: "Edit",
459
+ level: "selection",
460
+ menus: ["context/element", "palette"],
461
+ group: "2_styles",
462
+ undo: "none",
463
+ when: hasTarget,
464
+ enablement: () => Boolean(deps.target()?.node.style),
465
+ requires: "styles on the selected element",
466
+ run: () => {
467
+ const style = deps.target()?.node.style;
468
+ if (!style) {
469
+ return;
470
+ }
471
+ workspace.styleClipboard = jsonClone(style);
472
+ notify.success("Styles copied", { key: "clipboard" });
473
+ },
474
+ },
475
+ {
476
+ id: "edit.pasteStyles",
477
+ title: "Paste styles",
478
+ category: "Edit",
479
+ level: "selection",
480
+ menus: ["context/element", "palette"],
481
+ group: "2_styles",
482
+ undo: "document",
483
+ when: () => hasTarget() && structurallyEditable(),
484
+ enablement: () => deps.styleClipboard() !== null,
485
+ requires: "a copied style set",
486
+ run: () => {
487
+ const target = deps.target();
488
+ const style = deps.styleClipboard();
489
+ if (!target || !style) {
490
+ return;
491
+ }
492
+ const clone = jsonClone(style);
493
+ transactDoc(activeTab.value, (t) => mutateReplaceStyle(t, target.path, clone));
494
+ notify.success("Styles pasted", { action: "edit.undo", key: "clipboard" });
495
+ },
496
+ },
497
+
498
+ // ── Structure ── (shares "3_structure" with the default set's Duplicate)
499
+ {
500
+ id: "selection.insertBefore",
501
+ title: "Insert before",
502
+ category: "Insert",
503
+ level: "selection",
504
+ menus: ["context/element", "palette"],
505
+ group: "3_structure",
506
+ undo: "document",
507
+ when: hasTarget,
508
+ enablement: spliceable,
509
+ requires: NOT_STRUCTURAL,
510
+ run: () => insertSiblingParagraph(deps.target(), 0),
511
+ },
512
+ {
513
+ id: "selection.insertAfter",
514
+ title: "Insert after",
515
+ category: "Insert",
516
+ level: "selection",
517
+ menus: ["context/element", "palette"],
518
+ group: "3_structure",
519
+ undo: "document",
520
+ when: hasTarget,
521
+ enablement: spliceable,
522
+ requires: NOT_STRUCTURAL,
523
+ run: () => insertSiblingParagraph(deps.target(), 1),
524
+ },
525
+ {
526
+ id: "selection.wrap",
527
+ title: "Wrap in Div",
528
+ category: "Selection",
529
+ level: "selection",
530
+ menus: ["context/element", "palette"],
531
+ group: "3_structure",
532
+ undo: "document",
533
+ when: hasTarget,
534
+ enablement: spliceable,
535
+ requires: NOT_STRUCTURAL,
536
+ run: () => {
537
+ const target = deps.target();
538
+ if (target) {
539
+ transactDoc(activeTab.value, (t) => mutateWrapNode(t, target.path));
540
+ }
541
+ },
542
+ },
543
+
544
+ // ── Identity ──
545
+ {
546
+ id: "selection.setTitle",
547
+ title: "Set Title",
548
+ category: "Selection",
549
+ level: "selection",
550
+ menus: ["context/element", "palette"],
551
+ group: "4_identity",
552
+ undo: "document",
553
+ // The inline title editor lives in the surface that opened the menu; with no `rerender` hook
554
+ // There is nothing to edit into, and the row used to render as a silent no-op.
555
+ when: () => Boolean(deps.target()?.rerender),
556
+ enablement: spliceable,
557
+ requires: NOT_STRUCTURAL,
558
+ run: async () => {
559
+ const target = deps.target();
560
+ if (!target?.rerender) {
561
+ return;
562
+ }
563
+ // Lazy import breaks the context-menu ↔ layers-panel module cycle
564
+ const { startLayerTitleEdit } = await import("../panels/layers-panel");
565
+ startLayerTitleEdit(target.path, target.rerender);
566
+ },
567
+ },
568
+ /* EDIT COMPONENT AND CONVERT TO COMPONENT LIVE IN `panels/block-action-bar.ts`, beside the
569
+ block bar's other selection verbs, and are inherited here by `menus`. Both were defined twice
570
+ with different `when` predicates, so the menu row and the block-bar button could disagree
571
+ about whether the same selection was a component instance. */
572
+ ];
573
+ }
574
+
575
+ /** Insert an empty paragraph `offset` positions from the target (0 = before, 1 = after). */
576
+ function insertSiblingParagraph(target: ElementMenuTarget | null, offset: number): void {
577
+ if (!target) {
578
+ return;
579
+ }
580
+ const parent = parentElementPath(target.path) as JxPath;
581
+ const idx = childIndex(target.path) as number;
582
+ transactDoc(activeTab.value, (t) =>
583
+ mutateInsertNode(t, parent, idx + offset, { children: [], tagName: "p" }),
584
+ );
585
+ }
586
+
587
+ /**
588
+ * Copy and Cut — the two clipboard verbs, defined ONCE, beside the functions they call.
589
+ *
590
+ * They were defined twice: here, as part of the element menu's family, and in
591
+ * `editor/shortcuts.ts`'s chord table — same ids, same chords, both calling the `copyNode()` and
592
+ * `cutNode()` exported from this file. The two registries never met, so the duplicate ids were a
593
+ * debt rather than a crash; composing the element family into the app registry collected it.
594
+ *
595
+ * `edit.cut` is VISIBLE on the document root and disabled there with its `requires` sentence,
596
+ * rather than hidden — a greyed row that explains itself teaches, an absent one does not.
597
+ */
598
+ export function clipboardCommands(): AnyCommand[] {
599
+ return [
600
+ {
601
+ category: "Edit",
602
+ group: "1_clipboard",
603
+ id: "edit.copy",
604
+ keybinding: "mod+c",
605
+ keyScope: "canvas",
606
+ level: "selection",
607
+ menus: ["context/element", "palette"],
608
+ requires: "an element selection",
609
+ run: () => {
610
+ void copyNode();
611
+ },
612
+ title: "Copy",
613
+ undo: "none",
614
+ when: hasSelection,
615
+ },
616
+ {
617
+ category: "Edit",
618
+ destructive: true,
619
+ enablement: hasElementSelection,
620
+ group: "1_clipboard",
621
+ id: "edit.cut",
622
+ keybinding: "mod+x",
623
+ keyScope: "canvas",
624
+ level: "selection",
625
+ menus: ["context/element", "palette"],
626
+ requires: "an element selection that is not the document root",
627
+ run: () => {
628
+ void cutNode();
629
+ },
630
+ title: "Cut",
631
+ undo: "document",
632
+ when: hasSelection,
633
+ },
634
+ ];
635
+ }
636
+
637
+ /** Define the element menu's commands on `registry`. Throws on a duplicate id or chord clash. */
638
+ export function registerElementCommands(registry: CommandRegistry, deps: ElementCommandDeps): void {
639
+ registry.registerAll(elementCommands(deps));
640
+ }
641
+
642
+ // ─── The live registry ───────────────────────────────────────────────────────
643
+
644
+ /** The node the currently open menu addresses. */
645
+ let _target: ElementMenuTarget | null = null;
646
+
647
+ /**
648
+ * How the FALLBACK registry opens a component document.
649
+ *
650
+ * Set by `setContextMenuNavigate` from the bootstrap. In the running app `contextMenuRegistry()`
651
+ * returns the app's registry and this is never read; it exists so the fallback the tests and the
652
+ * pre-bootstrap window use is not a registry with a lying no-op in it.
653
+ */
654
+ let _navigate: ((path: string) => void | Promise<void>) | null = null;
655
+
656
+ /** Publish how the fallback registry opens a component document. */
657
+ export function setContextMenuNavigate(
658
+ navigate: ((path: string) => void | Promise<void>) | null,
659
+ ): void {
660
+ _navigate = navigate;
661
+ }
662
+
663
+ /**
664
+ * The node these verbs act on: the open menu's target, or — when no menu is open — the SELECTION.
665
+ *
666
+ * The fallback is what makes these eight records reachable from anywhere but a right-click. Every
667
+ * one of them declares `menus: ["context/element", "palette"]` and the palette never listed one,
668
+ * because `target()` returned `null` outside the menu and each record's `when` reads it. "Paste
669
+ * Style" and "Convert to Component" were pointer-only verbs advertising themselves as palette
670
+ * entries, in a plan whose first principle is that a capability has exactly one definition site and
671
+ * every surface projects it.
672
+ *
673
+ * A right-click already writes the selection it targets (see `openElementMenu`), so the two agree
674
+ * by construction rather than by coincidence.
675
+ */
676
+ function commandTarget(): ElementMenuTarget | null {
677
+ if (_target) {
678
+ return _target;
679
+ }
680
+ const tab = activeTab.value;
681
+ const path = primarySelection(tab?.session.selection);
682
+ if (!tab || !path) {
683
+ return null;
684
+ }
685
+ const node = getNodeAtPath(tab.doc.document, path) as JxMutableNode | undefined;
686
+ return node ? { node, path } : null;
687
+ }
688
+
689
+ /** Deps bound to the live modules. */
690
+ const liveDeps: ElementCommandDeps = {
691
+ componentPathFor: (tagName) =>
692
+ // A custom-element tag is the only thing that CAN name a component; the registry lookup is
693
+ // What decides whether it does.
694
+ (tagName.includes("-") && componentRegistry.find((c) => c.tagName === tagName)?.path) || null,
695
+ styleClipboard: () => workspace.styleClipboard,
696
+ target: commandTarget,
697
+ };
698
+
699
+ /**
700
+ * The eight element records, bound to the live modules, for the APP registry.
701
+ *
702
+ * `contextMenuRegistry()` below still builds its own — the menu needs a context whose selection
703
+ * facts describe the right-clicked node rather than the app's ambient one — but the records are
704
+ * these, so the palette row and the menu row are one definition rather than two that drift.
705
+ */
706
+ export function liveElementCommands(): AnyCommand[] {
707
+ return elementCommands(liveDeps);
708
+ }
709
+
710
+ /**
711
+ * The facts the predicates read, derived from the open menu's target.
712
+ *
713
+ * `selection.isRoot` is the context's "this selection has no removable position" flag — it is what
714
+ * `selection.delete` gates on — so it reports true for a repeater template as well as for the
715
+ * document root. Both are unspliceable, and treating only the literal root as root would let Delete
716
+ * splice a template at NaN (see {@link isSpliceablePath}).
717
+ */
718
+ function liveContext(): CommandContext {
719
+ const target = commandTarget();
720
+ const tab = activeTab.value;
721
+ // One read, two consumers — a chosen tag reads as `a|div`, which matches no component id.
722
+ const targetTag = displayTagName(target?.node.tagName);
723
+
724
+ return makeContext({
725
+ // The menu's context has to carry capabilities too, now that it renders a record gated on one:
726
+ // `makeContext` defaults every capability to false, which would hide Find Usages on every host.
727
+ capability: { findReferences: usagesSupported() },
728
+ document: { dirty: Boolean(tab?.doc.dirty), open: Boolean(tab) },
729
+ /* The editor the menu was opened over. `makeContext` defaults this to "none", which was a
730
+ falsehood the records here got away with only because every one of them gates on the menu's
731
+ own target instead: a verb that reads `editor.kind` — as the canvas verbs in `shortcuts.ts`
732
+ now do — would have been dead in this menu and live over a settings document, which is
733
+ exactly backwards. The menu opens from the canvas AND from the Outline, so the kind comes
734
+ from the tab rather than being asserted. */
735
+ editor: { kind: tab ? editorKindForMode(tab.session.ui.canvasMode) : "none" },
736
+ selection: {
737
+ count: target ? 1 : 0,
738
+ isComponentInstance: Boolean(target && liveDeps.componentPathFor(targetTag)),
739
+ isRepeater: target ? isArrayNode(target.node) : false,
740
+ isRoot: target ? !isSpliceablePath(target.path) : false,
741
+ kind: targetTag,
742
+ },
743
+ });
744
+ }
745
+
746
+ let _registry: CommandRegistry | null = null;
747
+
748
+ /**
749
+ * The registry the menu renders.
750
+ *
751
+ * It carries this file's records PLUS every `context/element` record from `commands/defaults.ts`
752
+ * (Duplicate and Delete). Those two have their one definition site there; until a bootstrap
753
+ * composes every contribution point into a single app-wide registry, the menu builds its own and
754
+ * supplies real implementations for exactly the verbs it renders.
755
+ */
756
+ export function contextMenuRegistry(): CommandRegistry {
757
+ /*
758
+ * THE APP'S REGISTRY, when there is one — §5.5's "the file keeps positioning and popover
759
+ * rendering only".
760
+ *
761
+ * The private registry below existed because the menu's context had to describe the RIGHT-CLICKED
762
+ * node while the app's described the ambient selection. Those are the same node now:
763
+ * `openElementMenu` writes the selection it targets before opening, and `commandTarget()` reads
764
+ * the selection when no menu is open. So the divergence the second registry was built to survive
765
+ * no longer exists, and keeping it meant the menu rendered a different `edit.copy` from the one
766
+ * ⌘C runs.
767
+ *
768
+ * It also means the menu renders every `context/element` record in the app — including ones
769
+ * contributed after this file loads — instead of the four families it happened to import.
770
+ */
771
+ const app = activeRegistry();
772
+ if (app) {
773
+ return app;
774
+ }
775
+ if (_registry) {
776
+ return _registry;
777
+ }
778
+ const registry = createCommandRegistry({ getContext: liveContext });
779
+ const inherited = defaultCommands({
780
+ ...noopCommandDeps(),
781
+ // Neither implementation re-checks spliceability. `selection.delete` and `selection.duplicate`
782
+ // Both declare `enablement: structurallyEditable`, which reads `selection.isRoot` — and
783
+ // {@link liveContext} sets that from the very predicate a hand-guard here would repeat.
784
+ // `registry.run` throws `CommandUnavailableError` before a disabled record's `run` is reached,
785
+ // So the guard was unreachable, and a second copy of the invariant is how the two come to
786
+ // Disagree.
787
+ deleteSelection: () => {
788
+ const target = _target;
789
+ if (target) {
790
+ transactDoc(activeTab.value, (t) => mutateRemoveNode(t, target.path));
791
+ }
792
+ },
793
+ duplicateSelection: () => {
794
+ const target = _target;
795
+ if (target) {
796
+ transactDoc(activeTab.value, (t) => mutateDuplicateNode(t, target.path));
797
+ }
798
+ },
799
+ }).filter((command) => (command.menus ?? []).includes("context/element"));
800
+ registry.registerAll(inherited);
801
+ // Find Usages has its one definition site in `panels/properties-panel.ts`, beside the section it
802
+ // Opens. Pulling its `context/element` records in here — the same move `defaultCommands` gets
803
+ // Above — is what keeps the menu row and the palette entry a single record rather than two that
804
+ // Drift.
805
+ registry.registerAll(
806
+ inspectorCommands().filter((command) => (command.menus ?? []).includes("context/element")),
807
+ );
808
+ /* The clipboard pair and the two component verbs, from the modules that own them. Each was
809
+ DEFINED a second time in this file until composing the element family into the app registry
810
+ turned the duplicate ids into a hard failure — which is what a "debt with a name" becomes when
811
+ the thing it was waiting for arrives. */
812
+ registry.registerAll(clipboardCommands());
813
+ registerSelectionCommands(registry, {
814
+ convertToComponent: () => convertToComponent(),
815
+ // The same seam `panels/block-action-bar.ts`'s own fallback uses: the host that opened the menu
816
+ // Supplies navigation, and in the real app this whole registry is bypassed for the app's.
817
+ navigateToComponent: (path) => {
818
+ void _navigate?.(path);
819
+ },
820
+ });
821
+ registerElementCommands(registry, liveDeps);
822
+ _registry = registry;
823
+ return registry;
236
824
  }
237
825
 
238
826
  // ─── Context menu ─────────────────────────────────────────────────────────────
239
827
 
828
+ /** One rendered row: a record plus everything the registry says about it right now. */
829
+ interface MenuRow {
830
+ command: AnyCommand;
831
+ enabled: boolean;
832
+ /** The `requires` sentence, printed as a greyed subtitle when the row is disabled. */
833
+ reason: string | undefined;
834
+ chord: string | undefined;
835
+ /** Whether a divider opens this row's group. */
836
+ dividerAbove: boolean;
837
+ }
838
+
240
839
  let _ctxHandle: ReturnType<typeof renderPopover> | null = null;
840
+ let _rows: MenuRow[] = [];
841
+ let _activeIdx = 0;
842
+ /** Whatever owned the keyboard before the menu took it, so it can be handed back. */
843
+ let _opener: HTMLElement | null = null;
844
+
845
+ /** The rendered `sp-menu-item` elements, in row order. */
846
+ function itemElements(): HTMLElement[] {
847
+ return _ctxHandle
848
+ ? [..._ctxHandle.host.querySelectorAll<HTMLElement>("sp-menu-item[data-command-id]")]
849
+ : [];
850
+ }
851
+
852
+ /** Move the roving tabindex (and the caret) to `index`, wrapping at both ends. */
853
+ function focusItem(index: number): void {
854
+ const items = itemElements();
855
+ if (items.length === 0) {
856
+ return;
857
+ }
858
+ const next = ((index % items.length) + items.length) % items.length;
859
+ for (const [at, item] of items.entries()) {
860
+ item.tabIndex = at === next ? 0 : -1;
861
+ item.toggleAttribute("focused", at === next);
862
+ }
863
+ _activeIdx = next;
864
+ items[next]?.focus();
865
+ }
866
+
867
+ /** Run the row's command, then close. Disabled rows explain themselves and do nothing. */
868
+ function activateRow(index: number): void {
869
+ const row = _rows[index];
870
+ if (!row?.enabled) {
871
+ return;
872
+ }
873
+ // Run BEFORE dismissing: every `run` reads `deps.target()` synchronously on entry, and dismissal
874
+ // Clears the target.
875
+ const result = contextMenuRegistry().run(row.command.id);
876
+ dismissContextMenu();
877
+ void result;
878
+ }
879
+
880
+ /**
881
+ * The menu keyboard contract: Up/Down/Home/End move, Enter/Space activate, Escape and Tab dismiss.
882
+ *
883
+ * Bound on `document` in the CAPTURE phase, the same shape `editor/slash-menu.ts` uses: the app's
884
+ * own Escape (which walks the selection ladder) and the canvas arrow-key nudges must not also fire,
885
+ * and a bubble-phase handler on the popover would race Spectrum's own menu key handling.
886
+ */
887
+ function onMenuKeydown(e: KeyboardEvent): void {
888
+ if (!_ctxHandle) {
889
+ return;
890
+ }
891
+ switch (e.key) {
892
+ case "ArrowDown": {
893
+ focusItem(_activeIdx + 1);
894
+ break;
895
+ }
896
+ case "ArrowUp": {
897
+ focusItem(_activeIdx - 1);
898
+ break;
899
+ }
900
+ case "Home": {
901
+ focusItem(0);
902
+ break;
903
+ }
904
+ case "End": {
905
+ focusItem(_rows.length - 1);
906
+ break;
907
+ }
908
+ case "Enter":
909
+ case " ": {
910
+ activateRow(_activeIdx);
911
+ break;
912
+ }
913
+ case "Escape":
914
+ case "Tab": {
915
+ dismissContextMenu();
916
+ break;
917
+ }
918
+ default: {
919
+ return;
920
+ }
921
+ }
922
+ e.preventDefault();
923
+ e.stopPropagation();
924
+ }
925
+
926
+ /** Drop the menu's global state and hand the keyboard back to whatever opened it. */
927
+ function teardownMenu(): void {
928
+ document.removeEventListener("keydown", onMenuKeydown, true);
929
+ const opener = _opener;
930
+ _target = null;
931
+ _rows = [];
932
+ _activeIdx = 0;
933
+ _opener = null;
934
+ // Only when the menu still held the keyboard: an outside CLICK has already moved focus somewhere
935
+ // The author chose, and yanking it back to the opener would fight them.
936
+ const active = document.activeElement;
937
+ if (opener?.isConnected && (!active || active === document.body)) {
938
+ opener.focus();
939
+ }
940
+ }
241
941
 
242
942
  /** Dismiss the context menu if open. */
243
943
  export function dismissContextMenu() {
244
- if (_ctxHandle) {
245
- _ctxHandle.dismiss();
246
- _ctxHandle = null;
944
+ if (!_ctxHandle) {
945
+ return;
247
946
  }
947
+ const handle = _ctxHandle;
948
+ _ctxHandle = null;
949
+ handle.dismiss();
950
+ teardownMenu();
951
+ }
952
+
953
+ /** Ask the registry what belongs in `placement` right now and decorate each record for rendering. */
954
+ function buildRows(placement: Placement): MenuRow[] {
955
+ const registry = contextMenuRegistry();
956
+ let group: string | undefined;
957
+ return registry.forPlacement(placement).map((command, index) => {
958
+ const dividerAbove = index > 0 && command.group !== group;
959
+ ({ group } = command);
960
+ const enabled = registry.isEnabled(command.id);
961
+ const chord = registry.keymap.formatBinding(command.id);
962
+ return {
963
+ // A chord that just restates the row's own name ("Delete" bound to Delete) teaches nothing
964
+ // And reads as a stutter, so it is not printed.
965
+ chord: chord?.toLowerCase() === command.title.toLowerCase() ? undefined : chord,
966
+ command,
967
+ dividerAbove,
968
+ enabled,
969
+ reason: enabled ? undefined : registry.disabledReason(command.id),
970
+ };
971
+ });
972
+ }
973
+
974
+ /** One row. Everything it prints comes off the record — nothing is passed in per call site. */
975
+ function rowTemplate(row: MenuRow, index: number) {
976
+ return html`${
977
+ row.dividerAbove ? html`<sp-menu-divider role="separator"></sp-menu-divider>` : nothing
978
+ }<sp-menu-item
979
+ role="menuitem"
980
+ data-command-id=${row.command.id}
981
+ tabindex=${index === 0 ? 0 : -1}
982
+ ?disabled=${!row.enabled}
983
+ aria-disabled=${row.enabled ? "false" : "true"}
984
+ style=${row.command.destructive && row.enabled ? "color: var(--danger)" : ""}
985
+ @click=${() => activateRow(index)}
986
+ >${row.command.title}${
987
+ row.chord
988
+ ? html`<kbd slot="value" style="color: var(--fg-dim)">${row.chord}</kbd>`
989
+ : nothing
990
+ }${
991
+ row.reason ? html`<span slot="description">Needs ${row.reason}</span>` : nothing
992
+ }</sp-menu-item
993
+ >`;
248
994
  }
249
995
 
250
996
  /**
251
997
  * @param {MouseEvent} e
252
998
  * @param {JxPath} path
253
- * @param {{ onEditComponent?: (path: string) => void; rerender?: () => void }} [opts]
999
+ * @param {{
1000
+ * rerender?: () => void;
1001
+ * placement?: Placement;
1002
+ * }} [opts]
254
1003
  */
255
1004
  export function showContextMenu(
256
1005
  e: MouseEvent,
257
1006
  path: JxPath,
258
1007
  opts: {
259
- onEditComponent?: (path: string) => void;
260
1008
  rerender?: () => void;
1009
+ /** Which registry placement to render. The canvas and the outline are both element menus. */
1010
+ placement?: Placement;
261
1011
  } = {},
262
1012
  ) {
263
1013
  e.preventDefault();
@@ -272,169 +1022,24 @@ export function showContextMenu(
272
1022
  return;
273
1023
  }
274
1024
 
275
- // Select the node
276
- tab.session.selection = path;
277
-
278
- // Index-based structural actions (cut/duplicate/insert/wrap/delete) require a numeric child
279
- // Index. Repeater templates (path tail "map") and the document root don't have one — they get
280
- // Copy only — so we never splice with a non-numeric index.
281
- const idxIsNumber = typeof childIndex(path) === "number";
282
-
283
- const items: { label: string; action?: () => void | Promise<void>; danger?: boolean }[] = [
284
- { action: () => copyNode(), label: "Copy" },
285
- ];
286
-
287
- if (path.length >= 2 && idxIsNumber) {
288
- items.push(
289
- { action: () => cutNode(), label: "Cut" },
290
- {
291
- action: () => transactDoc(activeTab.value, (t) => mutateDuplicateNode(t, path)),
292
- label: "Duplicate",
293
- },
294
- );
295
- if (node.style) {
296
- const nodeStyle = node.style;
297
- items.push({
298
- action: () => {
299
- workspace.styleClipboard = jsonClone(nodeStyle);
300
- statusMessage("Styles copied");
301
- },
302
- label: "Copy styles",
303
- });
304
- }
305
- if (workspace.styleClipboard) {
306
- items.push({
307
- action: () => {
308
- if (!workspace.styleClipboard) {
309
- return;
310
- }
311
- const style = jsonClone(workspace.styleClipboard);
312
- transactDoc(activeTab.value, (t) => mutateReplaceStyle(t, path, style));
313
- statusMessage("Styles pasted");
314
- },
315
- label: "Paste styles",
316
- });
317
- }
318
- // Separator
319
- items.push(
320
- { label: "—" },
321
- {
322
- action: () => {
323
- const pp = parentElementPath(path) as JxPath;
324
- const idx = childIndex(path) as number;
325
- transactDoc(activeTab.value, (t) =>
326
- mutateInsertNode(t, pp, idx, { children: [], tagName: "p" }),
327
- );
328
- },
329
- label: "Insert before",
330
- },
331
- {
332
- action: () => {
333
- const pp = parentElementPath(path) as JxPath;
334
- const idx = childIndex(path) as number;
335
- transactDoc(activeTab.value, (t) =>
336
- mutateInsertNode(t, pp, idx + 1, { children: [], tagName: "p" }),
337
- );
338
- },
339
- label: "Insert after",
340
- },
341
- {
342
- action: () => transactDoc(activeTab.value, (t) => mutateWrapNode(t, path)),
343
- label: "Wrap in Div",
344
- },
345
- );
346
- // Don't offer Repeat on a repeater template (path tail "map") or on an array node itself.
347
- if (path.at(-1) !== "map" && (node as JxMutableNode).$prototype !== "Array") {
348
- items.push({
349
- action: () => convertToRepeater(),
350
- label: "Repeat...",
351
- });
352
- }
353
- items.push({
354
- action: async () => {
355
- if (opts.rerender) {
356
- // Lazy import breaks the context-menu ↔ layers-panel module cycle
357
- const { startLayerTitleEdit } = await import("../panels/layers-panel");
358
- startLayerTitleEdit(path, opts.rerender);
359
- }
360
- },
361
- label: "Set Title",
362
- });
363
- if (node.tagName) {
364
- const isComponent =
365
- node.tagName.includes("-") &&
366
- componentRegistry.some(
367
- (/** @type {{ tagName: string }} */ c) => c.tagName === node.tagName,
368
- );
369
- if (isComponent && opts.onEditComponent) {
370
- const comp = componentRegistry.find(
371
- (/** @type {{ tagName: string; path: string }} */ c) => c.tagName === node.tagName,
372
- );
373
- items.push({
374
- action: () => opts.onEditComponent?.(comp?.path as string),
375
- label: "Edit Component",
376
- });
377
- } else if (!isComponent) {
378
- items.push({
379
- action: () => convertToComponent(),
380
- label: "Convert to Component",
381
- });
382
- }
383
- }
384
- // Separator
385
- items.push(
386
- { label: "—" },
387
- {
388
- action: () => transactDoc(activeTab.value, (t) => mutateRemoveNode(t, path)),
389
- danger: true,
390
- label: "Delete",
391
- },
392
- );
393
- }
394
- // Paste targets — never into/after an array node (its content is the single map template).
395
- if (path.length >= 2 && (node as JxMutableNode).$prototype !== "Array") {
396
- items.push(
397
- { label: "—" },
398
- {
399
- action: async () => {
400
- const nodes = await readFromClipboard();
401
- if (!nodes || nodes.length === 0) {
402
- return;
403
- }
404
- const idx = Array.isArray(node.children) ? node.children.length : 0;
405
- transactDoc(activeTab.value, (t) => {
406
- for (let i = 0; i < nodes.length; i++) {
407
- mutateInsertNode(t, path, idx + i, nodes[i]!);
408
- }
409
- });
410
- statusMessage("Pasted");
411
- },
412
- label: "Paste inside",
413
- },
414
- );
415
- if (idxIsNumber) {
416
- items.push({
417
- action: async () => {
418
- const nodes = await readFromClipboard();
419
- if (!nodes || nodes.length === 0) {
420
- return;
421
- }
422
- const pp = parentElementPath(path) as JxPath;
423
- const idx = childIndex(path) as number;
424
- transactDoc(activeTab.value, (t) => {
425
- for (let i = 0; i < nodes.length; i++) {
426
- mutateInsertNode(t, pp, idx + 1 + i, nodes[i]!);
427
- }
428
- });
429
- statusMessage("Pasted");
430
- },
431
- label: "Paste after",
432
- });
433
- }
1025
+ // Select the node — the menu addresses it, and so does everything the commands read. A
1026
+ // Right-click INSIDE an existing multi-selection keeps that selection, the way every list does:
1027
+ // Collapsing six selected cards to one because the user aimed at one of them would silently
1028
+ // Retarget the very batch commands the menu is about to offer.
1029
+ if (!isSelected(tab.session.selection, path)) {
1030
+ tab.session.selection = [path];
434
1031
  }
1032
+ _target = { node, path, rerender: opts.rerender };
1033
+ _rows = buildRows(opts.placement ?? "context/element");
1034
+ if (_rows.length === 0) {
1035
+ _target = null;
1036
+ return;
1037
+ }
1038
+ _activeIdx = 0;
1039
+ _opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
435
1040
 
436
- let x = e.clientX,
437
- y = e.clientY;
1041
+ let x = e.clientX;
1042
+ let y = e.clientY;
438
1043
 
439
1044
  _ctxHandle = renderPopover(
440
1045
  html`<sp-popover
@@ -458,26 +1063,21 @@ export function showContextMenu(
458
1063
  });
459
1064
  })}
460
1065
  >
461
- <sp-menu>
462
- ${items.map((item) =>
463
- item.label === "—"
464
- ? html`<sp-menu-divider></sp-menu-divider>`
465
- : html`<sp-menu-item
466
- style=${item.danger ? "color: var(--danger)" : ""}
467
- @click=${() => {
468
- dismissContextMenu();
469
- void item.action?.();
470
- }}
471
- >${item.label}</sp-menu-item
472
- >`,
473
- )}
1066
+ <sp-menu role="menu" aria-label="Element actions">
1067
+ ${_rows.map((row, index) => rowTemplate(row, index))}
474
1068
  </sp-menu>
475
1069
  </sp-popover>`,
476
1070
  {
477
1071
  dismissOnOutsideClick: true,
478
1072
  onDismiss: () => {
479
1073
  _ctxHandle = null;
1074
+ teardownMenu();
480
1075
  },
481
1076
  },
482
1077
  );
1078
+
1079
+ document.addEventListener("keydown", onMenuKeydown, true);
1080
+ // A frame later: lit has committed the items, and focusing the first row is what makes the whole
1081
+ // Keyboard contract reachable at all (the menu used to open with focus left behind it).
1082
+ requestAnimationFrame(() => focusItem(0));
483
1083
  }