@jxsuite/studio 2.0.0 → 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-0hzentwq.js → components-2bpqp4mq.js} +5 -7
  7. package/dist/chunks/{components-0hzentwq.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-2c188n41.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-97qcgvdn.js → studio-adn0z1k3.js} +14 -8
  74. package/dist/chunks/{studio-97qcgvdn.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-hhj92gcr.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-ygzav7z5.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-n9zmpxjg.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 +23711 -35185
  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 +34 -9
  214. package/src/new-project/location-fields.ts +9 -1
  215. package/src/new-project/new-project-modal.ts +270 -262
  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 -120
  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 +75 -2
  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 +113 -0
  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-qct0gt8f.js +0 -531
  363. package/dist/chunks/extension-sections-qct0gt8f.js.map +0 -12
  364. package/dist/chunks/files-02zn5y24.js +0 -50
  365. package/dist/chunks/files-02zn5y24.js.map +0 -9
  366. package/dist/chunks/git-panel-mbb0y0hz.js +0 -28
  367. package/dist/chunks/git-panel-mbb0y0hz.js.map +0 -9
  368. package/dist/chunks/layers-panel-9qekmqk1.js +0 -26
  369. package/dist/chunks/layers-panel-9qekmqk1.js.map +0 -9
  370. package/dist/chunks/settings-modal-gj0hrah4.js +0 -29
  371. package/dist/chunks/settings-modal-gj0hrah4.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-4k0m4fmp.js.map +0 -13
  381. package/dist/chunks/studio-568nx4t5.js +0 -469
  382. package/dist/chunks/studio-568nx4t5.js.map +0 -14
  383. package/dist/chunks/studio-9smfzqhc.js +0 -19
  384. package/dist/chunks/studio-9smfzqhc.js.map +0 -10
  385. package/dist/chunks/studio-asvf0j9e.js +0 -429
  386. package/dist/chunks/studio-asvf0j9e.js.map +0 -13
  387. package/dist/chunks/studio-d77qm3pk.js +0 -3427
  388. package/dist/chunks/studio-d77qm3pk.js.map +0 -26
  389. package/dist/chunks/studio-j6gqyjf4.js +0 -1854
  390. package/dist/chunks/studio-j6gqyjf4.js.map +0 -18
  391. package/dist/chunks/studio-n9zmpxjg.js.map +0 -115
  392. package/dist/chunks/studio-qkvt37c0.js.map +0 -9
  393. package/dist/chunks/studio-rq0vxb19.js +0 -3744
  394. package/dist/chunks/studio-rq0vxb19.js.map +0 -22
  395. package/dist/chunks/studio-wd6ewdvd.js +0 -853
  396. package/dist/chunks/studio-wd6ewdvd.js.map +0 -12
  397. package/dist/chunks/studio-xq0t7n5t.js +0 -140
  398. package/dist/chunks/studio-xq0t7n5t.js.map +0 -10
  399. package/dist/chunks/studio-xze4zrs1.js.map +0 -10
  400. package/dist/chunks/studio-ygzav7z5.js.map +0 -46
  401. package/dist/chunks/studio-zmy7241e.js +0 -399
  402. package/dist/chunks/studio-zmy7241e.js.map +0 -12
  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-2c188n41.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-hhj92gcr.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
@@ -11,6 +11,7 @@ import { postMessageChannel } from "./iframe-channel";
11
11
  import { canvasBaseOrigin } from "./canvas-origin";
12
12
  import { getPreviewNavigateHandler } from "./preview-navigate";
13
13
  import { resolveCanvasDocument } from "./canvas-live-render";
14
+ import { canvasPerf, recordEscalation, SPAN_PREPARE_RENDER, timeSpanAsync } from "./canvas-perf";
14
15
  import {
15
16
  applyBlockMerge,
16
17
  applyInlineCommit,
@@ -26,20 +27,23 @@ import { panToParentRect } from "./canvas-utils";
26
27
  import { clearDragGhost, moveDragGhost } from "../panels/drag-ghost";
27
28
  import { applyDropInstruction } from "../panels/dnd";
28
29
  import { rectOf } from "../utils/geometry";
30
+ import { notify } from "../services/notify";
29
31
  import { effect, effectScope } from "../reactivity";
32
+ import { pathsEqual, projectState, renderOnly } from "../store";
33
+ import { activeTab, focusPane, workspace } from "../workspace/workspace";
30
34
  import {
31
- canvasPanels,
32
- canvasWrap,
33
- pathsEqual,
34
- projectState,
35
- renderOnly,
36
- updateCanvas,
37
- updateUi,
38
- } from "../store";
39
- import { activeTab, workspace } from "../workspace/workspace";
35
+ paneOfContainer,
36
+ panelHostingCanvas,
37
+ stageContaining,
38
+ tabOfContainer,
39
+ } from "./canvas-surface";
40
+ import { cloneSelection, primarySelection, toggleSelected } from "../tabs/selection";
41
+ import type { JxPath } from "../state";
42
+ import { setLayoutSelection, shell } from "../shell";
40
43
  import { formatEditableVerdicts } from "../format/constraints";
41
44
  import { formatByName } from "../format/format-host";
42
45
  import { collabState } from "../collab/collab-state";
46
+ import { localeDirection } from "@jxsuite/schema/locale";
43
47
  import { getPlatform, hasPlatform } from "../platform";
44
48
  import type {
45
49
  ApplyFormatIntent,
@@ -53,7 +57,9 @@ import type {
53
57
  NodeHit,
54
58
  ParentToIframe,
55
59
  SelectionSnapshot,
60
+ SerializableRect,
56
61
  SerializedKey,
62
+ SyncedChord,
57
63
  WireDocOp,
58
64
  } from "./iframe-protocol";
59
65
  import type { IframeChannel } from "./iframe-channel";
@@ -79,14 +85,40 @@ interface HostState {
79
85
  ready: boolean;
80
86
  pending: ParentToIframe | null;
81
87
  overlay: OverlayLayer;
82
- /** Document path of the current selection (mirrors `session.selection`), for hover de-dupe. */
88
+ /** Primary selected path (mirrors `session.selection`'s last entry), for hover de-dupe. */
83
89
  selectionPath: (string | number)[] | null;
90
+ /** The whole selection this host last measured, so a re-measure redraws every box. */
91
+ selectionPaths: JxPath[];
84
92
  /** Id of the most recent selection `measure` request, so stale `geometry` replies are dropped. */
85
93
  selReqId: number;
94
+ /**
95
+ * The NON-primary selected paths the in-flight selection `measure` covers, serialized. The
96
+ * geometry reply draws a co-selection box for every hit whose path is in this set — a set of one
97
+ * selected node leaves it empty, so nothing but the selection box is ever drawn.
98
+ */
99
+ coSelectionKeys: Set<string>;
86
100
  /** Id of the most recent presence `measure` request (allocated from the selReqId counter). */
87
101
  presenceReqId: number;
88
102
  /** Serialized peer path → presence box meta for the in-flight presence measure. */
89
103
  presenceMeta: Map<string, { color: string; label: string }>;
104
+ /**
105
+ * Whether this host's iframe currently renders a PREVIEW. Preview is the fidelity view, so the
106
+ * host refuses every editing message from it ({@link PREVIEW_BLOCKED}), suppresses the overlay
107
+ * layer, and leaves the iframe at its CSS height so the frame scrolls its own document.
108
+ *
109
+ * Never assign this directly — go through {@link setHostPreview}, which applies the frame sizing
110
+ * the flag implies in the SAME state transition. See {@link applyFrameSizing}.
111
+ */
112
+ preview: boolean;
113
+ /**
114
+ * The last content height this host's iframe measured and posted, or null before the first
115
+ * measurement. Retained (rather than consumed and dropped) because frame sizing is a function of
116
+ * `preview` too, and the iframe DEDUPES `contentHeight` on an unchanged measurement — so leaving
117
+ * preview cannot rely on a fresh message arriving to restore the content-sized frame.
118
+ */
119
+ contentHeight: number | null;
120
+ /** Whether the last measured content was a component-definition fragment (drops the 480px floor). */
121
+ contentFragment: boolean;
90
122
  /** Whether an inline-edit session is live in this host's iframe (drives the format toolbar). */
91
123
  editing: boolean;
92
124
  /** The prop a live plain session edits (prop-bound text) — null for rich sessions/none. */
@@ -137,6 +169,28 @@ interface HostState {
137
169
  tabId: string | null;
138
170
  /** Tab id keyed by render gen for posted-but-unacked renders; adopted on `renderComplete`. */
139
171
  pendingTabIds: Map<number, string | null>;
172
+ /**
173
+ * Surgical patches posted to this iframe that have not acked (`patchComplete`/`patchError`).
174
+ *
175
+ * `pendingTabIds` covers full renders because it has to (identity adoption depends on it); a
176
+ * patch carries no identity, so nothing counted it. Both are "the DOM in that frame does not yet
177
+ * reflect what this host has told it", which is the question {@link canvasIdleBlockers} answers.
178
+ */
179
+ pendingPatches: number;
180
+ /**
181
+ * The frame's own last quiescence report (`{kind: "idle"}`), or null before the first one.
182
+ *
183
+ * Held PER HOST from the start. `shot.ts`'s "Studio's only child frame" is a coin flip the moment
184
+ * P8 adds a second pane, and a global would have to be unpicked exactly then.
185
+ */
186
+ idle: Omit<Extract<IframeToParent, { kind: "idle" }>, "kind"> | null;
187
+ /**
188
+ * Resolvers for {@link measureCanvasPath} requests, keyed by the `measure` reqId.
189
+ *
190
+ * Allocated from the same `selReqId` counter as the selection and presence measures, so the three
191
+ * can never collide and the `geometry` handler can dispatch on the id alone.
192
+ */
193
+ pendingMeasures: Map<number, (hits: NodeHit[]) => void>;
140
194
  /**
141
195
  * A split/insert re-entry deferred until this host's DOM contains the new element: a surgical
142
196
  * patch acks (`patchComplete`) at the SAME gen the host already reflects, an escalated full
@@ -217,15 +271,24 @@ export function sawIframeDragOver(seq: number): boolean {
217
271
  let evalReqId = 0;
218
272
 
219
273
  /**
220
- * One-shot: let automatic `$prototype: "Request"` entries fetch on the NEXT page render even in
274
+ * The panes whose NEXT page render may let automatic `$prototype: "Request"` entries fetch, even in
221
275
  * edit/design mode.
222
276
  *
223
277
  * Those fetches are suppressed outside preview because a full render re-resolves every state entry,
224
278
  * so an escalating authoring action would issue a request per render. But the Data activity's
225
- * Refresh exists to re-fire them on demand — its documented purpose — so it arms this flag and the
226
- * next render consumes it. Deliberately one-shot: a subsequent escalation must not inherit it.
279
+ * Refresh exists to re-fire them on demand — its documented purpose — so it arms a pane and that
280
+ * pane's next render consumes it. Deliberately one-shot: a subsequent escalation must not inherit
281
+ * it.
282
+ *
283
+ * **A `boolean`, then a per-PASS boolean, now a set of panes** — the same fact narrowing each time
284
+ * something else turned out to be able to claim it. Per-host was wrong because one Refresh mounts N
285
+ * artboards and the first swallowed the arm. Per-pass was wrong for the same reason one pane
286
+ * further out: two panes are two passes, both scheduled through rAF and both awaiting inside their
287
+ * mount loop, so whichever reached `preparePassRender` first took an arm the OTHER pane's Refresh
288
+ * had set — the button refreshed the pane nobody had pressed it in, and left its own pane
289
+ * suppressed.
227
290
  */
228
- let _allowAutoRequestsOnce = false;
291
+ const _autoRequestPanes = new Set<string>();
229
292
 
230
293
  /**
231
294
  * Open a preview link's target for real.
@@ -259,20 +322,35 @@ function openPreviewHref(href: string, state: HostState): void {
259
322
  window.open(url, "_blank", "noopener,noreferrer");
260
323
  }
261
324
 
262
- /** Arm the next page render to allow automatic request fetches (Data activity Refresh). */
263
- export function allowAutoRequestsOnNextRender(): void {
264
- _allowAutoRequestsOnce = true;
325
+ /**
326
+ * Arm one pane's next page render to allow automatic request fetches (Data activity Refresh).
327
+ *
328
+ * @param {string} paneId The pane the Refresh was pressed for — read ONCE by the caller, so the arm
329
+ * and the `renderCanvas` that follows it cannot end up naming different panes.
330
+ */
331
+ export function allowAutoRequestsOnNextRender(paneId: string): void {
332
+ _autoRequestPanes.add(paneId);
265
333
  }
266
334
 
267
- function consumeAllowAutoRequests(): boolean {
268
- const armed = _allowAutoRequestsOnce;
269
- _allowAutoRequestsOnce = false;
270
- return armed;
335
+ /** Take `paneId`'s arm, if it has one. */
336
+ function consumeAllowAutoRequests(paneId: string): boolean {
337
+ return _autoRequestPanes.delete(paneId);
271
338
  }
272
339
 
273
340
  /** Pending eval resolvers keyed by reqId; a timeout or stale reply resolves null. */
274
341
  const pendingEvals = new Map<number, (results: EvalExprResult[] | null) => void>();
275
342
 
343
+ /**
344
+ * Which host each in-flight request belongs to.
345
+ *
346
+ * `pendingEvals` and `pendingFlushes` are keyed by reqId alone, which was enough while nothing ever
347
+ * released a host: an unanswered request simply timed out. A pane that goes away has to settle its
348
+ * OWN requests and nobody else's, and the reqId does not say whose they are.
349
+ */
350
+ const evalOwners = new Map<number, HostState>();
351
+
352
+ const flushOwners = new Map<number, HostState>();
353
+
276
354
  /** How long (ms) the parent waits for an `evalResult` before falling back to the snapshot. */
277
355
  export const EVAL_TIMEOUT_MS = 300;
278
356
 
@@ -319,10 +397,13 @@ export function requestCanvasEval(
319
397
  return new Promise((resolve) => {
320
398
  const timer = setTimeout(() => {
321
399
  pendingEvals.delete(reqId);
400
+ evalOwners.delete(reqId);
322
401
  resolve(null);
323
402
  }, timeoutMs);
403
+ evalOwners.set(reqId, target);
324
404
  pendingEvals.set(reqId, (results) => {
325
405
  clearTimeout(timer);
406
+ evalOwners.delete(reqId);
326
407
  resolve(results);
327
408
  });
328
409
  });
@@ -368,6 +449,7 @@ export function flushCanvasEdits(
368
449
  const timer = setTimeout(() => {
369
450
  for (const id of ids) {
370
451
  pendingFlushes.delete(id);
452
+ flushOwners.delete(id);
371
453
  }
372
454
  resolve();
373
455
  }, timeoutMs);
@@ -384,6 +466,7 @@ export function flushCanvasEdits(
384
466
  const reqId = flushReqId;
385
467
  ids.push(reqId);
386
468
  pendingFlushes.set(reqId, settle);
469
+ flushOwners.set(reqId, host);
387
470
  host.channel.post({ kind: "flushEdits", reqId });
388
471
  }
389
472
  });
@@ -476,6 +559,232 @@ export function hostDragGeometry(host: HostState): {
476
559
  return { rect, scale };
477
560
  }
478
561
 
562
+ // ─── Quiescence and point resolution ────────────────────────────────────────────
563
+ // Two questions the parent realm could not previously answer about a cross-origin canvas: "has it
564
+ // Settled?" and "where on screen is this node?". Both were answered by the CALLER instead — a sleep
565
+ // And a `Math.abs(scale - 1) < 0.001` guess at whether a fit transform was in play. Both are the
566
+ // Host's own arithmetic; it already does them for the selection overlay and the block action bar.
567
+
568
+ /** A short, stable handle for a host inside a blocker string. */
569
+ function hostLabel(host: HostState): string {
570
+ if (host.stylebook) {
571
+ return "stylebook";
572
+ }
573
+ return host.tabId ?? "unbound";
574
+ }
575
+
576
+ /**
577
+ * Everything the canvas is still owed, one human-readable line per outstanding item, PER HOST.
578
+ *
579
+ * Empty means every live frame's DOM reflects what this host told it, its fonts have loaded, no
580
+ * animation is running and no image retry is in flight. Naming the blockers is the whole point:
581
+ * `probe.idle()` rejects with this list, so a slow subsystem identifies itself instead of being
582
+ * answered with `+500 ms` and a wrong capture (plan §13.4).
583
+ */
584
+ export function canvasIdleBlockers(): string[] {
585
+ const blockers: string[] = [];
586
+ for (const host of liveHosts) {
587
+ if (!host.iframe.isConnected) {
588
+ liveHosts.delete(host);
589
+ continue;
590
+ }
591
+ const at = `canvas[${hostLabel(host)}]`;
592
+ if (!host.ready) {
593
+ blockers.push(`${at}: frame has not handshaked`);
594
+ continue;
595
+ }
596
+ const unacked = [...host.pendingTabIds.keys()];
597
+ if (unacked.length > 0) {
598
+ blockers.push(`${at}: gen ${unacked.join(", ")} unacked`);
599
+ }
600
+ if (host.pendingPatches > 0) {
601
+ blockers.push(`${at}: ${host.pendingPatches} unacked patch(es)`);
602
+ }
603
+ if (host.pendingMeasures.size > 0) {
604
+ blockers.push(`${at}: ${host.pendingMeasures.size} measure(s) in flight`);
605
+ }
606
+ const { idle } = host;
607
+ if (!idle) {
608
+ blockers.push(`${at}: no quiescence report yet`);
609
+ continue;
610
+ }
611
+ if (idle.gen !== host.lastRenderedGen) {
612
+ blockers.push(`${at}: quiescence is for gen ${idle.gen}, DOM is gen ${host.lastRenderedGen}`);
613
+ }
614
+ if (!idle.fonts) {
615
+ blockers.push(`${at}: fonts still loading`);
616
+ }
617
+ if (idle.animations > 0) {
618
+ blockers.push(`${at}: ${idle.animations} running animation(s)`);
619
+ }
620
+ if (idle.images > 0) {
621
+ blockers.push(`${at}: ${idle.images} pending image retry(ies)`);
622
+ }
623
+ }
624
+ if (pendingEvals.size > 0) {
625
+ blockers.push(`canvas: ${pendingEvals.size} expression eval(s) in flight`);
626
+ }
627
+ return blockers;
628
+ }
629
+
630
+ /**
631
+ * A node's box in TOP-DOCUMENT coordinates, with `x`/`y` at its centre.
632
+ *
633
+ * The caller gets a point it can act on directly — click it, scroll to it, anchor a popover to it —
634
+ * without knowing that a canvas iframe, a panzoom transform and an edit-zoom scale sit between the
635
+ * document and the screen. P4.6's find-references jump, P8.4's jump bar and collab follow-peer all
636
+ * want exactly this, and so does anything driving Studio from outside.
637
+ */
638
+ export interface CanvasPoint {
639
+ x: number;
640
+ y: number;
641
+ left: number;
642
+ top: number;
643
+ width: number;
644
+ height: number;
645
+ }
646
+
647
+ /** How long a point request waits for its `geometry` reply before answering null. */
648
+ export const MEASURE_TIMEOUT_MS = 500;
649
+
650
+ /** Frames of an unchanged iframe offset that end a reveal. */
651
+ const PAN_SETTLE_FRAMES = 2;
652
+
653
+ /** Give up on a reveal that never settles rather than await it forever. */
654
+ const PAN_SETTLE_MAX_FRAMES = 40;
655
+
656
+ /**
657
+ * The live host a document path is addressed in: the one rendering the focused tab, else any ready
658
+ * page host. A stylebook host is never it — its paths decode to TAGS, not document paths.
659
+ */
660
+ function hostForPath(): HostState | null {
661
+ const tabId = activeTab.value?.id ?? null;
662
+ let fallback: HostState | null = null;
663
+ for (const host of liveHosts) {
664
+ if (!host.iframe.isConnected) {
665
+ liveHosts.delete(host);
666
+ continue;
667
+ }
668
+ if (!host.ready || host.stylebook) {
669
+ continue;
670
+ }
671
+ if (host.tabId === tabId) {
672
+ return host;
673
+ }
674
+ fallback ??= host;
675
+ }
676
+ return fallback;
677
+ }
678
+
679
+ /**
680
+ * Compose the host's own transforms onto an iframe-viewport rect.
681
+ *
682
+ * `scale` is the EMPIRICAL ratio {@link hostDragGeometry} reads fresh from the DOM, so it covers the
683
+ * design-mode panzoom transform and edit-mode content zoom together — which is precisely the
684
+ * distinction a caller cannot make from outside and used to guess at.
685
+ */
686
+ function pointForRect(host: HostState, rect: SerializableRect): CanvasPoint {
687
+ const { rect: frame, scale } = hostDragGeometry(host);
688
+ const left = frame.left + rect.x * scale;
689
+ const top = frame.top + rect.y * scale;
690
+ const width = rect.width * scale;
691
+ const height = rect.height * scale;
692
+ return { height, left, top, width, x: left + width / 2, y: top + height / 2 };
693
+ }
694
+
695
+ /** Ask one host to measure one path and resolve the reply as a top-document point. */
696
+ function measureIn(
697
+ host: HostState,
698
+ path: readonly (string | number)[],
699
+ ): Promise<CanvasPoint | null> {
700
+ return new Promise((resolve) => {
701
+ host.selReqId += 1;
702
+ const reqId = host.selReqId;
703
+ const timer = setTimeout(() => {
704
+ host.pendingMeasures.delete(reqId);
705
+ resolve(null);
706
+ }, MEASURE_TIMEOUT_MS);
707
+ host.pendingMeasures.set(reqId, (hits) => {
708
+ clearTimeout(timer);
709
+ const [hit] = hits;
710
+ resolve(hit ? pointForRect(host, hit.rect) : null);
711
+ });
712
+ // A plain copy: `session.selection` is a reactive proxy and only serializable values cross.
713
+ host.channel.post({ kind: "measure", paths: [[...path]], reqId });
714
+ });
715
+ }
716
+
717
+ /**
718
+ * Where the node at `path` is on screen right now, or null when no canvas can answer.
719
+ *
720
+ * Read-only: it measures, it does not move anything. {@link revealCanvasPath} is the half that does.
721
+ */
722
+ export function canvasPointAt(path: readonly (string | number)[]): Promise<CanvasPoint | null> {
723
+ const host = hostForPath();
724
+ return host ? measureIn(host, path) : Promise.resolve(null);
725
+ }
726
+
727
+ /**
728
+ * Resolve once the iframe's offset has stopped moving.
729
+ *
730
+ * The iframe's own top is the right thing to watch precisely because it does not care HOW the pane
731
+ * moved: a Design pan is a 250ms rAF tween of `view.panY`, an Edit reveal is one synchronous
732
+ * `scrollTop` write, and both show up here as the frame's on-screen offset changing and then
733
+ * holding still.
734
+ */
735
+ function panSettled(host: HostState): Promise<void> {
736
+ return new Promise((resolve) => {
737
+ let last = Number.NaN;
738
+ let stable = 0;
739
+ let frames = 0;
740
+ const tick = () => {
741
+ const { top } = rectOf(host.iframe);
742
+ stable = top === last ? stable + 1 : 0;
743
+ last = top;
744
+ frames += 1;
745
+ if (stable >= PAN_SETTLE_FRAMES || frames >= PAN_SETTLE_MAX_FRAMES) {
746
+ resolve();
747
+ return;
748
+ }
749
+ requestAnimationFrame(tick);
750
+ };
751
+ requestAnimationFrame(tick);
752
+ });
753
+ }
754
+
755
+ /**
756
+ * Bring the node at `path` into view and answer where it landed.
757
+ *
758
+ * The move is the same {@link panToParentRect} the stylebook's pan-to-card uses, and the second
759
+ * measure is not belt-and-braces: the point BEFORE the move is not the point a caller can act on.
760
+ *
761
+ * `panToParentRect` is what makes this work on BOTH surfaces — it pans a panzoom stage and scrolls
762
+ * a scrolling one. While it only panned, this function was a measured no-op in Edit mode: it
763
+ * returned the node's original, off-screen point, and `runInput`'s caret step then clicked a point
764
+ * outside the pane and selected nothing.
765
+ */
766
+ export async function revealCanvasPath(
767
+ path: readonly (string | number)[],
768
+ ): Promise<CanvasPoint | null> {
769
+ const host = hostForPath();
770
+ if (!host) {
771
+ return null;
772
+ }
773
+ const before = await measureIn(host, path);
774
+ if (!before) {
775
+ return null;
776
+ }
777
+ /* Pan the stage the MEASUREMENT came from. `hostForPath` prefers the host rendering the focused
778
+ tab but falls back to any ready page host, and the pan defaulted to `activeCanvasSurface()` —
779
+ so on the fallback the reveal measured in one pane and scrolled the other, then re-measured a
780
+ node that had not moved. `undefined` keeps the default for a host no pane claims (a detached
781
+ artboard in a test). */
782
+ const surface = panelHostingCanvas(host.canvasEl)?.surface;
783
+ panToParentRect({ height: before.height, top: before.top }, surface);
784
+ await panSettled(host);
785
+ return measureIn(host, path);
786
+ }
787
+
479
788
  /**
480
789
  * Pin the `.canvas-panel-viewport` (the white "page" surface, the canvas element's parent) to the
481
790
  * SCALED content height when edit-mode content zoom is active. A `transform: scale()` on the canvas
@@ -495,6 +804,69 @@ function syncEditZoomViewportHeight(state: HostState): void {
495
804
  viewport.style.height = scale === 1 ? "" : `${state.iframe.offsetHeight * scale}px`;
496
805
  }
497
806
 
807
+ /**
808
+ * Write the iframe's box from the host's CURRENT state — the render mode and the last content
809
+ * measurement — and nothing else. Frame sizing is derived, never incremental, because the two
810
+ * inputs arrive on different clocks: `preview` flips synchronously inside a mount, the measurement
811
+ * arrives asynchronously over the channel, and the iframe DEDUPES a repeated measurement (so "the
812
+ * next `contentHeight` will fix it" is false). Deriving both branches from one function called at
813
+ * every transition of either input is what makes the two impossible to disagree.
814
+ *
815
+ * Preview keeps the iframe a REAL VIEWPORT: it stays at the CSS height the preview stage gives it
816
+ * and scrolls its own document, so `position:sticky`, scroll-driven animation and
817
+ * IntersectionObserver reveals fire exactly as they will in production. Growing the frame to its
818
+ * content height — what every editing mode needs, so the parent overlay can reach every node — is
819
+ * what stopped all three from ever firing in the one view whose job is fidelity.
820
+ *
821
+ * The cssText's 480px `min-height` is a pre-measurement floor so an empty/short PAGE stays a usable
822
+ * canvas; a component DEFINITION (fragment) instead hugs its content, so drop the floor for it once
823
+ * measured (else a short component leaves dead space below — pages keep the floor and stay tall via
824
+ * `#jx-canvas-root`).
825
+ */
826
+ function applyFrameSizing(state: HostState): void {
827
+ if (state.preview) {
828
+ state.iframe.style.height = "100%";
829
+ state.iframe.style.minHeight = "0px";
830
+ return;
831
+ }
832
+ if (state.contentHeight === null) {
833
+ // Nothing measured yet — the cssText defaults (height:100%; min-height:480px) stand.
834
+ return;
835
+ }
836
+ state.iframe.style.height = `${state.contentHeight}px`;
837
+ state.iframe.style.minHeight = state.contentFragment ? "0px" : "480px";
838
+ syncEditZoomViewportHeight(state);
839
+ }
840
+
841
+ /**
842
+ * Adopt a render mode's preview-ness as ONE state transition: the flag, the overlay suppression,
843
+ * the dropped pending edit and the frame box all move together. Mounts must call this rather than
844
+ * assigning `state.preview` — a mount resolves its document asynchronously, so between the mode
845
+ * changing and the flag landing the host would otherwise answer a `contentHeight` with the previous
846
+ * mode's sizing rule and never get a second chance to correct it.
847
+ */
848
+ function setHostPreview(state: HostState, preview: boolean): void {
849
+ state.preview = preview;
850
+ state.overlay.setSuppressed(preview);
851
+ if (preview) {
852
+ state.pendingEnterEdit = null;
853
+ }
854
+ applyFrameSizing(state);
855
+ }
856
+
857
+ /**
858
+ * Declare, SYNCHRONOUSLY, which kind of render `canvasEl`'s host is about to receive.
859
+ *
860
+ * {@link mountIframeCanvas} resolves its document asynchronously, so the mode it will post is not
861
+ * known to the host until an await has passed. The renderer knows it before the await — it is the
862
+ * mode it just built the surface for — so it says so here, and the host's frame box moves with the
863
+ * flag rather than trailing it. Without this the `contentHeight` the iframe posts during the
864
+ * resolve is answered under the OUTGOING mode's sizing rule.
865
+ */
866
+ export function adoptCanvasPreviewMode(canvasEl: HTMLElement, preview: boolean): void {
867
+ setHostPreview(ensureHost(canvasEl), preview);
868
+ }
869
+
498
870
  /** Post a parent→iframe drag message to `host`'s channel (the coordinator builds it purely). */
499
871
  export function postDragMessage(host: HostState, msg: ParentToIframe): void {
500
872
  if (!host.iframe.isConnected) {
@@ -642,6 +1014,8 @@ function registerCanvasGutterDrop(canvasEl: HTMLElement, host: () => HostState |
642
1014
  export interface CanvasSlashRequest {
643
1015
  rect: { left: number; top: number; bottom: number; width: number; height: number };
644
1016
  filter: string;
1017
+ /** Whether the menu draws its own filter field — see the `slashShow` message for why. */
1018
+ showFilter?: boolean;
645
1019
  onSelect: (cmd: SlashCommand) => void;
646
1020
  onDismiss: () => void;
647
1021
  }
@@ -724,6 +1098,22 @@ function resolveStylebookTag(
724
1098
  */
725
1099
  let activeEditHost: HostState | null = null;
726
1100
 
1101
+ /**
1102
+ * Whether a text caret is live in a canvas iframe — the parent realm's only honest answer to "is
1103
+ * the author typing right now".
1104
+ *
1105
+ * The editing session runs INSIDE the cross-origin canvas frame, so `editor/inline-edit.ts`'s
1106
+ * `isEditing()` — a module-local `activeEl` in the PARENT bundle — is permanently false here. The
1107
+ * bridge already carries the truth: `editStart` opens a session, `selectionChanged` proves the
1108
+ * caret is still live in it, `editEnd` closes it. Derived rather than stored per host, so a frame
1109
+ * torn down mid-session (a mode switch, a closed tab) can never latch the flag on and go on
1110
+ * stealing ⌘C from a caret that no longer exists.
1111
+ */
1112
+ export function isCaretActive(): boolean {
1113
+ const host = activeEditHost;
1114
+ return Boolean(host?.editing && host.iframe.isConnected);
1115
+ }
1116
+
727
1117
  /** Injected toolbar re-render (set by studio.ts → renderBlockActionBar); avoids a panel→host cycle. */
728
1118
  let toolbarRefresh: (() => void) | null = null;
729
1119
 
@@ -732,13 +1122,34 @@ export function setToolbarRefresh(fn: () => void): void {
732
1122
  toolbarRefresh = fn;
733
1123
  }
734
1124
 
1125
+ /** Injected "a pointer went down in a canvas" signal (studio.ts → releaseBlockActionBar). */
1126
+ let canvasPointerDownHandler: (() => void) | null = null;
1127
+
1128
+ /**
1129
+ * Register what runs when a canvas frame reports a pointerdown. Pass `null` to unregister.
1130
+ *
1131
+ * Injected rather than imported for the reason {@link setToolbarRefresh} gives — this module
1132
+ * deliberately knows nothing about panels — and it exists as its own seam because
1133
+ * {@link focusHostPane} cannot serve as one: `focusPane` returns early when the pane already has
1134
+ * focus, which is the ordinary case (clicking around inside the pane you are already in) and
1135
+ * precisely the case the one subscriber, the block action bar's suppression release, is for.
1136
+ */
1137
+ export function setCanvasPointerDownHandler(fn: (() => void) | null): void {
1138
+ canvasPointerDownHandler = fn;
1139
+ }
1140
+
735
1141
  let selectionWatch: { stop: () => void } | null = null;
736
1142
 
737
1143
  /** Full-render escalation, injected by studio init (a patchError can't apply surgically). */
738
- let patchEscalation: (() => void) | null = null;
1144
+ let patchEscalation: ((paneId: string) => void) | null = null;
739
1145
 
740
- /** Register the full-render fallback the host invokes when the iframe reports a `patchError`. */
741
- export function setIframePatchEscalation(fn: () => void): void {
1146
+ /**
1147
+ * Register the full-render fallback the host invokes when the iframe reports a `patchError`.
1148
+ *
1149
+ * Takes the PANE whose frame reported it: only that stage's DOM has fallen behind its document, and
1150
+ * re-rendering the other pane would reload iframes that applied their patch perfectly well.
1151
+ */
1152
+ export function setIframePatchEscalation(fn: (paneId: string) => void): void {
742
1153
  patchEscalation = fn;
743
1154
  }
744
1155
 
@@ -748,12 +1159,20 @@ export function setIframePatchEscalation(fn: () => void): void {
748
1159
  * edit into its shadow doc. Returns how many hosts received it; the caller escalates to a full
749
1160
  * render when that's zero (no host could apply the edit in place, so the suppressed full render
750
1161
  * must run after all).
1162
+ *
1163
+ * **There is no `gen` parameter, and there cannot be one.** The generation a frame checks a patch
1164
+ * against belongs to the STAGE that frame is mounted on, and this loop deliberately spans stages:
1165
+ * one document displayed in two panes is two hosts with two independent `renderGeneration`s. A
1166
+ * single number for a multi-pane fan-out is not a parameter needing a better value — it is the bug.
1167
+ * Whichever pane had rendered more recently held the higher `renderedGen`, and `iframe-entry.ts`
1168
+ * dropped the patch there in silence, leaving a wrong picture on screen with `__jxCanvasPerf`
1169
+ * reporting a clean surgical apply. Each host's own generation is resolved inside the loop
1170
+ * instead.
1171
+ *
1172
+ * `host` is deliberately not a pane-scoped parameter name (see `PANE_PARAM_NAMES`), and the body
1173
+ * reads no focus: `panelHostingCanvas` resolves the stage from the host's own element.
751
1174
  */
752
- export function postPatchToHosts(
753
- forwardOps: WireDocOp[],
754
- gen: number,
755
- tabId: string | null,
756
- ): number {
1175
+ export function postPatchToHosts(forwardOps: WireDocOp[], tabId: string | null): number {
757
1176
  let posted = 0;
758
1177
  for (const host of liveHosts) {
759
1178
  if (!host.iframe.isConnected) {
@@ -761,6 +1180,23 @@ export function postPatchToHosts(
761
1180
  continue;
762
1181
  }
763
1182
  if (host.ready && host.tabId === tabId) {
1183
+ /* NO FALLBACK GENERATION, and `?? 0` was the one bug this whole function exists to end.
1184
+ A host whose stage cannot be resolved — still `ready` and connected while its surface's
1185
+ panel list is between mutations across one of `renderCanvasImpl`'s awaits — was posted
1186
+ gen `0`. `0 < renderedGen` is the frame's `patch-behind-render` branch, whose `patchError`
1187
+ is handled by resolving the surface with THIS SAME failing lookup: `patchEscalation` was
1188
+ never called. Meanwhile `posted` had been incremented, so the caller did not throw, and
1189
+ `markConsumed` had already suppressed the full render. An edit disappeared with no counter
1190
+ moving. A stage we cannot name is a stage we cannot patch: skip it, let `posted` fall, and
1191
+ let the caller's `no-ready-iframe-host` escalate the whole batch. The escalation counter
1192
+ moves HERE too, because "nothing was posted and nothing was wrong" and "nothing was posted
1193
+ because we could not name the stage" are the two answers this must never confuse. */
1194
+ const stage = panelHostingCanvas(host.canvasEl)?.surface;
1195
+ if (!stage) {
1196
+ recordEscalation("host-stage-unresolved");
1197
+ continue;
1198
+ }
1199
+ const gen = stage.renderGeneration;
764
1200
  // Only the host that originated this edit already has the DOM the patch describes. A
765
1201
  // Split-view panel on the same document did NOT type it and must render normally.
766
1202
  const echoPaths = echoOrigin?.host === host ? echoOrigin.paths : undefined;
@@ -769,6 +1205,7 @@ export function postPatchToHosts(
769
1205
  ? { echoPaths, forwardOps, gen, kind: "patch" }
770
1206
  : { forwardOps, gen, kind: "patch" },
771
1207
  );
1208
+ host.pendingPatches += 1;
772
1209
  posted += 1;
773
1210
  }
774
1211
  }
@@ -812,7 +1249,70 @@ function hostTab(state: HostState): Tab | null {
812
1249
  return state.tabId ? (workspace.tabs.get(state.tabId) ?? null) : null;
813
1250
  }
814
1251
 
815
- /** Lazily start one reactive watcher that re-measures the selection in every live iframe host. */
1252
+ /**
1253
+ * The tab a drag TARGET is showing — the one public reader on the opaque {@link DragHost} handle.
1254
+ *
1255
+ * `panels/canvas-dnd-bridge.ts` binds a session to the host under the cursor and then had nothing
1256
+ * to ask it, so the one fact it needed about the drag — what the dragged node is CALLED — came from
1257
+ * `activeTab`. The bridge is not supposed to read the handle's fields (that is what "opaque"
1258
+ * means), so the answer is a function rather than a widened type.
1259
+ */
1260
+ export function dragHostTab(host: DragHost): Tab | null {
1261
+ return hostTab(host);
1262
+ }
1263
+
1264
+ /**
1265
+ * Put the keyboard in the pane whose artboard was just clicked.
1266
+ *
1267
+ * `panels/pane-grid.ts` moves the pane focus on a `pointerdown` anywhere in a cell, and that
1268
+ * listener cannot see this click: the canvas is a cross-origin `<iframe>`, so a pointer event
1269
+ * inside it is delivered in the frame's own realm and never surfaces in the parent's. The `hit` and
1270
+ * `layoutHit` messages ARE that pointerdown, re-posted across the channel, so this is the seam.
1271
+ *
1272
+ * The pane comes from the artboard the message arrived through — `panelHostingCanvas` is the same
1273
+ * route the breakpoint and the escalation already take — never from the focus, which is the thing
1274
+ * being corrected. `focusPane` is a no-op when the pane already has focus, so the common case
1275
+ * (clicking around in the pane you are already in) costs one map lookup.
1276
+ */
1277
+ function focusHostPane(state: HostState): void {
1278
+ const paneId = panelHostingCanvas(state.canvasEl)?.surface.paneId;
1279
+ if (paneId) {
1280
+ focusPane(paneId);
1281
+ }
1282
+ }
1283
+
1284
+ /** No selection. A shared frozen empty, so the "this host shows nothing" path allocates nothing. */
1285
+ const NO_SELECTION: readonly JxPath[] = Object.freeze([]);
1286
+
1287
+ /** The tab each pane is showing, by tab id. Reading it inside an effect tracks every pane. */
1288
+ function shownSelections(): Map<string, readonly JxPath[]> {
1289
+ const byTab = new Map<string, readonly JxPath[]>();
1290
+ for (const pane of workspace.panes) {
1291
+ const tab = pane.activeTabId ? workspace.tabs.get(pane.activeTabId) : null;
1292
+ if (!tab) {
1293
+ continue;
1294
+ }
1295
+ const sel = (tab.session.selection ?? []) as readonly JxPath[];
1296
+ // Track the selection deeply enough that a change WITHIN the set re-measures: the effect
1297
+ // Reads every path, not just the array reference.
1298
+ void sel.map((path) => path.join("/")).join("|");
1299
+ byTab.set(tab.id, sel);
1300
+ }
1301
+ return byTab;
1302
+ }
1303
+
1304
+ /**
1305
+ * Lazily start one reactive watcher that re-measures each host's OWN document's selection.
1306
+ *
1307
+ * **Every other fan-out in this module filters by `host.tabId`** — `postPatchToHosts`,
1308
+ * `requestCanvasEval`, `flushCanvasEdits` — and this one did not. It read `activeTab` and posted
1309
+ * the result to every entry in `liveHosts`, so with two panes open the focused pane's selection
1310
+ * paths were measured inside the OTHER pane's frame, against a document that does not contain them,
1311
+ * and written over that host's own `selectionPath`/`selectionPaths`. The reverse cost more:
1312
+ * `requestSelection`'s `if (!primary)` branch clears the overlay, so the focused pane merely having
1313
+ * nothing selected erased the side pane's box — the unfocused pane could never show a selection at
1314
+ * all.
1315
+ */
816
1316
  function ensureSelectionWatch(): void {
817
1317
  if (selectionWatch) {
818
1318
  return;
@@ -820,12 +1320,12 @@ function ensureSelectionWatch(): void {
820
1320
  const scope = effectScope(true);
821
1321
  scope.run(() => {
822
1322
  effect(() => {
823
- const sel = activeTab.value?.session.selection ?? null;
1323
+ const byTab = shownSelections();
824
1324
  // Track the stylebook selection too: stylebook hosts measure the selected TAG's card
825
1325
  // (session.selection is deliberately [] in stylebook mode).
826
- void activeTab.value?.session.ui.stylebookSelection;
1326
+ void shell.stylebook.selection;
827
1327
  for (const host of liveHosts) {
828
- requestSelection(host, sel);
1328
+ requestSelection(host, (host.tabId ? byTab.get(host.tabId) : null) ?? NO_SELECTION);
829
1329
  }
830
1330
  });
831
1331
  });
@@ -844,8 +1344,14 @@ function ensurePresenceWatch(): void {
844
1344
  const scope = effectScope(true);
845
1345
  scope.run(() => {
846
1346
  effect(() => {
847
- const tab = activeTab.value;
848
- if (tab) {
1347
+ // EVERY shown tab's roster, for the reason {@link ensureSelectionWatch} gives: a peer moving
1348
+ // Their cursor in the side pane's document is a repaint the side pane owes, and tracking only
1349
+ // The focused tab left the other pane's presence boxes frozen at whatever it last drew.
1350
+ for (const pane of workspace.panes) {
1351
+ const tab = pane.activeTabId ? workspace.tabs.get(pane.activeTabId) : null;
1352
+ if (!tab) {
1353
+ continue;
1354
+ }
849
1355
  // Track the roster deeply enough that selection moves re-trigger.
850
1356
  const { peers } = collabState(tab);
851
1357
  void peers.map((peer) => JSON.stringify(peer.state.structuralSelection ?? null)).join("|");
@@ -869,7 +1375,10 @@ function requestPresence(host: HostState): void {
869
1375
  if (host.stylebook || !host.ready || !host.iframe.isConnected) {
870
1376
  return;
871
1377
  }
872
- const tab = activeTab.value;
1378
+ // THIS host's document, not the focused pane's — the peer boxes drawn in a frame have to be the
1379
+ // Peers looking at what that frame is showing, and `peer.state.focusedPath` is compared against
1380
+ // It below.
1381
+ const tab = hostTab(host);
873
1382
  const peers = tab ? collabState(tab).peers : [];
874
1383
  host.presenceMeta.clear();
875
1384
  const paths: (string | number)[][] = [];
@@ -878,12 +1387,17 @@ function requestPresence(host: HostState): void {
878
1387
  if (!structuralSelection || peer.state.focusedPath !== tab?.documentPath) {
879
1388
  continue;
880
1389
  }
881
- const path = [...structuralSelection];
882
- paths.push(path);
883
- host.presenceMeta.set(JSON.stringify(path), {
884
- color: peer.state.user.color,
885
- label: peer.state.user.name ?? peer.state.user.login,
886
- });
1390
+ // A peer publishes their whole selection SET (§6.5). Every path gets its own box under the
1391
+ // Same name and colour — the meta map is keyed by path, so a peer selecting six nodes draws
1392
+ // Six boxes and a peer selecting one draws exactly the one box it always did.
1393
+ for (const path of structuralSelection) {
1394
+ const copy = [...path];
1395
+ paths.push(copy);
1396
+ host.presenceMeta.set(JSON.stringify(copy), {
1397
+ color: peer.state.user.color,
1398
+ label: peer.state.user.name ?? peer.state.user.login,
1399
+ });
1400
+ }
887
1401
  }
888
1402
  if (paths.length === 0) {
889
1403
  host.presenceReqId = -1;
@@ -895,28 +1409,44 @@ function requestPresence(host: HostState): void {
895
1409
  host.channel.post({ kind: "measure", paths, reqId: host.presenceReqId });
896
1410
  }
897
1411
 
898
- /** Track the selection on a host and ask its iframe to measure it (or clear the box when null). */
899
- function requestSelection(host: HostState, sel: (string | number)[] | null): void {
1412
+ /**
1413
+ * Track the selection on a host and ask its iframe to measure it (or clear the boxes when empty).
1414
+ *
1415
+ * The PRIMARY path is posted first and drawn as the selection box; the rest are drawn as
1416
+ * co-selection boxes from the same reply, so one round trip covers the whole set. With one path
1417
+ * selected the request is byte-identical to what it always was — one path in, one box out.
1418
+ */
1419
+ function requestSelection(host: HostState, sel: readonly JxPath[]): void {
900
1420
  if (host.stylebook) {
901
1421
  requestStylebookSelection(host);
902
1422
  return;
903
1423
  }
904
- host.selectionPath = sel;
1424
+ const primary = primarySelection(sel);
1425
+ host.selectionPath = primary;
1426
+ host.selectionPaths = sel as JxPath[];
905
1427
  if (!host.iframe.isConnected) {
906
1428
  liveHosts.delete(host);
907
1429
  return;
908
1430
  }
909
- if (!sel) {
1431
+ if (!primary) {
1432
+ host.coSelectionKeys.clear();
910
1433
  host.overlay.setSelection(null);
1434
+ host.overlay.setCoSelection([]);
911
1435
  return;
912
1436
  }
913
1437
  if (!host.ready) {
914
1438
  return;
915
1439
  }
916
- host.selReqId += 1;
917
1440
  // Post a plain copy: `session.selection` is a reactive proxy, and only serializable values may
918
1441
  // Cross the postMessage boundary.
919
- host.channel.post({ kind: "measure", paths: [[...sel]], reqId: host.selReqId });
1442
+ const others = cloneSelection(sel.slice(0, -1));
1443
+ host.coSelectionKeys = new Set(others.map((path) => JSON.stringify(path)));
1444
+ host.selReqId += 1;
1445
+ host.channel.post({
1446
+ kind: "measure",
1447
+ paths: [[...primary], ...others],
1448
+ reqId: host.selReqId,
1449
+ });
920
1450
  }
921
1451
 
922
1452
  /**
@@ -929,7 +1459,7 @@ function requestStylebookSelection(host: HostState): void {
929
1459
  liveHosts.delete(host);
930
1460
  return;
931
1461
  }
932
- const tag = activeTab.value?.session.ui.stylebookSelection ?? null;
1462
+ const tag = shell.stylebook.selection;
933
1463
  const cardPath = tag ? host.stylebook?.tagToCardPath.get(tag) : undefined;
934
1464
  if (!tag || !cardPath) {
935
1465
  host.overlay.setSelection(null);
@@ -948,6 +1478,79 @@ function requestStylebookSelection(host: HostState): void {
948
1478
  */
949
1479
  const DEFAULT_CANVAS_URL = "/packages/studio/canvas.html";
950
1480
 
1481
+ /**
1482
+ * Release one host: its channel, its overlay, its frame, and everything awaiting a reply from it.
1483
+ *
1484
+ * A settled-with-nothing reply rather than a dropped promise, because every caller of
1485
+ * `requestCanvasEval` / `flushCanvasEdits` / a measure is awaiting one: dropping the entry would
1486
+ * hang a save on a frame that no longer exists, and `canvasIdleBlockers()` would go on naming it.
1487
+ */
1488
+ function releaseHost(host: HostState): void {
1489
+ host.channel.dispose();
1490
+ for (const [reqId, resolve] of pendingEvals) {
1491
+ if (evalOwners.get(reqId) === host) {
1492
+ pendingEvals.delete(reqId);
1493
+ evalOwners.delete(reqId);
1494
+ resolve(null);
1495
+ }
1496
+ }
1497
+ for (const [reqId, settle] of pendingFlushes) {
1498
+ if (flushOwners.get(reqId) === host) {
1499
+ pendingFlushes.delete(reqId);
1500
+ flushOwners.delete(reqId);
1501
+ settle();
1502
+ }
1503
+ }
1504
+ for (const [, resolve] of host.pendingMeasures) {
1505
+ resolve([]);
1506
+ }
1507
+ host.pendingMeasures.clear();
1508
+ host.pendingTabIds.clear();
1509
+ cancelInsertHide(host);
1510
+ /* The module-level pointer at THIS host goes with it. Everything else here was already released
1511
+ and `activeEditHost` was not, so unsplitting a pane with a live inline-edit caret left the
1512
+ parent realm believing it was still editing: the format toolbar stayed up anchored to a
1513
+ detached frame, and `commitActiveEditSession()` posted `endEdit` through
1514
+ `iframe.contentWindow?.postMessage` on a removed frame — an optional-chained silent no-op, so
1515
+ the edit was lost without a word. Cleared here rather than guarded at each reader, because
1516
+ "the host that owns the edit session" cannot be a host that no longer exists. */
1517
+ if (activeEditHost === host) {
1518
+ activeEditHost = null;
1519
+ }
1520
+ host.overlay.root.remove();
1521
+ host.iframe.remove();
1522
+ hosts.delete(host.canvasEl);
1523
+ liveHosts.delete(host);
1524
+ }
1525
+
1526
+ /**
1527
+ * Release every canvas host mounted under `root`, and say how many there were.
1528
+ *
1529
+ * The one NON-lazy path out of {@link liveHosts}. Eleven sites prune a disconnected host when they
1530
+ * happen to walk the set, which is enough to stop a dead frame being posted to and is not enough to
1531
+ * release it: `iframe-channel.ts` adds a `window` "message" listener that only `dispose()` removes,
1532
+ * and the sole parent-side `dispose()` was the URL-change rebuild in {@link ensureHost}. So a closed
1533
+ * pane — or any mode transition, which detaches every artboard — left one live listener and one
1534
+ * overlay subtree per frame, for the life of the window.
1535
+ *
1536
+ * @param {HTMLElement} root
1537
+ * @returns {number} How many hosts were released.
1538
+ */
1539
+ export function releaseCanvasHosts(root: HTMLElement): number {
1540
+ let released = 0;
1541
+ for (const host of new Set(liveHosts)) {
1542
+ if (root.contains(host.canvasEl)) {
1543
+ releaseHost(host);
1544
+ released += 1;
1545
+ }
1546
+ }
1547
+ return released;
1548
+ }
1549
+
1550
+ /* There is no `liveCanvasHostCount()`. A count the app never reads is a function reachable from
1551
+ nothing, which `tests/reachability.test.ts` refuses on principle — and the honest measure was
1552
+ already there: {@link releaseCanvasHosts} RETURNS how many it released. */
1553
+
951
1554
  function ensureHost(canvasEl: HTMLElement): HostState {
952
1555
  // Read the platform's canvasUrl when one is registered; otherwise fall back to the default. The
953
1556
  // Dev server leaves it unset, and some tests mount without a platform registered.
@@ -960,9 +1563,7 @@ function ensureHost(canvasEl: HTMLElement): HostState {
960
1563
  // The platform's loopback canvasUrl arrived after this host was built with the default URL
961
1564
  // (electrobun resolves it async over RPC) — tear the early iframe down and rebuild against the
962
1565
  // Right cross-origin origin.
963
- existing.channel.dispose();
964
- liveHosts.delete(existing);
965
- hosts.delete(canvasEl);
1566
+ releaseHost(existing);
966
1567
  }
967
1568
  // ParentOrigin is the parent's real origin, passed into the iframe URL so the cross-origin iframe
968
1569
  // Can target a postMessage back at it.
@@ -974,6 +1575,9 @@ function ensureHost(canvasEl: HTMLElement): HostState {
974
1575
  const iframeOrigin = new URL(canvasUrl, location.href).origin;
975
1576
  const iframe = document.createElement("iframe");
976
1577
  iframe.className = "jx-canvas-iframe";
1578
+ // Without a title the whole canvas is an unlabelled frame to assistive tech — and the canvas is
1579
+ // The artefact, not chrome.
1580
+ iframe.title = "Canvas — live page render";
977
1581
  iframe.style.cssText =
978
1582
  "width:100%;min-height:480px;height:100%;border:0;display:block;background:#fff";
979
1583
  // Preserve any query already on canvasUrl (e.g. electrobun's ?win=7) and append the token (always)
@@ -998,7 +1602,7 @@ function ensureHost(canvasEl: HTMLElement): HostState {
998
1602
  }
999
1603
  const overlay = createOverlayLayer(document);
1000
1604
  canvasEl.replaceChildren(iframe, overlay.root);
1001
- registerCanvasGutterDrop(canvasEl, () => hosts.get(canvasEl) ?? null);
1605
+ registerCanvasGutterDrop(canvasEl, () => hostForCanvas(canvasEl));
1002
1606
  const channel = postMessageChannel<ParentToIframe, IframeToParent>({
1003
1607
  acceptOrigin: iframeOrigin,
1004
1608
  source: window,
@@ -1015,11 +1619,14 @@ function ensureHost(canvasEl: HTMLElement): HostState {
1015
1619
  canvasEl,
1016
1620
  canvasUrl,
1017
1621
  channel,
1622
+ contentFragment: false,
1623
+ contentHeight: null,
1018
1624
  editing: false,
1019
1625
  editingProp: null,
1020
1626
  iframe,
1021
1627
  insertHideTimer: null,
1022
1628
  insertHover: false,
1629
+ coSelectionKeys: new Set<string>(),
1023
1630
  insertZone: null,
1024
1631
  lastRenderedGen: -1,
1025
1632
  lastSelectionRect: null,
@@ -1027,12 +1634,17 @@ function ensureHost(canvasEl: HTMLElement): HostState {
1027
1634
  overlay,
1028
1635
  panReqId: -1,
1029
1636
  pending: null,
1637
+ idle: null,
1030
1638
  pendingEnterEdit: null,
1639
+ pendingMeasures: new Map(),
1640
+ pendingPatches: 0,
1031
1641
  pendingTabIds: new Map(),
1032
1642
  presenceMeta: new Map(),
1033
1643
  presenceReqId: -1,
1644
+ preview: false,
1034
1645
  ready: false,
1035
1646
  selectionPath: null,
1647
+ selectionPaths: [],
1036
1648
  selReqId: 0,
1037
1649
  snapshot: null,
1038
1650
  stylebook: null,
@@ -1125,31 +1737,95 @@ function onInsertButtonClick(state: HostState, e: MouseEvent): void {
1125
1737
  insertZoneClickHandler?.(state.overlay.insertButton, zone);
1126
1738
  }
1127
1739
 
1740
+ /**
1741
+ * Messages a PREVIEW render is not allowed to act on. Preview must behave like the shipped page:
1742
+ * clicking selects nothing, nothing is outlined, there is no insertion "+", the browser's own
1743
+ * context menu stands, nothing can be dropped into it, and no text can be typed into it.
1744
+ *
1745
+ * The frame withholds most of these itself ({@link file://./iframe-interaction.ts}'s mode gate),
1746
+ * but the canvas bundle ships prebuilt in `dist/`, so the host refuses them independently rather
1747
+ * than trusting the frame's build to be current. Cleanup messages (`dragEnd`, `fileDragLeave`) are
1748
+ * deliberately NOT blocked — they only tear affordances down.
1749
+ */
1750
+ const PREVIEW_BLOCKED: ReadonlySet<IframeToParent["kind"]> = new Set([
1751
+ "contextMenu",
1752
+ "dragOver",
1753
+ "dropResult",
1754
+ "editCommit",
1755
+ "editCommitProp",
1756
+ "editInsert",
1757
+ "editMerge",
1758
+ "editRangeReplace",
1759
+ "editSplit",
1760
+ "editStart",
1761
+ "fileDragOver",
1762
+ "fileDrop",
1763
+ "hit",
1764
+ "hover",
1765
+ "insertZones",
1766
+ "layoutHit",
1767
+ "nativeDragEnter",
1768
+ ]);
1769
+
1128
1770
  /** Handle a message the iframe posted back: ready handshake, pointer hit/hover, measured geometry. */
1129
1771
  function handleMessage(state: HostState, msg: IframeToParent): void {
1772
+ if (state.preview && PREVIEW_BLOCKED.has(msg.kind)) {
1773
+ return;
1774
+ }
1130
1775
  switch (msg.kind) {
1131
1776
  case "ready": {
1132
1777
  state.ready = true;
1778
+ /* The chord table first, before the pending render: a frame that has painted a page the
1779
+ author can click into must already know which keystrokes to hand back. */
1780
+ const table = keymapSource?.();
1781
+ if (table) {
1782
+ state.channel.post({ chords: table.chords, kind: "keymap", mac: table.mac });
1783
+ }
1133
1784
  if (state.pending) {
1134
1785
  state.channel.post(state.pending);
1135
1786
  state.pending = null;
1136
1787
  }
1137
1788
  // Re-measure the current selection now that the iframe can answer.
1138
- requestSelection(state, state.selectionPath);
1789
+ requestSelection(state, state.selectionPaths);
1790
+ return;
1791
+ }
1792
+ case "paneFocus": {
1793
+ /* The frame says only that a pointer went down in it. Every mode posts this, including
1794
+ preview — see the message's own docstring for the two holes in `hit` that it closes. */
1795
+ focusHostPane(state);
1796
+ // …and "a pointer went down in a canvas" is exactly what un-hides the block action bar: the
1797
+ // Author is back on the surface it belongs to. Harmless in preview, where the bar draws
1798
+ // Nothing anyway.
1799
+ canvasPointerDownHandler?.();
1139
1800
  return;
1140
1801
  }
1141
1802
  case "hit": {
1803
+ /* A click in a canvas is a click in a PANE, and the parent realm never saw it. See
1804
+ `focusHostPane`; it goes first so everything below writes into the pane the person is now
1805
+ in rather than starting a selection the Inspector will not show. `paneFocus` has usually
1806
+ beaten it here, and both stay: the canvas bundle ships prebuilt, so a frame whose build
1807
+ predates `paneFocus` must still focus its pane on a click that lands on a node. */
1808
+ focusHostPane(state);
1809
+ // Both, for the same reason and the same cost: the release is a no-op unless the bar is
1810
+ // Actually suppressed, so the pair of calls a modern frame makes is one null check.
1811
+ canvasPointerDownHandler?.();
1812
+ // Selecting a real document node retires any layout selection — the two are alternatives, and
1813
+ // A stale layout panel next to a fresh element selection would name the wrong thing.
1814
+ setLayoutSelection(null);
1142
1815
  // The clicked panel becomes the ACTIVE panel (same as clicking its header): getActivePanel(),
1143
1816
  // Header highlighting, and the style panel's breakpoint context all follow the click — and the
1144
1817
  // Block action bar anchors to the panel the selection was actually made in, not panel 0.
1145
1818
  let panelMedia: string | null = null;
1146
- for (const p of canvasPanels) {
1147
- if (hosts.get(p.canvas as HTMLElement) === state) {
1148
- if (!p.mediaName?.startsWith("git-diff")) {
1149
- panelMedia = panelMediaToActiveMedia(p.mediaName);
1150
- updateUi("activeMedia", panelMedia);
1151
- }
1152
- break;
1819
+ const clicked = panelHostingCanvas(state.canvasEl)?.panel;
1820
+ if (clicked && !clicked.mediaName?.startsWith("git-diff")) {
1821
+ panelMedia = panelMediaToActiveMedia(clicked.mediaName);
1822
+ // The breakpoint belongs to the tab THIS host renders, resolved the same way every other
1823
+ // Doc-touching message in this switch resolves it. `updateUi` writes to `activeTab`, which
1824
+ // Is the focused pane's tab — so a click in an unfocused pane set the wrong document's
1825
+ // Breakpoint, and the Style panel then edited a compound block the person never opened.
1826
+ const hitTab = hostTab(state);
1827
+ if (hitTab) {
1828
+ hitTab.session.ui.activeMedia = panelMedia;
1153
1829
  }
1154
1830
  }
1155
1831
  // A canvas left-click closes an open context menu — its parent-realm outside-click listener
@@ -1171,10 +1847,16 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
1171
1847
  return;
1172
1848
  }
1173
1849
  // A click in the canvas selects the node; the selection watcher redraws the box via `measure`.
1850
+ // Ctrl/Cmd-click ACCUMULATES instead — the same modifier the Outline uses, so the two
1851
+ // Surfaces answer the same gesture. Without the modifier this is a plain replace, which is
1852
+ // What every canvas click has always been.
1174
1853
  state.selectionPath = msg.hit.path;
1175
- const tab = activeTab.value;
1854
+ state.selectionPaths = [msg.hit.path];
1855
+ const tab = hostTab(state);
1176
1856
  if (tab) {
1177
- tab.session.selection = msg.hit.path;
1857
+ tab.session.selection = msg.additive
1858
+ ? toggleSelected(tab.session.selection, msg.hit.path)
1859
+ : [msg.hit.path];
1178
1860
  }
1179
1861
  // Draw immediately from the posted rect for snappiness (the measure round-trip confirms it).
1180
1862
  {
@@ -1184,11 +1866,36 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
1184
1866
  }
1185
1867
  return;
1186
1868
  }
1869
+ case "layoutHit": {
1870
+ // A click on layout chrome — a node with no page-document path, so it can never become a
1871
+ // `session.selection`. Before this it selected nothing and posted nothing, which is what made
1872
+ // The first click a new user makes (the site name in the header) appear to do nothing at all.
1873
+ // Specimen catalogs have no layout, so the stylebook host ignores it.
1874
+ if (state.stylebook) {
1875
+ return;
1876
+ }
1877
+ // Layout chrome is still canvas, and clicking it is still a click in a pane.
1878
+ focusHostPane(state);
1879
+ canvasContextMenuHandler?.dismiss();
1880
+ setLayoutSelection(msg.hit);
1881
+ // Mutually exclusive with a document selection: the inspector renders one panel or the other.
1882
+ state.selectionPath = null;
1883
+ state.selectionPaths = [];
1884
+ const tab = hostTab(state);
1885
+ if (tab) {
1886
+ tab.session.selection = [];
1887
+ }
1888
+ const rect = canvasRectToParent(msg.hit.rect);
1889
+ state.overlay.setSelection(rect, `LAYOUT · ${msg.hit.layoutFile}`);
1890
+ state.lastSelectionRect = rect;
1891
+ renderOnly("rightPanel");
1892
+ return;
1893
+ }
1187
1894
  case "hover": {
1188
1895
  if (state.stylebook) {
1189
1896
  // Decode to a tag; suppress the box when hovering the selected tag (legacy parity).
1190
1897
  const tag = msg.hit ? resolveStylebookTag(state.stylebook.pathToTag, msg.hit.path) : null;
1191
- const selected = activeTab.value?.session.ui.stylebookSelection ?? null;
1898
+ const selected = shell.stylebook.selection;
1192
1899
  if (msg.hit && tag && tag !== selected) {
1193
1900
  state.overlay.setHover(canvasRectToParent(msg.hit.rect));
1194
1901
  } else {
@@ -1217,6 +1924,15 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
1217
1924
  return;
1218
1925
  }
1219
1926
  case "geometry": {
1927
+ // A `measureCanvasPath` caller is waiting on this exact id — answer it and stop. Checked
1928
+ // FIRST because these ids come from the same counter as the two overlay measures below, so
1929
+ // Falling through would let a point request repaint the selection box.
1930
+ const awaiting = state.pendingMeasures.get(msg.reqId);
1931
+ if (awaiting) {
1932
+ state.pendingMeasures.delete(msg.reqId);
1933
+ awaiting(msg.hits);
1934
+ return;
1935
+ }
1220
1936
  // Remote-presence reply: draw one colored box per measured peer selection.
1221
1937
  if (msg.reqId === state.presenceReqId) {
1222
1938
  state.presenceReqId = -1;
@@ -1242,12 +1958,29 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
1242
1958
  return;
1243
1959
  }
1244
1960
  if (msg.reqId === state.selReqId) {
1245
- const [hit] = msg.hits;
1246
- const rect = hit ? canvasRectToParent(hit.rect) : null;
1247
- const sbTag = state.stylebook
1248
- ? (activeTab.value?.session.ui.stylebookSelection ?? null)
1249
- : null;
1961
+ // A stylebook host measures a SPECIMEN path — a card in a generated catalogue, which is
1962
+ // Not a `session.selection` path at all — so its one hit is its one box, as it always was.
1963
+ // Everywhere else the primary is chosen by PATH rather than by position, because a path
1964
+ // The iframe could not resolve is simply absent from `hits`.
1965
+ const primaryKey = JSON.stringify(state.selectionPath);
1966
+ const co: OverlayPlacement[] = [];
1967
+ let rect: ParentRect | null = null;
1968
+ if (state.stylebook) {
1969
+ const [hit] = msg.hits;
1970
+ rect = hit ? canvasRectToParent(hit.rect) : null;
1971
+ } else {
1972
+ for (const hit of msg.hits) {
1973
+ const key = JSON.stringify(hit.path);
1974
+ if (key === primaryKey) {
1975
+ rect = canvasRectToParent(hit.rect);
1976
+ } else if (state.coSelectionKeys.has(key)) {
1977
+ co.push(canvasRectToParent(hit.rect));
1978
+ }
1979
+ }
1980
+ }
1981
+ const sbTag = state.stylebook ? shell.stylebook.selection : null;
1250
1982
  state.overlay.setSelection(rect, sbTag ? `<${sbTag}>` : null);
1983
+ state.overlay.setCoSelection(co);
1251
1984
  if (rect) {
1252
1985
  state.lastSelectionRect = rect;
1253
1986
  }
@@ -1263,6 +1996,7 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
1263
1996
  return;
1264
1997
  }
1265
1998
  pendingEvals.delete(msg.reqId);
1999
+ evalOwners.delete(msg.reqId);
1266
2000
  resolve(msg.gen === state.lastRenderedGen ? msg.results : null);
1267
2001
  return;
1268
2002
  }
@@ -1283,12 +2017,39 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
1283
2017
  }
1284
2018
  case "patchComplete": {
1285
2019
  // A patch never re-targets the host (tabId untouched) — only the DOM/geometry changed.
2020
+ state.pendingPatches = Math.max(0, state.pendingPatches - 1);
1286
2021
  onDomUpdated(state, msg.gen);
1287
2022
  return;
1288
2023
  }
2024
+ case "idle": {
2025
+ // The frame's own quiescence report. Held, not acted on: it is read by
2026
+ // {@link canvasIdleBlockers} when something asks whether Studio has settled.
2027
+ state.idle = {
2028
+ animations: msg.animations,
2029
+ fonts: msg.fonts,
2030
+ gen: msg.gen,
2031
+ images: msg.images,
2032
+ };
2033
+ return;
2034
+ }
1289
2035
  case "renderError": {
1290
2036
  // The render never landed — its pending identity must not be adopted by a later ack.
1291
2037
  state.pendingTabIds.delete(msg.gen);
2038
+ // …and no `dataScope` will follow it, so a Refresh waiting on one must stop waiting. Without
2039
+ // This the button spins forever on the one failure it most needs to report.
2040
+ const failed = hostTab(state);
2041
+ if (failed) {
2042
+ failed.session.canvas.refreshing = false;
2043
+ renderOnly("leftPanel");
2044
+ }
2045
+ // …and the author is told. `msg.message` was read NOWHERE: the canvas would go blank or stale
2046
+ // And the one string that said why was deleted with the pending id. Keyed on the host, so a
2047
+ // Render loop that fails every generation is one problem rather than sixty.
2048
+ notify.error("The page could not be rendered.", {
2049
+ detail: msg.message,
2050
+ key: `canvas.render:${hostLabel(state)}`,
2051
+ source: "Canvas",
2052
+ });
1292
2053
  return;
1293
2054
  }
1294
2055
  case "dataScope": {
@@ -1300,20 +2061,25 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
1300
2061
  if (msg.gen !== state.lastRenderedGen) {
1301
2062
  return;
1302
2063
  }
1303
- updateCanvas({ scope: msg.scope });
2064
+ // Onto the tab this host rendered, not the focused one: the snapshot describes THAT
2065
+ // Document's `$defs`, and `updateCanvas` would have filed a background pane's data under the
2066
+ // Foreground pane's tab — the Data panel then explains a document that is not on screen.
2067
+ const scoped = hostTab(state);
2068
+ if (scoped) {
2069
+ scoped.session.canvas.scope = msg.scope;
2070
+ // The answer a pending Refresh was waiting for. Clearing it HERE rather than on a timer is
2071
+ // The whole point: a fetch that takes two seconds keeps saying so for two seconds.
2072
+ scoped.session.canvas.refreshing = false;
2073
+ }
1304
2074
  renderOnly("leftPanel");
1305
2075
  return;
1306
2076
  }
1307
2077
  case "contentHeight": {
1308
- // Size the iframe element to its document so the canvas never scrolls internally — the parent
1309
- // Canvas pans/scrolls instead, every node stays inside the iframe box (hit-testable), and the
1310
- // Overlay (drawn in canvas space) tracks it. The cssText's 480px `min-height` is a
1311
- // Pre-measurement floor so an empty/short PAGE stays a usable canvas; a component DEFINITION
1312
- // (fragment) instead hugs its content, so drop the floor for it once measured (else a short
1313
- // Component leaves dead space below — pages keep the floor and stay tall via #jx-canvas-root).
1314
- state.iframe.style.height = `${msg.height}px`;
1315
- state.iframe.style.minHeight = msg.fragment ? "0px" : "480px";
1316
- syncEditZoomViewportHeight(state);
2078
+ // The measurement is STATE, not an instruction: record it, then re-derive the frame box from
2079
+ // (preview flag, last measurement) so the two can never disagree. See applyFrameSizing.
2080
+ state.contentHeight = msg.height;
2081
+ state.contentFragment = msg.fragment;
2082
+ applyFrameSizing(state);
1317
2083
  return;
1318
2084
  }
1319
2085
  case "dragOver": {
@@ -1407,7 +2173,21 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
1407
2173
  }
1408
2174
  case "patchError": {
1409
2175
  // The iframe couldn't apply the edit surgically — fall back to a full render of the live doc.
1410
- patchEscalation?.();
2176
+ // The patch is no longer outstanding either way; a counter that only went up would wedge
2177
+ // Every later idle() behind a message the host already handled.
2178
+ state.pendingPatches = Math.max(0, state.pendingPatches - 1);
2179
+ /* An UNRESOLVABLE stage escalates nothing, and that is now a complete answer rather than a
2180
+ hole. It used to be half of finding 9: `postPatchToHosts` posted a fabricated gen `0` to a
2181
+ host whose stage it could not name, the frame answered `patch-behind-render`, and this
2182
+ same failing lookup swallowed the escalation while `markConsumed` had already suppressed
2183
+ the pane's full render. The post side refuses to post at all now, so reaching here means
2184
+ the panel list was replaced BETWEEN the post and the ack — and that replacement is itself
2185
+ a newer full render of this stage, which is why scheduling one would rebuild a stage that
2186
+ is not stale. See the two tests either side of this in `iframe-host.test.ts`. */
2187
+ const failed = panelHostingCanvas(state.canvasEl)?.surface;
2188
+ if (failed) {
2189
+ patchEscalation?.(failed.paneId);
2190
+ }
1411
2191
  return;
1412
2192
  }
1413
2193
  case "forwardKey": {
@@ -1447,6 +2227,16 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
1447
2227
  }
1448
2228
  state.lastSnapshotSeq = msg.seq;
1449
2229
  state.snapshot = msg;
2230
+ /* A snapshot posts ONLY from a live session (the frame's `onSelectionChange` returns early
2231
+ when nothing is being edited), so it is independent proof that a caret is alive in this
2232
+ host — the third of the three messages {@link isCaretActive} is derived from. Adopting the
2233
+ host here recovers the flag if the `editStart` that opened the session never landed, and it
2234
+ cannot resurrect a finished one: the frame stops posting snapshots before it posts
2235
+ `editEnd`, and the channel is FIFO. */
2236
+ if (!state.editing) {
2237
+ state.editing = true;
2238
+ activeEditHost = state;
2239
+ }
1450
2240
  toolbarRefresh?.();
1451
2241
  return;
1452
2242
  }
@@ -1454,6 +2244,7 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
1454
2244
  const done = pendingFlushes.get(msg.reqId);
1455
2245
  if (done) {
1456
2246
  pendingFlushes.delete(msg.reqId);
2247
+ flushOwners.delete(msg.reqId);
1457
2248
  done();
1458
2249
  }
1459
2250
  return;
@@ -1522,6 +2313,7 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
1522
2313
  const top = msg.rect.y * scale + ifr.top;
1523
2314
  canvasSlashHandler?.show({
1524
2315
  filter: msg.filter,
2316
+ ...(msg.showFilter === true ? { showFilter: true } : {}),
1525
2317
  onDismiss: () => state.channel.post({ kind: "slashDismissed" }),
1526
2318
  onSelect: (cmd) => state.channel.post({ cmd: { ...cmd }, kind: "slashSelect" }),
1527
2319
  rect: {
@@ -1550,6 +2342,15 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
1550
2342
  if (state.stylebook) {
1551
2343
  return; // The doc context menu's actions are meaningless for specimen paths.
1552
2344
  }
2345
+ /* A right-click is a click, and it is the ONLY message it delivers: `contextmenu` does not
2346
+ fire `click`, so `hit` — where the other half of this seam focuses the pane — never
2347
+ arrives. Without this line the menu was built against the FOCUSED document while pointing
2348
+ at a node in this one: `editor/context-menu.ts` writes `tab.session.selection = [path]`
2349
+ before it decides which rows to show, so right-clicking the side pane moved the PRIMARY
2350
+ pane's selection to a path from a different document, and Duplicate/Delete/Wrap then
2351
+ operated there. When the focused document had no such path the menu returned early and the
2352
+ right-click did nothing at all, silently. */
2353
+ focusHostPane(state);
1553
2354
  // A canvas right-click — convert to parent-viewport coords and show the Jx element menu.
1554
2355
  const { rect: ifr, scale } = hostDragGeometry(state);
1555
2356
  canvasContextMenuHandler?.show({
@@ -1588,16 +2389,20 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
1588
2389
  */
1589
2390
  function onDomUpdated(state: HostState, gen: number): void {
1590
2391
  state.lastRenderedGen = gen;
1591
- requestSelection(state, state.selectionPath);
2392
+ requestSelection(state, state.selectionPaths);
1592
2393
  requestPresence(state);
1593
2394
  hideInsertZoneNow(state);
1594
2395
  if (state.pendingEnterEdit && gen >= state.pendingEnterEdit.minGen) {
1595
2396
  const { offset, path } = state.pendingEnterEdit;
1596
2397
  state.pendingEnterEdit = null;
1597
- // Re-enter only when this host still shows the ACTIVE tab — a background tab's iframe renders a
1598
- // Different doc, so re-entering there would grab the wrong element (the commit itself already
1599
- // Landed in the right tab via hostTab routing).
1600
- if (state.tabId !== null && state.tabId === workspace.activeTabId) {
2398
+ /* Re-enter only when this host STILL SHOWS THE TAB IT OWES THE CARET TO — which is a question
2399
+ about this host's pane, not about the focus. A background tab's iframe renders a different
2400
+ document, so re-entering there would grab the wrong element; the commit itself already landed
2401
+ in the right tab via `hostTab` routing.
2402
+ This asked `workspace.activeTabId` for two phases, which is the same question only while
2403
+ there is one stage. With two, the side pane could be displaying its tab, holding a caret it
2404
+ owed, and drop it because the PRIMARY had focus. */
2405
+ if (state.tabId !== null && state.tabId === tabOfContainer(state.canvasEl)?.id) {
1601
2406
  reenterEdit(state, path, offset);
1602
2407
  }
1603
2408
  }
@@ -1638,7 +2443,16 @@ function redispatchWheel(
1638
2443
  msg: Extract<IframeToParent, { kind: "forwardWheel" }>,
1639
2444
  ): void {
1640
2445
  const { rect, scale } = hostDragGeometry(state);
1641
- canvasWrap.dispatchEvent(
2446
+ /* The wheel goes back to the stage this FRAME is mounted on, not to "the canvas".
2447
+ `canvasWrap` was the focused pane's stage, so a wheel forwarded out of an unfocused pane's
2448
+ iframe panned the other pane. Resolved from the DOM rather than from the panel bookkeeping,
2449
+ because the frame is physically inside exactly one stage whether or not a pass has recorded an
2450
+ artboard for it yet. */
2451
+ const stage = stageContaining(state.canvasEl)?.wrap;
2452
+ if (!stage) {
2453
+ return;
2454
+ }
2455
+ stage.dispatchEvent(
1642
2456
  new WheelEvent("wheel", {
1643
2457
  bubbles: true,
1644
2458
  cancelable: true,
@@ -1679,8 +2493,154 @@ function drawHover(state: HostState, hit: NodeHit | null): void {
1679
2493
  }
1680
2494
 
1681
2495
  /**
1682
- * Render `doc` into the iframe canvas mounted in `canvasEl`: resolve the document parent-side and
1683
- * post it (queued until the iframe is `ready`).
2496
+ * The part of a `render` message that is a fact about the DOCUMENT rather than about a host: the
2497
+ * resolved and wire-serialized document, its base, the path-mapper context, the mode and the site
2498
+ * style. Everything a host contributes — its width, its preview flag, its tab-id bookkeeping —
2499
+ * stays outside.
2500
+ */
2501
+ type PreparedRender = Omit<
2502
+ Extract<ParentToIframe, { kind: "render" }>,
2503
+ "colorScheme" | "gen" | "kind"
2504
+ >;
2505
+
2506
+ /** One render pass's prepared payloads, by document IDENTITY. */
2507
+ type PreparedDocs = Map<
2508
+ JxMutableNode,
2509
+ { tabId: string | null; viewTabId: string | null; payload: Promise<PreparedRender> }
2510
+ >;
2511
+
2512
+ /**
2513
+ * How many render passes may be prepared at once. Two panes, each mid-pass, plus slack.
2514
+ *
2515
+ * A ceiling rather than "however many are in flight", because nothing tells this module when a pass
2516
+ * ENDS: the last artboard of a pass is indistinguishable from the first artboard of the next one. A
2517
+ * small LRU is the honest bound — a pass whose artboards are still arriving is always among the
2518
+ * most recently touched, and anything older cannot be reused by anyone.
2519
+ */
2520
+ const PREPARED_PASS_LIMIT = 4;
2521
+
2522
+ /**
2523
+ * The prepared payload for every render pass in flight, keyed by generation.
2524
+ *
2525
+ * A design-mode canvas draws one artboard per breakpoint, and every one of them renders the SAME
2526
+ * document at a different viewport width. Resolving it per artboard meant N layout merges, N
2527
+ * edit-mode transforms, 2N whole-document JSON round trips — and, on a dynamic-route page, N
2528
+ * backend round trips, because `resolveParamBoundState` POSTs each param-bound state entry to
2529
+ * `/__jx_resolve__` and that ran once per host. The pass resolves once and every host is fed from
2530
+ * the result.
2531
+ *
2532
+ * **It was ONE pass, and a second stage is what made that wrong.** The slot was replaced whenever
2533
+ * `gen` differed, on the reasoning that "a render pass is exactly one generation, so the map is
2534
+ * dropped whole when the next pass starts". That holds while a pass is the only thing running. Both
2535
+ * stages schedule through rAF and `mountIframeCanvas` awaits inside the loop, so two passes
2536
+ * INTERLEAVE at every await: pane A's second artboard came back to a slot pane B had just claimed,
2537
+ * re-resolved and re-serialized a document that had been prepared moments earlier, and evicted pane
2538
+ * B's in turn. Three preparations for three artboards — the fan-out this cache exists to remove,
2539
+ * restored exactly by the second pane.
2540
+ *
2541
+ * Within a generation the key is document IDENTITY, because git-diff mounts two different documents
2542
+ * under one generation and each gets its own entry. `tabId` rides along because `editableTags` is
2543
+ * derived from it, and the VIEW tab's id beside it because the whole resolution is derived from
2544
+ * THAT — the document path, the layout toggle, the preview params and the mode. A mismatch in
2545
+ * either re-prepares rather than reusing. `tabId` alone would not do: an override render nulls it,
2546
+ * so two git-diff documents would agree on `null` while being views of different tabs.
2547
+ */
2548
+ const preparedPasses = new Map<number, PreparedDocs>();
2549
+
2550
+ /**
2551
+ * This generation's prepared documents, created on first mention and touched to the front.
2552
+ *
2553
+ * Insertion order is the LRU order: re-inserting on every access is what keeps two interleaved
2554
+ * passes both live no matter how long they alternate, while a pass nobody has touched for four
2555
+ * generations falls off the end.
2556
+ */
2557
+ function preparedDocsFor(gen: number): PreparedDocs {
2558
+ const existing = preparedPasses.get(gen);
2559
+ if (existing) {
2560
+ preparedPasses.delete(gen);
2561
+ preparedPasses.set(gen, existing);
2562
+ return existing;
2563
+ }
2564
+ const fresh: PreparedDocs = new Map();
2565
+ preparedPasses.set(gen, fresh);
2566
+ for (const stale of preparedPasses.keys()) {
2567
+ if (preparedPasses.size <= PREPARED_PASS_LIMIT) {
2568
+ break;
2569
+ }
2570
+ preparedPasses.delete(stale);
2571
+ }
2572
+ return fresh;
2573
+ }
2574
+
2575
+ /**
2576
+ * Resolve + serialize `doc` for the wire, once per render pass.
2577
+ *
2578
+ * Returns the SAME promise to every host in the pass, so the second artboard awaits the first one's
2579
+ * work instead of repeating it. The payload objects it yields are shared by reference across the
2580
+ * messages posted to each host: `postMessage` structured-clones on the way into each frame, so
2581
+ * every iframe still folds patches into a shadow doc that is its own. Nothing may mutate a
2582
+ * `PreparedRender` after it is built.
2583
+ */
2584
+ function preparePassRender(
2585
+ gen: number,
2586
+ doc: JxMutableNode,
2587
+ tabId: string | null,
2588
+ viewTab: Tab | null,
2589
+ paneId: string,
2590
+ ): Promise<PreparedRender> {
2591
+ const byDoc = preparedDocsFor(gen);
2592
+ const viewTabId = viewTab?.id ?? null;
2593
+ const cached = byDoc.get(doc);
2594
+ if (cached && cached.tabId === tabId && cached.viewTabId === viewTabId) {
2595
+ return cached.payload;
2596
+ }
2597
+ // One-shot per PANE's pass, not per host and not globally. `allowAutoRequestsOnNextRender` arms
2598
+ // "the next render of pane X": consuming it inside the per-host mount meant whichever artboard
2599
+ // Mounted first swallowed it and the rest re-rendered with automatic `Request` entries still
2600
+ // Suppressed, and consuming a GLOBAL here meant whichever PANE's pass got here first did the
2601
+ // Same to the other one. `gen` comes from one monotonic counter shared by every surface, so a
2602
+ // Pass belongs to exactly one pane and this consume is that pane's.
2603
+ const allowAutoRequests = consumeAllowAutoRequests(paneId);
2604
+ const payload = timeSpanAsync(SPAN_PREPARE_RENDER, async (): Promise<PreparedRender> => {
2605
+ canvasPerf.renderPreparations += 1;
2606
+ const resolved = await resolveCanvasDocument(doc, viewTab);
2607
+ // The doc must be structured-cloneable to cross postMessage. A Jx document is JSON by contract,
2608
+ // So a JSON round-trip (NOT structuredClone, which would throw) drops residual functions /
2609
+ // Reactive proxy artifacts that would otherwise raise DataCloneError and silently drop the
2610
+ // Entire message.
2611
+ // oxlint-disable-next-line unicorn/prefer-structured-clone
2612
+ const cloneableDoc = JSON.parse(JSON.stringify(resolved.renderDoc)) as unknown;
2613
+ // The RAW page doc (forward-op + data-jx-path coordinate space) crosses as the shadow doc.
2614
+ // oxlint-disable-next-line unicorn/prefer-structured-clone
2615
+ const cloneableShadow = JSON.parse(JSON.stringify(doc)) as unknown;
2616
+ // Which tags can hold a caret depends on the document's vocabulary, so the format's verdicts
2617
+ // Ride with the render rather than being baked into the frame. Absent for a native document,
2618
+ // Where the studio's own element metadata answers on its own.
2619
+ // Resolve from THIS render's tab, not any host's `tabId` — that is only adopted when a render is
2620
+ // Acknowledged, so at post time it still names the previous render's document (null on first
2621
+ // Mount, which is exactly the render that matters).
2622
+ const renderTab = tabId ? (workspace.tabs.get(tabId) ?? null) : null;
2623
+ const formatElements = formatByName(renderTab?.doc.sourceFormat)?.studio?.elements;
2624
+ const editableTags = formatElements ? formatEditableVerdicts(formatElements) : undefined;
2625
+ return {
2626
+ doc: cloneableDoc,
2627
+ docBase: resolved.docBase ?? `${canvasBaseOrigin()}/`,
2628
+ mapperCtx: resolved.mapperCtx,
2629
+ mode: resolved.mapperCtx.canvasMode as CanvasMode,
2630
+ shadowDoc: cloneableShadow,
2631
+ siteStyle: resolved.siteStyle,
2632
+ ...(editableTags ? { editableTags } : {}),
2633
+ ...(allowAutoRequests ? { allowAutoRequests: true } : {}),
2634
+ };
2635
+ });
2636
+ byDoc.set(doc, { payload, tabId, viewTabId });
2637
+ return payload;
2638
+ }
2639
+
2640
+ /**
2641
+ * Render `doc` into the iframe canvas mounted in `canvasEl`: resolve the document once for the
2642
+ * whole render pass ({@link preparePassRender}) and post it to this host (queued until the iframe
2643
+ * is `ready`).
1684
2644
  *
1685
2645
  * `widthPx` makes the panel's breakpoint width an EXPLICIT lever on the iframe element itself (only
1686
2646
  * `style.width` is touched — the rest of cssText, incl. `min-height:480px; height:100%`, is kept).
@@ -1694,6 +2654,15 @@ function drawHover(state: HostState, hit: NodeHit | null): void {
1694
2654
  * git-diff, whose iframes must never route doc mutations anywhere). It is recorded against `gen`
1695
2655
  * and adopted into `host.tabId` only when the iframe acks the render — see
1696
2656
  * {@link HostState.tabId}.
2657
+ *
2658
+ * `viewTab` is a DIFFERENT question and that is why it is a second parameter: `tabId` asks "where
2659
+ * do this frame's mutations go", `viewTab` asks "whose document path, layout toggle, preview params
2660
+ * and canvas mode resolve it". They diverge for exactly the override case — a git-diff render has
2661
+ * no mutation target but is still a view OF the tab whose diff it is, and its `docBase` and edit
2662
+ * transform must come from that tab. The default derives it from the element, the way
2663
+ * `paneOfContainer` does everywhere else stage content is handed a host and nothing else; the one
2664
+ * production caller (`canvas-render.ts`) passes `tabOfPane(surface.paneId)` explicitly, because it
2665
+ * has already resolved it to pick the document.
1697
2666
  */
1698
2667
  export async function mountIframeCanvas(
1699
2668
  gen: number,
@@ -1701,6 +2670,7 @@ export async function mountIframeCanvas(
1701
2670
  canvasEl: HTMLElement,
1702
2671
  widthPx?: number | null,
1703
2672
  tabId: string | null = null,
2673
+ viewTab: Tab | null = tabOfContainer(canvasEl),
1704
2674
  ): Promise<void> {
1705
2675
  const state = ensureHost(canvasEl);
1706
2676
  state.pendingTabIds.set(gen, tabId);
@@ -1711,41 +2681,32 @@ export async function mountIframeCanvas(
1711
2681
  // Own `latestGen`), so the parent must NOT gate on `view.renderGeneration`: during boot many
1712
2682
  // Renders fire and the generation is usually stale by the time resolution finishes, which would
1713
2683
  // Otherwise drop every post.
1714
- const resolved = await resolveCanvasDocument(doc);
1715
- // The doc must be structured-cloneable to cross postMessage. A Jx document is JSON by contract, so
1716
- // A JSON round-trip (NOT structuredClone, which would throw) drops residual functions / reactive
1717
- // Proxy artifacts that would otherwise raise DataCloneError and silently drop the entire message.
1718
- // oxlint-disable-next-line unicorn/prefer-structured-clone
1719
- const cloneableDoc = JSON.parse(JSON.stringify(resolved.renderDoc)) as unknown;
1720
- // The RAW page doc (forward-op + data-jx-path coordinate space) crosses as the iframe's shadow doc.
1721
- // oxlint-disable-next-line unicorn/prefer-structured-clone
1722
- const cloneableShadow = JSON.parse(JSON.stringify(doc)) as unknown;
1723
- // Which tags can hold a caret depends on the document's vocabulary, so the format's verdicts ride
1724
- // With the render rather than being baked into the frame. Absent for a native document, where the
1725
- // Studio's own element metadata answers on its own.
1726
- // Resolve from THIS render's tab, not `state.tabId` — that is only adopted when the render is
1727
- // Acknowledged, so at post time it still names the previous render's document (null on first
1728
- // Mount, which is exactly the render that matters).
1729
- const renderTab = tabId ? (workspace.tabs.get(tabId) ?? null) : null;
1730
- const formatElements = formatByName(renderTab?.doc.sourceFormat)?.studio?.elements;
1731
- const editableTags = formatElements ? formatEditableVerdicts(formatElements) : undefined;
2684
+ const prepared = await preparePassRender(gen, doc, tabId, viewTab, paneOfContainer(canvasEl));
1732
2685
  const message: ParentToIframe = {
1733
- colorScheme: activeSchemeWire(),
1734
- doc: cloneableDoc,
1735
- docBase: resolved.docBase ?? `${canvasBaseOrigin()}/`,
2686
+ ...prepared,
2687
+ // Per-TAB, and read at POST time so a scheme flip that raced the shared resolution is not
2688
+ // Baked into the payload every host shares. `viewTab` is this artboard's tab — defaulted from
2689
+ // `tabOfContainer(canvasEl)`, the same route the rest of this mount takes.
2690
+ colorScheme: schemeWireFor(viewTab),
1736
2691
  gen,
1737
2692
  kind: "render",
1738
- mapperCtx: resolved.mapperCtx,
1739
- mode: resolved.mapperCtx.canvasMode as CanvasMode,
1740
- shadowDoc: cloneableShadow,
1741
- siteStyle: resolved.siteStyle,
1742
- ...(editableTags ? { editableTags } : {}),
1743
- ...(consumeAllowAutoRequests() ? { allowAutoRequests: true } : {}),
1744
2693
  };
1745
- // A mode switch to preview mid-split must not start an edit session in the preview render.
1746
- if (message.mode === "preview") {
1747
- state.pendingEnterEdit = null;
1748
- }
2694
+ // Preview is the fidelity view: no editing messages are honoured from it, no overlay is painted
2695
+ // Over it, and the frame stays viewport-sized so it scrolls for real. A mode switch to preview
2696
+ // Mid-split must likewise not start an edit session in the preview render. The flag and the frame
2697
+ // Box move together (setHostPreview) — this assignment sits AFTER an await, so any contentHeight
2698
+ // That landed while the document resolved was answered under the previous mode's rule.
2699
+ setHostPreview(state, message.mode === "preview");
2700
+ deliverRender(state, message);
2701
+ }
2702
+
2703
+ /**
2704
+ * Hand a host its render — straight down the channel when the iframe is up, queued as `pending`
2705
+ * until it says `ready`. Counted either way: the message is this pass's work for this host whether
2706
+ * or not the frame has finished loading.
2707
+ */
2708
+ function deliverRender(state: HostState, message: ParentToIframe): void {
2709
+ canvasPerf.hostRenderPosts += 1;
1749
2710
  if (state.ready) {
1750
2711
  state.channel.post(message);
1751
2712
  } else {
@@ -1773,6 +2734,7 @@ export function mountStylebookCanvas(
1773
2734
  const state = ensureHost(canvasEl);
1774
2735
  state.pendingTabIds.set(gen, null);
1775
2736
  state.stylebook = { pathToTag: generated.pathToTag, tagToCardPath: generated.tagToCardPath };
2737
+ setHostPreview(state, false);
1776
2738
  state.pendingEnterEdit = null;
1777
2739
  state.iframe.style.width = widthPx ? `${widthPx}px` : "100%";
1778
2740
  // Two independent plain clones: the iframe renders `doc` and folds styleUpdates into `shadowDoc`
@@ -1782,7 +2744,9 @@ export function mountStylebookCanvas(
1782
2744
  // oxlint-disable-next-line unicorn/prefer-structured-clone
1783
2745
  const cloneableShadow = JSON.parse(JSON.stringify(generated.doc)) as unknown;
1784
2746
  const message: ParentToIframe = {
1785
- colorScheme: activeSchemeWire(),
2747
+ // The specimen carries no tab identity, but the STAGE it is mounted into does: a stylebook is
2748
+ // Opened as a tab like anything else, and its `previewColorScheme` is that tab's.
2749
+ colorScheme: schemeWireFor(tabOfContainer(canvasEl)),
1786
2750
  doc: cloneableDoc,
1787
2751
  docBase: `${canvasBaseOrigin()}/`,
1788
2752
  gen,
@@ -1800,16 +2764,26 @@ export function mountStylebookCanvas(
1800
2764
  // SiteStyle too would double-apply it.
1801
2765
  siteStyle: null,
1802
2766
  };
1803
- if (state.ready) {
1804
- state.channel.post(message);
1805
- } else {
1806
- state.pending = message;
1807
- }
2767
+ deliverRender(state, message);
1808
2768
  }
1809
2769
 
1810
- /** The active tab's forced preview scheme as wire data (auto → null). */
1811
- function activeSchemeWire(): "light" | "dark" | null {
1812
- const s = activeTab.value?.session.ui.previewColorScheme;
2770
+ /**
2771
+ * A TAB's forced preview scheme as wire data (auto → null).
2772
+ *
2773
+ * Takes its tab, and that is the whole fix. It was `activeSchemeWire()` — zero-argument, reading
2774
+ * `activeTab` — and both mounts call it while holding the artboard they are rendering INTO, so
2775
+ * every render of the side pane posted the FOCUSED tab's scheme over its own: a side tab set to
2776
+ * Dark rendered light whenever an Auto tab had the keyboard, and a side tab set to Auto rendered
2777
+ * dark whenever a Dark one did. Its own control went on saying what it had been set to, because the
2778
+ * record it reads is the one nobody wrote.
2779
+ *
2780
+ * This is verbatim the defect {@link postColorSchemeToLiveHosts} was given a `root` for. The PUSH
2781
+ * path was scoped and the RENDER path was not, and the per-pane effect in `studio.ts` only re-runs
2782
+ * when the scheme CHANGES — so nothing repaired the pane afterwards, and any later re-render (a
2783
+ * document edit, a breakpoint change, a mode switch) silently reverted it again.
2784
+ */
2785
+ function schemeWireFor(tab: Tab | null): "light" | "dark" | null {
2786
+ const s = tab?.session.ui.previewColorScheme;
1813
2787
  return s === "light" || s === "dark" ? s : null;
1814
2788
  }
1815
2789
 
@@ -1817,18 +2791,59 @@ function activeSchemeWire(): "light" | "dark" | null {
1817
2791
  * Flip the color-scheme preview on every ready host (page and stylebook alike — both render
1818
2792
  * scheme-aware CSS). A pure attribute write iframe-side: no render, no patch, no gen.
1819
2793
  */
1820
- export function postColorSchemeToLiveHosts(scheme: "light" | "dark" | null): void {
2794
+ export function postColorSchemeToLiveHosts(
2795
+ scheme: "light" | "dark" | null,
2796
+ root?: HTMLElement | null,
2797
+ ): void {
1821
2798
  for (const host of liveHosts) {
1822
2799
  if (!host.iframe.isConnected) {
1823
2800
  liveHosts.delete(host);
1824
2801
  continue;
1825
2802
  }
2803
+ /* Scoped to one stage when the caller names one. The preview scheme is a per-TAB choice
2804
+ (`session.ui.previewColorScheme`), so with two live hosts an unscoped post flipped the other
2805
+ pane's document to a scheme nobody had asked it for — and left its own control still saying
2806
+ "Auto", because the record it reads belongs to the tab that was never changed. */
2807
+ if (root && !root.contains(host.iframe)) {
2808
+ continue;
2809
+ }
1826
2810
  if (host.ready) {
1827
2811
  host.channel.post({ kind: "setColorScheme", scheme });
1828
2812
  }
1829
2813
  }
1830
2814
  }
1831
2815
 
2816
+ /**
2817
+ * Flip the artboard's language on every ready host — `lang` and `dir`, and nothing else.
2818
+ *
2819
+ * This is what makes `i18n.switchLocale` a rendering context rather than a chip. Jx has no message
2820
+ * catalogue, so the text does not change; the direction does, and a layout that only mirrors in
2821
+ * production is a layout an author cannot check before shipping it.
2822
+ *
2823
+ * SCOPED to one stage when the caller names one, for the reason {@link postColorSchemeToLiveHosts}
2824
+ * carries the same parameter: the preview locale is a per-TAB choice, so with two live hosts an
2825
+ * unscoped post redraws the other pane's document in a language nobody asked it for — and leaves
2826
+ * its own control still naming the language it was never moved off.
2827
+ *
2828
+ * `dir` is computed here and sent with the tag: `localeDirection` is CLDR's answer through
2829
+ * `Intl.Locale`, and a frame deriving it again is a second copy of the RTL script list to keep.
2830
+ */
2831
+ export function postLocaleToLiveHosts(locale: string | null, root?: HTMLElement | null): void {
2832
+ const dir = localeDirection(locale);
2833
+ for (const host of liveHosts) {
2834
+ if (!host.iframe.isConnected) {
2835
+ liveHosts.delete(host);
2836
+ continue;
2837
+ }
2838
+ if (root && !root.contains(host.iframe)) {
2839
+ continue;
2840
+ }
2841
+ if (host.ready) {
2842
+ host.channel.post({ dir, kind: "setLocale", locale });
2843
+ }
2844
+ }
2845
+ }
2846
+
1832
2847
  /**
1833
2848
  * Push the project's current site style to every ready PAGE host as an in-place sheet replace (live
1834
2849
  * design-token editing — stylebook hosts pre-merge site style into the specimen doc and are
@@ -1877,7 +2892,7 @@ export function postStyleUpdateToStylebookHosts(style: Record<string, unknown>):
1877
2892
  if (host.ready && host.stylebook) {
1878
2893
  host.channel.post({ gen: host.lastRenderedGen, kind: "styleUpdate", style: cloneable });
1879
2894
  posted += 1;
1880
- requestSelection(host, host.selectionPath);
2895
+ requestSelection(host, host.selectionPaths);
1881
2896
  }
1882
2897
  }
1883
2898
  return posted;
@@ -1889,8 +2904,7 @@ export function postStyleUpdateToStylebookHosts(style: Record<string, unknown>):
1889
2904
  * converted parent-viewport rect (see the `geometry` handler's panReqId branch).
1890
2905
  */
1891
2906
  export function panToStylebookTag(tag: string): void {
1892
- const panel = getActivePanel();
1893
- const host = panel ? (hosts.get(panel.canvas as HTMLElement) ?? null) : null;
2907
+ const host = hostForActivePanel();
1894
2908
  if (!host?.stylebook || !host.ready) {
1895
2909
  return;
1896
2910
  }
@@ -1905,27 +2919,79 @@ export function panToStylebookTag(tag: string): void {
1905
2919
 
1906
2920
  // ─── Format-toolbar bridge (Phase 4b-2) ─────────────────────────────────────────
1907
2921
 
1908
- /** The host whose iframe currently owns the inline-edit session (or null). */
1909
- export function getActiveEditHost(): HostState | null {
1910
- return activeEditHost;
1911
- }
1912
-
1913
2922
  /** The current edit session's editing flag + latest selection snapshot, for the parent toolbar. */
1914
2923
  export function getEditSnapshot(): {
1915
2924
  editing: boolean;
1916
2925
  editingProp: string | null;
1917
2926
  snapshot: SelectionSnapshot | null;
1918
2927
  } {
1919
- if (!activeEditHost) {
2928
+ /* The same connectivity guard {@link isCaretActive} has, and for the same reason: a session
2929
+ belongs to a frame, and a frame that has left the document has no caret in it. `releaseHost`
2930
+ clears `activeEditHost`, so this is the second line rather than the first — it covers the frame
2931
+ detached by something that never routed through a release (a `hardClearCanvasWrap`, a mode
2932
+ transition mid-session), where an unguarded `editing: true` keeps the format toolbar on screen
2933
+ anchored to nothing. */
2934
+ const host = activeEditHost;
2935
+ if (!host || !host.iframe.isConnected) {
1920
2936
  return { editing: false, editingProp: null, snapshot: null };
1921
2937
  }
1922
2938
  return {
1923
- editing: activeEditHost.editing,
1924
- editingProp: activeEditHost.editingProp,
1925
- snapshot: activeEditHost.snapshot,
2939
+ editing: host.editing,
2940
+ editingProp: host.editingProp,
2941
+ snapshot: host.snapshot,
1926
2942
  };
1927
2943
  }
1928
2944
 
2945
+ /**
2946
+ * The chord table every live canvas frame forwards against, and the source it is computed from.
2947
+ *
2948
+ * `setKeymapSource` is called once at bootstrap with the app's registry, and again by nothing:
2949
+ * {@link publishKeymap} re-reads it, so a rebinding in Preferences reaches the canvas by the host
2950
+ * calling `publishKeymap()` rather than by anyone holding a stale copy.
2951
+ */
2952
+ let keymapSource: (() => { mac: boolean; chords: readonly SyncedChord[] }) | null = null;
2953
+
2954
+ /**
2955
+ * Tell the host how to read the live chord table.
2956
+ *
2957
+ * Injected rather than imported so this module keeps its one direction of dependency: the canvas
2958
+ * host knows nothing about the command registry, and the bootstrap that owns both wires them.
2959
+ */
2960
+ export function setKeymapSource(
2961
+ source: () => { mac: boolean; chords: readonly SyncedChord[] },
2962
+ onChange?: (listener: () => void) => () => void,
2963
+ ): void {
2964
+ keymapSource = source;
2965
+ // A copy with no invalidation is a second authority that drifts. `onChange` fires when the user
2966
+ // Rebinds a key, and reposting is what makes Preferences › Keyboard reach inside the canvas —
2967
+ // Which three hand-written lists in `iframe-keys.ts` could never do.
2968
+ onChange?.(() => publishKeymap());
2969
+ publishKeymap();
2970
+ }
2971
+
2972
+ /**
2973
+ * Send the current chord table to every live frame.
2974
+ *
2975
+ * Called on a frame's `ready` (so a newly mounted canvas is never guessing) and whenever the keymap
2976
+ * changes (so rebinding ⌘B in Preferences rebinds it inside the page too — the thing three
2977
+ * hand-written lists in `iframe-keys.ts` could never do).
2978
+ */
2979
+ export function publishKeymap(): void {
2980
+ const table = keymapSource?.();
2981
+ if (!table) {
2982
+ return;
2983
+ }
2984
+ for (const host of liveHosts) {
2985
+ if (!host.iframe.isConnected) {
2986
+ liveHosts.delete(host);
2987
+ continue;
2988
+ }
2989
+ if (host.ready) {
2990
+ host.channel.post({ chords: table.chords, kind: "keymap", mac: table.mac });
2991
+ }
2992
+ }
2993
+ }
2994
+
1929
2995
  /** Post an `applyFormat` intent to the active edit host's iframe (no-op when none/not ready). */
1930
2996
  export function postApplyFormat(intent: ApplyFormatIntent): void {
1931
2997
  const host = activeEditHost;
@@ -1935,6 +3001,21 @@ export function postApplyFormat(intent: ApplyFormatIntent): void {
1935
3001
  host.channel.post({ intent, kind: "applyFormat" });
1936
3002
  }
1937
3003
 
3004
+ /**
3005
+ * Ask the active edit host's iframe to open the slash menu at its caret.
3006
+ *
3007
+ * The same shape and the same guard as {@link postApplyFormat}: the caret lives in the other realm,
3008
+ * so a command in this one can only ask. `activeEditHost` is null when no session is live, which is
3009
+ * exactly when `insert.openSlashMenu` refuses.
3010
+ */
3011
+ export function postOpenSlash(): void {
3012
+ const host = activeEditHost;
3013
+ if (!host || !host.ready) {
3014
+ return;
3015
+ }
3016
+ host.channel.post({ kind: "openSlash" });
3017
+ }
3018
+
1938
3019
  /**
1939
3020
  * Ask the active edit host's iframe to commit-and-end its inline-edit session (no-op when none).
1940
3021
  * The parent calls this when intent leaves the edit surface in the PARENT realm — a tab switch, a
@@ -1944,7 +3025,9 @@ export function postApplyFormat(intent: ApplyFormatIntent): void {
1944
3025
  */
1945
3026
  export function commitActiveEditSession(): void {
1946
3027
  const host = activeEditHost;
1947
- if (host?.editing && host.ready) {
3028
+ // `isConnected` too: posting into a removed frame is an optional-chained no-op inside the
3029
+ // Channel, so the caller is told the commit was requested when nothing received it.
3030
+ if (host?.editing && host.ready && host.iframe.isConnected) {
1948
3031
  host.channel.post({ kind: "endEdit" });
1949
3032
  }
1950
3033
  }
@@ -1952,7 +3035,7 @@ export function commitActiveEditSession(): void {
1952
3035
  /** The live host backing the active panel's canvas (for non-edit selection-bar positioning). */
1953
3036
  function hostForActivePanel(): HostState | null {
1954
3037
  const panel = getActivePanel();
1955
- return panel ? (hosts.get(panel.canvas as HTMLElement) ?? null) : null;
3038
+ return panel ? hostForCanvas(panel.canvas as HTMLElement) : null;
1956
3039
  }
1957
3040
 
1958
3041
  /**