@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
@@ -1,17 +1,51 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * Layers panel — document tree view showing element hierarchy with collapse, selection, move
4
- * actions, and drag-and-drop reordering.
3
+ * Layers panel — the Outline: the document tree, with collapse, selection, drag-and-drop
4
+ * reordering, and per-row actions that are RENDERINGS of the command registry.
5
+ *
6
+ * Three things this file is deliberate about.
7
+ *
8
+ * **Rows are `registry.forPlacement("outline/row")`.** Every row used to carry five hand-built
9
+ * action buttons, always visible, on every row — five custom elements with shadow roots per visible
10
+ * row. They now collapse to the selected row plus the hovered one, which is Gutenberg's rule and
11
+ * the one plan §3.2 ⑩ codifies: the floating bar owns selection-scoped verbs, the inspector owns
12
+ * values. The verbs, their names, their chords and their disabled reasons all come from the records
13
+ * in `block-action-bar.ts` — the surface renders, it does not decide.
14
+ *
15
+ * The hovered row's cluster is mounted imperatively ({@link mountHoverActions}) rather than by a
16
+ * `display: none` CSS rule, because a hidden `sp-action-button` is still an upgraded custom element
17
+ * with a shadow root: CSS reveal would have kept the whole cost the collapse exists to remove. At
18
+ * most two clusters exist at any moment — the selected row's, and the one under the pointer.
19
+ *
20
+ * **A row says something.** On a real page the tree was a wall of rows all reading "div": only
21
+ * text-bearing nodes got a preview and containers got the tag they already wear as a coloured
22
+ * badge. {@link outlineLabel} derives an identity instead — a title, an `$id`, a class, a landmark
23
+ * name, the first text inside — and returns "" rather than repeat the badge.
24
+ *
25
+ * **It is a tree, and it is reachable.** `role="tree"` / `role="treeitem"` with a roving tabindex
26
+ * and the arrow-key model ARIA specifies: ↑↓ walk the visible rows, → expands then descends, ←
27
+ * collapses then ascends, Enter/F2 renames.
28
+ *
29
+ * **The rows are a MODEL, and the DOM holds a window onto it** ({@link OutlineRow}, `ui/
30
+ * virtual-window.ts`). A 5 000-node page drew 5 000 rows, each carrying an `sp-icon` custom
31
+ * element, on every repaint. It now draws the viewport plus three rows of overscan — and because
32
+ * the DOM is no longer the whole list, nothing may ASK the DOM what the whole list is. Every
33
+ * question about "which rows exist, and in what order" — the shift-range, the arrow walk, Home/End,
34
+ * ←'s climb to the parent, the reveal that follows the selection — is answered from the array
35
+ * {@link buildOutlineRows} produces. A shift-range read off the DOM would silently select the wrong
36
+ * set the moment either end of it scrolled out of the window: a correctness bug wearing a
37
+ * performance change's clothes.
5
38
  */
6
39
 
7
- import { html, nothing } from "lit-html";
40
+ import { html, render as litRender, nothing } from "lit-html";
41
+ import { displayTagName, isTagExpression, tagNameCandidates } from "@jxsuite/schema/guards";
8
42
  import { classMap } from "lit-html/directives/class-map.js";
9
43
  import { ifDefined } from "lit-html/directives/if-defined.js";
44
+ import { ref } from "lit-html/directives/ref.js";
10
45
  import { repeat } from "lit-html/directives/repeat.js";
11
46
  import {
12
47
  VOID_ELEMENTS,
13
48
  childIndex,
14
- childList,
15
49
  flattenTree,
16
50
  getNodeAtPath,
17
51
  nodeLabel,
@@ -20,20 +54,686 @@ import {
20
54
  pathsEqual,
21
55
  } from "../store";
22
56
  import { activeTab } from "../workspace/workspace";
57
+ import {
58
+ isSelected as isPathSelected,
59
+ primarySelection,
60
+ rangeSelection,
61
+ selectionAnchor,
62
+ toggleSelected,
63
+ } from "../tabs/selection";
23
64
  import type { JxPath } from "../state";
24
65
  import type { JxMutableNode } from "@jxsuite/schema/types";
25
- import {
26
- mutateMoveNode,
27
- mutateRemoveNode,
28
- mutateUpdateProperty,
29
- transactDoc,
30
- } from "../tabs/transact";
66
+ import { mutateUpdateProperty, transactDoc } from "../tabs/transact";
31
67
  import { view } from "../view";
68
+ import { setActivityTab } from "../shell";
69
+ import { renderEmptyState } from "./empty-state";
70
+ import { registerPanel } from "./panel-registry";
71
+ import { renderStylebookLayersTemplate } from "./stylebook-layers-panel";
72
+ import { selectStylebookTag, stylebookMeta } from "./stylebook-panel";
32
73
  import { isInlineElement } from "../editor/inline-edit";
33
74
  import { showContextMenu } from "../editor/context-menu";
34
75
  import { panToElement } from "../canvas/canvas-utils";
76
+ import {
77
+ commandIcon,
78
+ commandTooltip,
79
+ runCommand,
80
+ selectionCommandRegistry,
81
+ showCommandOverflow,
82
+ withCommandTarget,
83
+ } from "./block-action-bar";
84
+ import {
85
+ listWindow,
86
+ measuredRowHeight,
87
+ revealListRow,
88
+ watchListWindow,
89
+ } from "../ui/virtual-window";
90
+ import type { CommandRegistry } from "../commands/registry";
91
+ import type { ListWindowWatch } from "../ui/virtual-window";
35
92
  import type { TemplateResult } from "lit-html";
36
93
 
94
+ // ─── What a row says ─────────────────────────────────────────────────────────
95
+
96
+ /** How much of a text preview a 240px column can carry before it is just noise. */
97
+ const LABEL_MAX = 32;
98
+
99
+ /**
100
+ * Tags whose human name is worth more than the tag itself.
101
+ *
102
+ * Deliberately short. `section`, `ul` and `table` are omitted: "Section" next to a `section` badge
103
+ * is the repetition this function exists to remove.
104
+ */
105
+ const LANDMARK_NAMES: Readonly<Record<string, string>> = {
106
+ article: "Article",
107
+ aside: "Sidebar",
108
+ dialog: "Dialog",
109
+ figure: "Figure",
110
+ footer: "Footer",
111
+ form: "Form",
112
+ header: "Header",
113
+ main: "Main",
114
+ nav: "Navigation",
115
+ };
116
+
117
+ /** Trim and ellipsize to {@link LABEL_MAX}. */
118
+ function truncate(text: string): string {
119
+ const clean = text.replaceAll(/\s+/gu, " ").trim();
120
+ return clean.length > LABEL_MAX ? `${clean.slice(0, LABEL_MAX)}…` : clean;
121
+ }
122
+
123
+ /**
124
+ * The first text anywhere under `node`, or "".
125
+ *
126
+ * Bounded at three levels and short-circuited on the first hit: a container's identity is usually
127
+ * its heading or its first line, and walking a whole subtree per row would cost the render what the
128
+ * five-buttons-per-row build already cost it.
129
+ */
130
+ function firstText(node: JxMutableNode, depth = 0): string {
131
+ if (typeof node.textContent === "string" && node.textContent.trim()) {
132
+ return node.textContent.trim();
133
+ }
134
+ if (depth >= 3 || !Array.isArray(node.children)) {
135
+ return "";
136
+ }
137
+ for (const child of node.children) {
138
+ if (typeof child === "string" && child.trim()) {
139
+ return child.trim();
140
+ }
141
+ if (child && typeof child === "object") {
142
+ const found = firstText(child, depth + 1);
143
+ if (found) {
144
+ return found;
145
+ }
146
+ }
147
+ }
148
+ return "";
149
+ }
150
+
151
+ /** The node's first class name, when it has a plain (unbound) `class` attribute. */
152
+ function firstClass(node: JxMutableNode): string {
153
+ const value = node.attributes?.class;
154
+ return typeof value === "string" ? (value.trim().split(/\s+/u)[0] ?? "") : "";
155
+ }
156
+
157
+ /**
158
+ * What an Outline row says about `node`, beyond the tag its badge already shows.
159
+ *
160
+ * Returns "" when the node has no identity of its own — the badge is then the whole answer, which
161
+ * is honest, and quieter than a column of "div".
162
+ *
163
+ * This is NOT `nodeLabel()`. `nodeLabel` answers "name this node anywhere" and prefixes the tag (`p
164
+ * — Hello`), which is right for the canvas overlay and the status bar and wrong here, where the tag
165
+ * is already a coloured badge two pixels to the left. Those surfaces are unchanged.
166
+ */
167
+ export function outlineLabel(node: JxMutableNode): string {
168
+ if (node.$title) {
169
+ return truncate(node.$title);
170
+ }
171
+ // Repeaters and slots have a real name of their own; nodeLabel already composes it.
172
+ if (node.$prototype === "Array" || node.tagName === "slot") {
173
+ return nodeLabel(node);
174
+ }
175
+ if (node.$id) {
176
+ return `#${truncate(node.$id)}`;
177
+ }
178
+ if (typeof node.textContent === "string" && node.textContent.trim()) {
179
+ return truncate(node.textContent);
180
+ }
181
+ const cls = firstClass(node);
182
+ if (cls) {
183
+ return `.${truncate(cls)}`;
184
+ }
185
+ const landmark = LANDMARK_NAMES[displayTagName(node.tagName).toLowerCase()];
186
+ if (landmark) {
187
+ return landmark;
188
+ }
189
+ const inner = firstText(node);
190
+ if (inner) {
191
+ return `“${truncate(inner)}”`;
192
+ }
193
+ const count = Array.isArray(node.children) ? node.children.length : 0;
194
+ return count > 0 ? `${count} item${count === 1 ? "" : "s"}` : "";
195
+ }
196
+
197
+ // ─── Row actions, from the registry ──────────────────────────────────────────
198
+
199
+ /**
200
+ * Verbs shown inline on a row before the rest fold into `⋮`. A 240px column is the budget.
201
+ *
202
+ * Four is what the Outline's own verbs cost: the moves (up, down, into previous, out of parent),
203
+ * which are the reason a document tree has rows you can grab at all. Duplicate and Delete sort
204
+ * after them (`3_structure`, `9_danger`) and so ride in the `⋮` menu with their names and chords
205
+ * intact — they are also on the block action bar, in the row's context menu, and on ⌘D / Delete.
206
+ * The four moves are on none of those.
207
+ */
208
+ export const OUTLINE_ROW_MAX_ITEMS = 4;
209
+
210
+ /**
211
+ * The row's action cluster.
212
+ *
213
+ * Wrapped in {@link withCommandTarget} so every record is evaluated against THIS ROW'S node rather
214
+ * than the selection — `PLACEMENT_MATRIX["outline/row"]` says row actions act on the row's node,
215
+ * and the hovered row is not the selected one.
216
+ */
217
+ export function renderRowCommands(registry: CommandRegistry, path: JxPath) {
218
+ return withCommandTarget(path, () => {
219
+ const placed = registry.forPlacement("outline/row");
220
+ const shown = placed.slice(0, OUTLINE_ROW_MAX_ITEMS);
221
+ const overflow = placed.slice(OUTLINE_ROW_MAX_ITEMS);
222
+ return html`${shown.map(
223
+ (command) => html`<sp-action-button
224
+ quiet
225
+ size="xs"
226
+ class=${command.destructive ? "layer-action layer-delete" : "layer-action"}
227
+ data-command=${command.id}
228
+ aria-label=${command.title}
229
+ title=${commandTooltip(registry, command)}
230
+ ?disabled=${registry.disabledReason(command.id) !== undefined}
231
+ @click=${(e: MouseEvent) => {
232
+ e.stopPropagation();
233
+ (e.currentTarget as HTMLElement).blur();
234
+ runCommand(registry, command.id, path);
235
+ }}
236
+ >${commandIcon(command)}</sp-action-button
237
+ >`,
238
+ )}
239
+ ${
240
+ overflow.length === 0
241
+ ? nothing
242
+ : html`<sp-action-button
243
+ quiet
244
+ size="xs"
245
+ class="layer-action layer-overflow"
246
+ aria-label="More actions"
247
+ aria-haspopup="menu"
248
+ title="More actions"
249
+ @click=${(e: MouseEvent) => {
250
+ e.stopPropagation();
251
+ showCommandOverflow(e.currentTarget as HTMLElement, registry, overflow, path);
252
+ }}
253
+ >
254
+ <sp-icon-more slot="icon"></sp-icon-more>
255
+ </sp-action-button>`
256
+ }`;
257
+ });
258
+ }
259
+
260
+ // ─── The hovered row's cluster ───────────────────────────────────────────────
261
+
262
+ /**
263
+ * The row whose cluster this module mounted on hover, so it can be taken down again.
264
+ *
265
+ * One at a time: the pointer is in one place. The SELECTED row's cluster is lit's (it is in the row
266
+ * template), and is never touched from here.
267
+ */
268
+ let _hoverActionsRow: HTMLElement | null = null;
269
+
270
+ /** The empty span a non-selected row keeps for {@link mountHoverActions} to render into. */
271
+ function actionSlot(row: HTMLElement): HTMLElement | null {
272
+ return row.classList.contains("selected")
273
+ ? null
274
+ : row.querySelector<HTMLElement>(".layer-actions");
275
+ }
276
+
277
+ /** Take down the hover cluster, if one is up. */
278
+ export function clearHoverActions(): void {
279
+ const slot = _hoverActionsRow ? actionSlot(_hoverActionsRow) : null;
280
+ if (slot) {
281
+ litRender(nothing, slot);
282
+ }
283
+ _hoverActionsRow = null;
284
+ }
285
+
286
+ /** Build `row`'s cluster into its slot, replacing whatever was there. */
287
+ function mountHoverActions(row: HTMLElement): void {
288
+ const slot = actionSlot(row);
289
+ const key = row.dataset.path;
290
+ if (!slot || key === undefined) {
291
+ return;
292
+ }
293
+ _hoverActionsRow = row;
294
+ litRender(renderRowCommands(selectionCommandRegistry(), pathFromKey(key)), slot);
295
+ }
296
+
297
+ /**
298
+ * Delegated `mouseover` (which bubbles; `mouseenter` does not): reveal the cluster on the row under
299
+ * the pointer, and only that row.
300
+ */
301
+ export function onTreeHover(e: Event): void {
302
+ const row = (e.target as HTMLElement | null)?.closest<HTMLElement>('.layer-row[role="treeitem"]');
303
+ if (!row || row === _hoverActionsRow) {
304
+ return;
305
+ }
306
+ clearHoverActions();
307
+ mountHoverActions(row);
308
+ }
309
+
310
+ /**
311
+ * The two things that can only be decided once the rows are in the document.
312
+ *
313
+ * Deferred by a microtask on purpose. A `ref` on the tree element commits BEFORE the child part
314
+ * holding the rows, so on a first render the callback sees an empty tree; and the template this
315
+ * module returns is rendered by its caller, so a microtask is the first tick at which the new DOM
316
+ * exists either way.
317
+ *
318
+ * - The roving tabindex needs to know which rows survived their collapsed ancestors.
319
+ * - The hover cluster needs re-mounting: rows are keyed by path, so a document edit can leave the
320
+ * pointer on a DOM row that now stands for a different node, with different disabled reasons.
321
+ */
322
+ export function afterTreeRender(tree: HTMLElement): void {
323
+ const row = _hoverActionsRow;
324
+ queueMicrotask(() => {
325
+ adoptOutlineTree(tree);
326
+ applyTreeRovingTabindex(tree);
327
+ // A keyboard jump that had to scroll first left its target here, for the render it provoked.
328
+ const wanted = _pendingFocusKey;
329
+ if (wanted !== null) {
330
+ _pendingFocusKey = null;
331
+ focusRow(tree, rowElementFor(tree, wanted) ?? undefined);
332
+ }
333
+ if (!row || _hoverActionsRow !== row) {
334
+ return;
335
+ }
336
+ if (row.isConnected && !row.classList.contains("selected")) {
337
+ mountHoverActions(row);
338
+ } else {
339
+ _hoverActionsRow = null;
340
+ }
341
+ });
342
+ }
343
+
344
+ // ─── The tree, as a keyboard surface ─────────────────────────────────────────
345
+
346
+ /** Pixels of indent per level, and the depth past which the column stops paying for more. */
347
+ const INDENT_STEP = 16;
348
+ const INDENT_MAX_DEPTH = 6;
349
+
350
+ /**
351
+ * The indent for `depth`, capped.
352
+ *
353
+ * Uncapped, a depth-12 row pushed 192px of empty space in front of a badge and a label inside a
354
+ * 240px column, which is what made the panel scroll sideways instead of reading as a tree.
355
+ */
356
+ export function indentWidth(depth: number): number {
357
+ return Math.min(depth, INDENT_MAX_DEPTH) * INDENT_STEP;
358
+ }
359
+
360
+ /** The inverse of {@link pathKey}: numeric segments come back as numbers, as the doc stores them. */
361
+ function pathFromKey(key: string): JxPath {
362
+ return key ? (key.split("/").map((s) => (/^\d+$/u.test(s) ? Number(s) : s)) as JxPath) : [];
363
+ }
364
+
365
+ // ─── The row model, and the window onto it ───────────────────────────────────
366
+
367
+ /**
368
+ * One row the Outline WOULD draw, whether or not it is currently in the window.
369
+ *
370
+ * Built in a first pass that decides visibility (collapsed ancestors, inline elements, the content
371
+ * root) and costs nothing but the decision; the templates are built in a second pass, for the
372
+ * window only. Splitting the two is the whole saving — the expensive half is the `sp-icon` and
373
+ * `sp-action-button` custom elements a row template mounts, not the walk that finds it.
374
+ */
375
+ interface OutlineRow {
376
+ /** `pathKey(path)` — the drag-and-drop and roving-tabindex key, and lit's `repeat` key. */
377
+ key: string;
378
+ path: JxPath;
379
+ depth: number;
380
+ /** Index of this row's parent in the model, or -1 at the top level. Feeds ← and `aria-setsize`. */
381
+ parent: number;
382
+ /**
383
+ * Whether this row is a `role="treeitem"`.
384
+ *
385
+ * Text-node rows are drawn but are not tree items, and were never part of the keyboard walk or a
386
+ * shift-range: `treeRows()` selected on the role, so moving those questions to the model has to
387
+ * carry the same distinction rather than quietly start selecting text nodes.
388
+ */
389
+ item: boolean;
390
+ node: JxMutableNode | string | number | boolean;
391
+ nodeType: string;
392
+ /** 1-based position among the row's `role="treeitem"` siblings — see {@link numberOutlineSets}. */
393
+ posInSet: number;
394
+ /** How many `role="treeitem"` siblings the row has, itself included. */
395
+ setSize: number;
396
+ }
397
+
398
+ /**
399
+ * The declared height of one row — `styles/panels.css` `.layer-row { block-size: 24px }`.
400
+ *
401
+ * A window needs a row height BEFORE the first row exists, so this constant is what the first paint
402
+ * windows by; {@link outlineRowHeight} measures a real row afterwards and believes the measurement.
403
+ * The stylesheet declares the height explicitly so the two cannot drift apart in silence.
404
+ */
405
+ export const OUTLINE_ROW_HEIGHT = 24;
406
+
407
+ /** The rows the Outline last built, in display order. */
408
+ let _outlineRows: OutlineRow[] = [];
409
+ /** The `.layers-tree` element, kept between renders so a window can be computed for the next one. */
410
+ let _outlineList: HTMLElement | null = null;
411
+ /** The scroll watch that repaints the Outline as its scroller moves. */
412
+ let _outlineWatch: ListWindowWatch | null = null;
413
+ /** The Navigator repaint, captured per render so the scroll watch never holds a stale one. */
414
+ let _outlineRerender: (() => void) | null = null;
415
+
416
+ /** The height one row actually has; the declared constant until a row has been laid out. */
417
+ function outlineRowHeight(): number {
418
+ return measuredRowHeight(_outlineList, ".layer-row", OUTLINE_ROW_HEIGHT);
419
+ }
420
+
421
+ /** The model index of the row keyed `key`, or -1. */
422
+ function outlineIndexOfKey(key: string | undefined): number {
423
+ return key === undefined ? -1 : _outlineRows.findIndex((row) => row.key === key);
424
+ }
425
+
426
+ /** The model index of `path`, or -1. Used by the reveal, which is given a path and not a row. */
427
+ function outlineIndexOfPath(path: JxPath | null): number {
428
+ return path === null ? -1 : _outlineRows.findIndex((row) => pathsEqual(row.path, path));
429
+ }
430
+
431
+ /**
432
+ * The next `role="treeitem"` row from `index`, walking by `step`; -1 at the ends.
433
+ *
434
+ * The step is over the MODEL, so ↓ at the bottom of the window moves to the row below the window
435
+ * rather than stopping dead — which is what a DOM-indexed walk did the moment the tree windowed.
436
+ */
437
+ function outlineStep(index: number, step: 1 | -1): number {
438
+ for (let i = index + step; i >= 0 && i < _outlineRows.length; i += step) {
439
+ if (_outlineRows[i]!.item) {
440
+ return i;
441
+ }
442
+ }
443
+ return -1;
444
+ }
445
+
446
+ /**
447
+ * The visible rows' paths, in display order — the list a shift-range is a range OF.
448
+ *
449
+ * Read from the MODEL, not from the DOM. It used to walk `data-jx-path` off the rendered rows,
450
+ * which was exact while every row was rendered and became a silent lie the moment the tree
451
+ * windowed: `rangeSelection` degenerates to `[target]` when the anchor is absent from the list it
452
+ * is given (`tabs/selection.ts`), so shift-clicking with a scrolled-past anchor would have selected
453
+ * one row and said nothing. "Visible" still means what it always meant — a collapsed ancestor
454
+ * removes its descendants from the model too — it just no longer means "painted".
455
+ */
456
+ function visibleRowPaths(): JxPath[] {
457
+ const paths: JxPath[] = [];
458
+ for (const row of _outlineRows) {
459
+ if (row.item) {
460
+ paths.push(row.path);
461
+ }
462
+ }
463
+ return paths;
464
+ }
465
+
466
+ /**
467
+ * Scroll the model row at `index` into the window, and say whether the scroller moved.
468
+ *
469
+ * The repaint is the scroll watch's, not this function's — see `revealListRow`.
470
+ */
471
+ function revealOutlineRow(index: number): boolean {
472
+ return revealListRow(_outlineList, index, outlineRowHeight());
473
+ }
474
+
475
+ /**
476
+ * Repaint the Outline because its window changed.
477
+ *
478
+ * Deferred to a microtask so a scroll that arrives while lit is committing cannot re-enter the
479
+ * render that is producing the rows.
480
+ *
481
+ * **Never during a drag.** `panels/dnd.ts` holds the drop targets it registered on the rendered
482
+ * rows and shifts the rows either side of the pointer by a transform; re-rendering underneath it
483
+ * would drop both. A wheel-scroll mid-drag therefore keeps the window the drag started with, and
484
+ * the drop's own repaint restores it.
485
+ *
486
+ * **Never for a tree that is gone.** The stylebook draws a different panel into the same dock,
487
+ * whose scroller is the one this watch is still listening to.
488
+ */
489
+ function outlineWindowChanged(): void {
490
+ if (_outlineList?.isConnected !== true || _outlineList.querySelector(".layer-row.dragging")) {
491
+ return;
492
+ }
493
+ queueMicrotask(() => _outlineRerender?.());
494
+ }
495
+
496
+ /**
497
+ * Adopt the rendered tree: remember it, and keep it watching its scroller.
498
+ *
499
+ * Called from {@link afterTreeRender}, one microtask after the rows are committed — the first moment
500
+ * they exist AND the element can be resolved to whatever scrolls it (`#left-panel`, or the bottom
501
+ * dock). The first paint of a session therefore draws every row, because nothing can be measured
502
+ * before it; the watch's opening measurement is what asks for the second, windowed one.
503
+ *
504
+ * Idempotent by construction: `watchListWindow` hands back the same watch for the same element and
505
+ * scroller, so calling this after every render costs a comparison.
506
+ */
507
+ function adoptOutlineTree(tree: HTMLElement): void {
508
+ _outlineList = tree;
509
+ _outlineWatch = watchListWindow(_outlineWatch, tree, {
510
+ count: () => _outlineRows.length,
511
+ onChange: outlineWindowChanged,
512
+ rowHeight: outlineRowHeight,
513
+ });
514
+ }
515
+
516
+ /**
517
+ * The node an Outline row stands for, read back off the row.
518
+ *
519
+ * Rows carry their `JxPath` verbatim, as JSON, in `data-jx-path` — node IDENTITY in the DOM. That
520
+ * is a different thing from the neighbouring `data-path`, which is `pathKey`'s lossy `join("/")`
521
+ * string and exists as the drag-and-drop and roving-tabindex Map key; `["children", "0"]` and
522
+ * `["children", 0]` share a key and are different nodes, and a segment containing a slash has no
523
+ * key at all.
524
+ *
525
+ * Everything that has to point at a node from outside the render — shift-range multi-select,
526
+ * drag-reorder, canvas to Outline sync, a collaborator's cursor, a jump from Problems — needs the
527
+ * unambiguous one.
528
+ */
529
+ export function outlineRowPath(el: Element | null): JxPath | null {
530
+ const row = el?.closest<HTMLElement>("[data-jx-path]");
531
+ if (!row?.dataset.jxPath) {
532
+ return null;
533
+ }
534
+ try {
535
+ const parsed: unknown = JSON.parse(row.dataset.jxPath);
536
+ return Array.isArray(parsed) ? (parsed as JxPath) : null;
537
+ } catch {
538
+ return null;
539
+ }
540
+ }
541
+
542
+ /** Every row currently on screen, in visual order. */
543
+ function treeRows(tree: HTMLElement): HTMLElement[] {
544
+ return [...tree.querySelectorAll<HTMLElement>('.layer-row[role="treeitem"]')];
545
+ }
546
+
547
+ /**
548
+ * Roving tabindex over the rows: the selected row is the tab stop, else the first.
549
+ *
550
+ * Applied after every render ({@link afterTreeRender}) rather than baked into each row's template,
551
+ * because "is the selected row actually on screen" is only known once the collapsed ancestors have
552
+ * been skipped — a selection inside a collapsed branch would otherwise leave the tree with no tab
553
+ * stop at all.
554
+ */
555
+ export function applyTreeRovingTabindex(tree: HTMLElement): void {
556
+ const rows = treeRows(tree);
557
+ if (rows.length === 0) {
558
+ return;
559
+ }
560
+ // The PRIMARY row is the tab stop, not merely the first selected one: a multi-selection has one
561
+ // Keyboard position, and it is the row the author last pointed at. `data-primary` is stamped by
562
+ // The row template, so the two never have to re-derive the same answer.
563
+ const primary = rows.find((row) => row.dataset.primary !== undefined);
564
+ const selected = primary ?? rows.find((row) => row.getAttribute("aria-selected") === "true");
565
+ const stop = selected ?? rows[0]!;
566
+ for (const row of rows) {
567
+ row.tabIndex = row === stop ? 0 : -1;
568
+ }
569
+ }
570
+
571
+ /** Focus `row`, and make it the tree's single tab stop. */
572
+ function focusRow(tree: HTMLElement, row: HTMLElement | undefined): void {
573
+ if (!row) {
574
+ return;
575
+ }
576
+ for (const other of treeRows(tree)) {
577
+ other.tabIndex = other === row ? 0 : -1;
578
+ }
579
+ row.focus();
580
+ }
581
+
582
+ /** The rendered row for a model key, or null when the window does not currently hold it. */
583
+ function rowElementFor(tree: HTMLElement, key: string): HTMLElement | null {
584
+ return tree.querySelector<HTMLElement>(`.layer-row[data-path="${CSS.escape(key)}"]`);
585
+ }
586
+
587
+ /**
588
+ * The row a repaint should hand the keyboard to, once it exists.
589
+ *
590
+ * A keyboard jump can name a row the window does not hold — End on a 5 000-row page, or ← climbing
591
+ * to a parent far above the viewport. Scrolling to it is immediate; DRAWING it is the Navigator
592
+ * scheduler's business, and `ctx.rerender` is explicitly "not a synchronous re-render". So the
593
+ * request outlives the keystroke by exactly one render: {@link afterTreeRender} spends it, and
594
+ * spends it once — a focus request that survived its own repaint is stale, and stealing the
595
+ * keyboard later is worse than not having moved it.
596
+ */
597
+ let _pendingFocusKey: string | null = null;
598
+
599
+ /** Move the keyboard to the model row at `index`, bringing it into the window if it is outside one. */
600
+ function focusModelRow(tree: HTMLElement, index: number): void {
601
+ const row = _outlineRows[index];
602
+ if (!row) {
603
+ return;
604
+ }
605
+ const el = rowElementFor(tree, row.key);
606
+ if (el) {
607
+ focusRow(tree, el);
608
+ return;
609
+ }
610
+ if (revealOutlineRow(index)) {
611
+ _pendingFocusKey = row.key;
612
+ _outlineRerender?.();
613
+ }
614
+ }
615
+
616
+ /** Select the model row at `index`, so the canvas and the inspector follow the keyboard. */
617
+ function selectModelRow(index: number, gesture: { range?: boolean } = {}): void {
618
+ const row = _outlineRows[index];
619
+ if (row) {
620
+ applyRowSelection(row.path, gesture);
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Apply one row activation to the selection, honouring the two accumulate gestures (§6.5).
626
+ *
627
+ * - Plain: replace the selection with this path. **This is the only branch a keyboard walk or an
628
+ * unmodified click can reach, and it is byte-identical to what the Outline always did.**
629
+ * - Ctrl/Cmd: toggle this path in or out, leaving the rest alone.
630
+ * - Shift: the contiguous run of VISIBLE rows from the anchor to here.
631
+ *
632
+ * It took the tree element as its first parameter while a range was resolved by reading the rows
633
+ * out of the DOM. It no longer is ({@link visibleRowPaths}), and the parameter went with the read —
634
+ * a signature that still asked for the tree would suggest the answer depends on what is painted.
635
+ *
636
+ * @param {JxPath} path
637
+ * @param {{ additive?: boolean; range?: boolean }} gesture
638
+ */
639
+ export function applyRowSelection(
640
+ path: JxPath,
641
+ gesture: { additive?: boolean; range?: boolean } = {},
642
+ ): void {
643
+ const tab = activeTab.value;
644
+ if (!tab) {
645
+ return;
646
+ }
647
+ if (gesture.range) {
648
+ tab.session.selection = rangeSelection(
649
+ visibleRowPaths(),
650
+ selectionAnchor(tab.session.selection),
651
+ path,
652
+ );
653
+ return;
654
+ }
655
+ if (gesture.additive) {
656
+ tab.session.selection = toggleSelected(tab.session.selection, path);
657
+ return;
658
+ }
659
+ tab.session.selection = [path];
660
+ }
661
+
662
+ /**
663
+ * The ARIA tree keyboard model.
664
+ *
665
+ * ↑ / ↓ walk the visible rows and take the selection with them — in an outline, "focus follows
666
+ * selection" is what an author means by pressing Down. → expands a collapsed row and otherwise
667
+ * descends into it; ← collapses an expanded one and otherwise climbs to its parent. Enter and F2
668
+ * rename. Delete is deliberately absent: it is `selection.delete`'s chord, and the registry owns
669
+ * it.
670
+ *
671
+ * Every one of those moves is an index into {@link outlineRows}, not into the rendered rows. The DOM
672
+ * answer and the model answer agreed exactly while the tree drew everything; now the DOM holds a
673
+ * window, and asking it for "the row after this one" would stop the walk at the window's edge and
674
+ * make ← climb to whichever ancestor happened to be painted.
675
+ */
676
+ export function onTreeKeydown(
677
+ e: KeyboardEvent,
678
+ collapsed: Set<string>,
679
+ rerender: () => void,
680
+ ): void {
681
+ const tree = e.currentTarget as HTMLElement;
682
+ const row = (e.target as HTMLElement).closest<HTMLElement>('.layer-row[role="treeitem"]');
683
+ if (!row) {
684
+ return;
685
+ }
686
+ const key = row.dataset.path ?? "";
687
+ const index = outlineIndexOfKey(row.dataset.path);
688
+ const expanded = row.getAttribute("aria-expanded");
689
+
690
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
691
+ e.preventDefault();
692
+ const next = outlineStep(index, e.key === "ArrowDown" ? 1 : -1);
693
+ if (next >= 0) {
694
+ // Shift+Arrow extends the range, the same gesture shift-click makes and through the same
695
+ // Function. Without Shift the walk replaces the selection, exactly as it always has.
696
+ selectModelRow(next, { range: e.shiftKey });
697
+ focusModelRow(tree, next);
698
+ }
699
+ } else if (e.key === "ArrowRight") {
700
+ e.preventDefault();
701
+ if (expanded === "false") {
702
+ collapsed.delete(key);
703
+ rerender();
704
+ } else if (expanded === "true") {
705
+ focusModelRow(tree, outlineStep(index, 1));
706
+ }
707
+ } else if (e.key === "ArrowLeft") {
708
+ e.preventDefault();
709
+ if (expanded === "true") {
710
+ collapsed.add(key);
711
+ rerender();
712
+ } else {
713
+ // The model records each row's parent as it builds them, so the climb is exact rather than a
714
+ // Backwards scan for a smaller `aria-level` — which could only ever see painted rows.
715
+ const parent = _outlineRows[index]?.parent ?? -1;
716
+ if (parent >= 0) {
717
+ selectModelRow(parent);
718
+ focusModelRow(tree, parent);
719
+ }
720
+ }
721
+ } else if (e.key === "Home" || e.key === "End") {
722
+ e.preventDefault();
723
+ const target = e.key === "Home" ? outlineStep(-1, 1) : outlineStep(_outlineRows.length, -1);
724
+ if (target >= 0) {
725
+ selectModelRow(target);
726
+ focusModelRow(tree, target);
727
+ }
728
+ } else if (e.key === "Enter" || e.key === "F2") {
729
+ e.preventDefault();
730
+ selectModelRow(index);
731
+ if (row.dataset.path !== undefined) {
732
+ startLayerTitleEdit(pathFromKey(row.dataset.path), rerender);
733
+ }
734
+ }
735
+ }
736
+
37
737
  /**
38
738
  * Start inline title editing on a layer row.
39
739
  *
@@ -44,6 +744,13 @@ export function startLayerTitleEdit(path: JxPath, rerender: () => void) {
44
744
  const key = pathKey(path);
45
745
  const row = document.querySelector(`.layer-row[data-path="${key}"]`);
46
746
  if (!row) {
747
+ // Renaming through a command (⌘↵, the palette, the block bar) can name a row the window does
748
+ // Not hold. Scroll to it and repaint; the row exists on the next pass, and the caller's own
749
+ // Chord is the one gesture in the app where re-pressing it is obvious.
750
+ const index = outlineIndexOfPath(path);
751
+ if (index >= 0 && revealOutlineRow(index)) {
752
+ rerender();
753
+ }
47
754
  return;
48
755
  }
49
756
  const label = row.querySelector(".layer-label") as HTMLElement | null;
@@ -65,7 +772,7 @@ export function startLayerTitleEdit(path: JxPath, rerender: () => void) {
65
772
  input.className = "layer-title-input";
66
773
  input.value = node.$title || "";
67
774
  const { $title: _, ...nodeWithoutTitle } = node;
68
- input.placeholder = nodeLabel(nodeWithoutTitle);
775
+ input.placeholder = outlineLabel(nodeWithoutTitle) || displayTagName(node.tagName) || "div";
69
776
  label.after(input);
70
777
  input.focus();
71
778
  input.select();
@@ -107,352 +814,345 @@ export function startLayerTitleEdit(path: JxPath, rerender: () => void) {
107
814
  }
108
815
 
109
816
  /**
110
- * @param {{ navigateToComponent: (path: string) => void; rerender: () => void }} ctx
111
- * @returns {import("lit-html").TemplateResult}
817
+ * Pass one: the rows the Outline WOULD draw, in display order.
818
+ *
819
+ * Every line here is a decision about VISIBILITY — a collapsed ancestor, an inline element the
820
+ * canvas edits as text rather than as a block, the root the content mode does not own — and no line
821
+ * here builds a template. That split is the whole saving: the walk is O(nodes) and costs a few
822
+ * comparisons per node, while the half it defers mounts an `sp-icon` per row and an action cluster
823
+ * on the selected one. Only the rows in the window ever pay it.
824
+ *
825
+ * @param {JxMutableNode} doc @param {string} mode @param {Set<string>} collapsed
112
826
  */
113
- export function renderLayersTemplate(ctx: {
114
- navigateToComponent: (path: string) => void;
115
- rerender: () => void;
116
- }) {
117
- const tab = activeTab.value;
118
-
119
- for (const fn of view.dndCleanups) {
120
- fn();
121
- }
122
- view.dndCleanups = [];
123
-
124
- const rows = flattenTree(tab!.doc.document);
125
- const collapsed = (view._layersCollapsed ||= new Set());
126
-
127
- const layerRows: { key: string; tpl: TemplateResult }[] = [];
827
+ function buildOutlineRows(doc: JxMutableNode, mode: string, collapsed: Set<string>): OutlineRow[] {
828
+ const rows: OutlineRow[] = [];
829
+ /** Indices of the rows still open above the row being decided; the last of them is its parent. */
830
+ const ancestors: number[] = [];
128
831
  // Rows arrive in pre-order, so "is any ancestor collapsed?" is a running depth comparison rather
129
832
  // Than a per-row walk back up the path. The old form did `path.slice(0, d)` + `pathKey(sub)` for
130
833
  // Every ancestor of every row — O(depth) array copies and string joins per row, on every render.
131
834
  let collapsedAtDepth: number | null = null;
132
- for (const { node, path, depth, nodeType } of rows) {
835
+ for (const { node, path, depth, nodeType } of flattenTree(doc)) {
133
836
  if (collapsedAtDepth !== null && depth > collapsedAtDepth) {
134
837
  continue;
135
838
  }
136
839
  // Back at or above the collapsed ancestor's depth: it no longer covers this row.
137
840
  collapsedAtDepth = null;
138
- const rowKey = pathKey(path);
139
- if (collapsed.has(rowKey)) {
841
+ const key = pathKey(path);
842
+ if (collapsed.has(key)) {
140
843
  collapsedAtDepth = depth;
141
844
  }
142
845
 
143
- if (tab?.doc.mode === "content" && path.length === 0) {
144
- continue;
145
- }
146
-
147
- if (nodeType === "text") {
148
- const textPreview = String(node).length > 40 ? `${String(node).slice(0, 40)}…` : String(node);
149
- layerRows.push({
150
- key: rowKey,
151
- tpl: html`
152
- <div
153
- class="layer-row"
154
- style="padding-left:${depth * 16 + 8}px; opacity: 0.6; font-style: italic;"
155
- >
156
- <span
157
- class="layer-tag"
158
- style="background: var(--spectrum-gray-500, #64748b); font-size: 0.65rem;"
159
- >text</span
160
- >
161
- <span class="layer-label">${textPreview}</span>
162
- </div>
163
- `,
164
- });
165
- continue;
166
- }
167
-
168
- // After text-node skip, node is guaranteed to be JxMutableNode (not a primitive)
169
- if (typeof node !== "object" || node === null) {
846
+ if (mode === "content" && path.length === 0) {
170
847
  continue;
171
848
  }
172
- const jxNode: JxMutableNode = node as JxMutableNode;
173
849
 
174
- if (path.length >= 2 && nodeType === "element") {
175
- const pPath = parentElementPath(path);
176
- const parentNode = pPath ? getNodeAtPath(tab!.doc.document, pPath) : null;
177
- if (parentNode && isInlineElement(jxNode, parentNode)) {
850
+ const isText = nodeType === "text";
851
+ if (!isText) {
852
+ // After the text-node branch, a row's node is a JxMutableNode or it is not a row at all.
853
+ if (typeof node !== "object" || node === null) {
178
854
  continue;
179
855
  }
856
+ if (path.length >= 2 && nodeType === "element") {
857
+ const parentPath = parentElementPath(path);
858
+ const parentNode = parentPath ? getNodeAtPath(doc, parentPath) : null;
859
+ if (parentNode && isInlineElement(node, parentNode)) {
860
+ continue;
861
+ }
862
+ }
180
863
  }
181
864
 
182
- const key = rowKey;
183
- const isSelected = pathsEqual(path, tab!.session.selection);
184
- const hasChildren = Array.isArray(jxNode.children) && jxNode.children.length > 0;
185
- const hasMapChildren =
186
- jxNode.children &&
187
- typeof jxNode.children === "object" &&
188
- (jxNode.children as unknown as Record<string, unknown>).$prototype === "Array";
189
- const hasCases =
190
- jxNode.$switch &&
191
- jxNode.cases &&
192
- typeof jxNode.cases === "object" &&
193
- Object.keys(jxNode.cases).length > 0;
194
- const isExpandable =
195
- hasChildren || hasMapChildren || hasCases || (nodeType === "map" && jxNode.map);
196
- // Array nodes can't accept dropped children (their content is the single map template), so they
197
- // Block the make-child drop instruction like void elements do.
198
- const isVoidEl =
199
- VOID_ELEMENTS.has((jxNode.tagName || "div").toLowerCase()) || nodeType === "map";
200
-
201
- /** @type {string} */
202
- let badgeClass;
203
- /** @type {string | number} */
204
- let badgeText;
205
- /** @type {string | undefined} */
206
- let badgeTitle;
207
- if (nodeType === "map") {
208
- badgeClass = "layer-tag map-tag";
209
- badgeText = "↻";
210
- badgeTitle = "Repeater (mapped array)";
211
- } else if (nodeType === "case" || nodeType === "case-ref") {
212
- badgeClass = "layer-tag case-tag";
213
- badgeText = path.at(-1);
214
- badgeTitle = `$switch case: ${path.at(-1)}`;
215
- } else if (jxNode.$switch) {
216
- badgeClass = "layer-tag switch-tag";
217
- badgeText = "⇄";
218
- badgeTitle = "$switch";
219
- } else if (jxNode.tagName === "slot") {
220
- const slotName = jxNode.attributes?.name;
221
- badgeClass = "layer-tag slot-tag";
222
- badgeText = "▣";
223
- badgeTitle =
224
- typeof slotName === "string" && slotName.trim()
225
- ? `Slot "${slotName.trim()}"`
226
- : "Default slot";
227
- } else {
228
- badgeClass = "layer-tag";
229
- badgeText = jxNode.tagName || "div";
230
- badgeTitle = undefined;
865
+ while (ancestors.length > 0 && rows[ancestors.at(-1)!]!.depth >= depth) {
866
+ ancestors.pop();
231
867
  }
868
+ rows.push({
869
+ depth,
870
+ item: !isText,
871
+ key,
872
+ node,
873
+ nodeType,
874
+ parent: ancestors.at(-1) ?? -1,
875
+ path,
876
+ posInSet: 0,
877
+ setSize: 0,
878
+ });
879
+ ancestors.push(rows.length - 1);
880
+ }
881
+ return numberOutlineSets(rows);
882
+ }
232
883
 
233
- /** @type {string} */
234
- let labelText;
235
- /** @type {boolean} */
236
- let labelItalic;
237
- if (nodeType === "case-ref") {
238
- labelText = jxNode.$ref || "external";
239
- labelItalic = true;
240
- } else {
241
- labelText = nodeLabel(jxNode);
242
- labelItalic = false;
884
+ /**
885
+ * Stamp each row's position among its siblings — the two attributes a WINDOWED tree cannot omit.
886
+ *
887
+ * A tree that draws every row lets the assistive technology count them itself. A windowed one hands
888
+ * it eleven rows out of five thousand, and without `aria-posinset`/`aria-setsize` it will read
889
+ * "item 3 of 11" for a document with hundreds of sections: not a missing nicety, a false statement.
890
+ * Only `role="treeitem"` rows are counted — a text-node row is drawn, but it is not in the set.
891
+ */
892
+ function numberOutlineSets(rows: OutlineRow[]): OutlineRow[] {
893
+ const totals = new Map<number, number>();
894
+ for (const row of rows) {
895
+ if (row.item) {
896
+ totals.set(row.parent, (totals.get(row.parent) ?? 0) + 1);
243
897
  }
898
+ }
899
+ const seen = new Map<number, number>();
900
+ for (const row of rows) {
901
+ if (!row.item) {
902
+ continue;
903
+ }
904
+ const position = (seen.get(row.parent) ?? 0) + 1;
905
+ seen.set(row.parent, position);
906
+ row.posInSet = position;
907
+ row.setSize = totals.get(row.parent) ?? position;
908
+ }
909
+ return rows;
910
+ }
244
911
 
245
- // Array (repeater) nodes are first-class structural nodes — movable/draggable/deletable like
246
- // Elements. Both sit at a numeric child index; templates (path tail "map") and case nodes do
247
- // Not, so they stay selectable/editable but not structurally manipulable.
248
- const isStructural =
249
- (nodeType === "element" || nodeType === "map") && typeof childIndex(path) === "number";
250
- const isRoot = tab?.doc.mode === "content" ? path.length === 0 : path.length < 2;
251
- const idx = isStructural ? (childIndex(path) as number) : 0;
252
- const parentPath = isStructural && !isRoot ? (parentElementPath(path) as JxPath) : null;
253
- const parentNode = parentPath ? getNodeAtPath(tab!.doc.document, parentPath) : null;
254
- const siblingCount = childList(parentNode).length;
255
- const canMoveUp = isStructural && !isRoot && idx > 0;
256
- const canMoveDown = isStructural && !isRoot && idx < siblingCount - 1;
257
- const prevSibling = canMoveUp && parentNode ? childList(parentNode)[idx - 1] : null;
258
- const prevIsContainer = (() => {
259
- if (!prevSibling || typeof prevSibling !== "object") {
260
- return false;
261
- }
262
- if (VOID_ELEMENTS.has((prevSibling.tagName || "div").toLowerCase())) {
263
- return false;
264
- }
265
- const ch = prevSibling.children;
266
- if (!ch) {
267
- return false;
912
+ /** A text node's row: drawn, and deliberately not a tree item — there is nothing to do to it. */
913
+ function textRowTemplate(row: OutlineRow): TemplateResult {
914
+ const text = String(row.node);
915
+ const preview = text.length > 40 ? `${text.slice(0, 40)}…` : text;
916
+ return html`
917
+ <div
918
+ class="layer-row"
919
+ data-jx-path=${JSON.stringify(row.path)}
920
+ style="padding-left:${indentWidth(row.depth) + 8}px; opacity: 0.6; font-style: italic;"
921
+ >
922
+ <span
923
+ class="layer-tag"
924
+ style="background: var(--spectrum-gray-500, #64748b); font-size: 0.65rem;"
925
+ >text</span
926
+ >
927
+ <span class="layer-label">${preview}</span>
928
+ </div>
929
+ `;
930
+ }
931
+
932
+ /**
933
+ * Pass two: one row, drawn.
934
+ *
935
+ * Called for the rows in the window and for no others, which is why it takes the document facts it
936
+ * needs (`selection`, `mode`) as arguments instead of reading `activeTab` for each row.
937
+ */
938
+ function outlineRowTemplate(
939
+ row: OutlineRow,
940
+ doc: { selection: JxPath[]; mode: string },
941
+ collapsed: Set<string>,
942
+ registry: CommandRegistry,
943
+ ctx: { rerender: () => void },
944
+ ): TemplateResult {
945
+ if (!row.item) {
946
+ return textRowTemplate(row);
947
+ }
948
+ const { depth, key, nodeType, path } = row;
949
+ const jxNode = row.node as JxMutableNode;
950
+
951
+ // Every member of the set draws selected; the PRIMARY additionally carries the roving tab stop,
952
+ // So a batch has one keyboard position rather than six.
953
+ const isSelected = isPathSelected(doc.selection, path);
954
+ const isPrimary = pathsEqual(path, primarySelection(doc.selection));
955
+ const hasChildren = Array.isArray(jxNode.children) && jxNode.children.length > 0;
956
+ const hasMapChildren =
957
+ jxNode.children &&
958
+ typeof jxNode.children === "object" &&
959
+ (jxNode.children as unknown as Record<string, unknown>).$prototype === "Array";
960
+ const hasCases =
961
+ jxNode.$switch &&
962
+ jxNode.cases &&
963
+ typeof jxNode.cases === "object" &&
964
+ Object.keys(jxNode.cases).length > 0;
965
+ const isExpandable =
966
+ hasChildren || hasMapChildren || hasCases || (nodeType === "map" && jxNode.map);
967
+ // Array nodes can't accept dropped children (their content is the single map template), so they
968
+ // Block the make-child drop instruction like void elements do.
969
+ const isVoidEl =
970
+ VOID_ELEMENTS.has((displayTagName(jxNode.tagName) || "div").toLowerCase()) ||
971
+ nodeType === "map";
972
+
973
+ /** @type {string} */
974
+ let badgeClass;
975
+ /** @type {string | number} */
976
+ let badgeText;
977
+ /** @type {string | undefined} */
978
+ let badgeTitle;
979
+ if (nodeType === "map") {
980
+ badgeClass = "layer-tag map-tag";
981
+ badgeText = "↻";
982
+ badgeTitle = "Repeating list — one copy per item";
983
+ } else if (nodeType === "case" || nodeType === "case-ref") {
984
+ badgeClass = "layer-tag case-tag";
985
+ badgeText = path.at(-1);
986
+ badgeTitle = `Condition case: ${path.at(-1)}`;
987
+ } else if (jxNode.$switch) {
988
+ badgeClass = "layer-tag switch-tag";
989
+ badgeText = "⇄";
990
+ badgeTitle = "Condition";
991
+ } else if (jxNode.tagName === "slot") {
992
+ const slotName = jxNode.attributes?.name;
993
+ badgeClass = "layer-tag slot-tag";
994
+ badgeText = "▣";
995
+ badgeTitle =
996
+ typeof slotName === "string" && slotName.trim()
997
+ ? `Slot "${slotName.trim()}"`
998
+ : "Default slot";
999
+ } else {
1000
+ badgeClass = "layer-tag";
1001
+ /* The ROW BADGE, which is the one place the tag is shown as itself rather than folded into a
1002
+ label — so it is the one that rendered `[object Object]` for a chosen tag after the other
1003
+ reads were fixed. `outlineLabel` never returns a tag (it prefers a title, an id, text, a
1004
+ class, a landmark), which is why fixing `nodeLabel` did not reach here. */
1005
+ badgeText = displayTagName(jxNode.tagName) || "div";
1006
+ badgeTitle = isTagExpression(jxNode.tagName)
1007
+ ? `Tag chosen when the element is created: ${tagNameCandidates(jxNode.tagName).join(" or ")}`
1008
+ : undefined;
1009
+ }
1010
+
1011
+ /** @type {string} */
1012
+ let labelText;
1013
+ /** @type {boolean} */
1014
+ let labelItalic;
1015
+ if (nodeType === "case-ref") {
1016
+ labelText = jxNode.$ref || "external";
1017
+ labelItalic = true;
1018
+ } else {
1019
+ labelText = outlineLabel(jxNode);
1020
+ labelItalic = false;
1021
+ }
1022
+
1023
+ // Array (repeater) nodes are first-class structural nodes — movable/draggable/deletable like
1024
+ // Elements. Both sit at a numeric child index; templates (path tail "map") and case nodes do
1025
+ // Not, so they stay selectable/editable but not structurally manipulable.
1026
+ const isStructural =
1027
+ (nodeType === "element" || nodeType === "map") && typeof childIndex(path) === "number";
1028
+ const isRoot = doc.mode === "content" ? path.length === 0 : path.length < 2;
1029
+
1030
+ return html`
1031
+ <div
1032
+ class=${classMap({ "layer-row": true, selected: isSelected })}
1033
+ role="treeitem"
1034
+ aria-level=${depth + 1}
1035
+ aria-posinset=${row.posInSet}
1036
+ aria-setsize=${row.setSize}
1037
+ aria-selected=${isSelected ? "true" : "false"}
1038
+ aria-expanded=${isExpandable ? (collapsed.has(key) ? "false" : "true") : nothing}
1039
+ tabindex=${isPrimary ? "0" : "-1"}
1040
+ data-primary=${isPrimary ? "" : nothing}
1041
+ data-jx-path=${JSON.stringify(path)}
1042
+ data-path=${key}
1043
+ data-dnd-row=${isStructural ? key : nothing}
1044
+ data-dnd-depth=${isStructural ? depth : nothing}
1045
+ data-dnd-void=${isStructural && isVoidEl ? "" : nothing}
1046
+ data-dnd-expanded=${isStructural && isExpandable && !collapsed.has(key) ? "" : nothing}
1047
+ @click=${(e: MouseEvent) => {
1048
+ applyRowSelection(path, { additive: e.ctrlKey || e.metaKey, range: e.shiftKey });
1049
+ panToElement(path);
1050
+ }}
1051
+ @dblclick=${
1052
+ isStructural
1053
+ ? (e: MouseEvent) => {
1054
+ e.stopPropagation();
1055
+ startLayerTitleEdit(path, ctx.rerender);
1056
+ }
1057
+ : nothing
268
1058
  }
269
- if (
270
- typeof ch === "object" &&
271
- (ch as unknown as Record<string, unknown>).$prototype === "Array"
272
- ) {
273
- return true;
1059
+ @contextmenu=${
1060
+ isStructural
1061
+ ? (e: MouseEvent) =>
1062
+ showContextMenu(e, path, {
1063
+ rerender: ctx.rerender,
1064
+ })
1065
+ : nothing
274
1066
  }
275
- if (!Array.isArray(ch)) {
276
- return false;
1067
+ >
1068
+ <span class="layer-indent" style="width:${indentWidth(depth)}px"></span>
1069
+ <span class="layer-toggle"
1070
+ >${
1071
+ isExpandable
1072
+ ? html`
1073
+ ${
1074
+ collapsed.has(key)
1075
+ ? html`<sp-icon-chevron-right></sp-icon-chevron-right>`
1076
+ : html`<sp-icon-chevron-down></sp-icon-chevron-down>`
1077
+ }
1078
+ `
1079
+ : nothing
1080
+ }</span
1081
+ >
1082
+ <span class=${badgeClass} title=${ifDefined(badgeTitle ?? undefined)}>${badgeText}</span>
1083
+ <span class="layer-label" style=${labelItalic ? "font-style:italic" : nothing}
1084
+ >${labelText}</span
1085
+ >
1086
+ ${
1087
+ isStructural && !isRoot
1088
+ ? html`<span class="layer-drag-handle" title="Drag to reorder">⠿</span>`
1089
+ : nothing
277
1090
  }
278
- if (ch.length === 0) {
279
- return true;
1091
+ ${
1092
+ // The selected row's cluster is declared here so it survives every re-render; every
1093
+ // Other structural row keeps an EMPTY slot, which the pointer fills (mountHoverActions)
1094
+ // And empties again. The two branches are different templates, so lit swaps the DOM
1095
+ // Between them and a hover cluster can never outlive the row becoming selected.
1096
+ isStructural && !isRoot
1097
+ ? isSelected
1098
+ ? html`<span class="layer-actions">${renderRowCommands(registry, path)}</span>`
1099
+ : html`<span class="layer-actions"></span>`
1100
+ : nothing
280
1101
  }
281
- return ch.some(
282
- (c) => typeof c === "object" && c !== null && !isInlineElement(c, prevSibling),
283
- );
284
- })();
285
- const canMoveIn = isStructural && !isRoot && prevIsContainer;
286
- const grandparentPath =
287
- isStructural && parentPath && parentPath.length >= 2
288
- ? (parentElementPath(parentPath) as JxPath)
289
- : null;
290
- const canMoveOut = isStructural && !isRoot && Boolean(grandparentPath);
291
-
292
- layerRows.push({
293
- key,
294
- tpl: html`
295
- <div
296
- class=${classMap({ "layer-row": true, selected: isSelected })}
297
- data-path=${key}
298
- data-dnd-row=${isStructural ? key : nothing}
299
- data-dnd-depth=${isStructural ? depth : nothing}
300
- data-dnd-void=${isStructural && isVoidEl ? "" : nothing}
301
- data-dnd-expanded=${isStructural && isExpandable && !collapsed.has(key) ? "" : nothing}
302
- @click=${() => {
303
- activeTab.value!.session.selection = path;
304
- panToElement(path);
305
- }}
306
- @dblclick=${
307
- isStructural
308
- ? (e: MouseEvent) => {
309
- e.stopPropagation();
310
- startLayerTitleEdit(path, ctx.rerender);
311
- }
312
- : nothing
313
- }
314
- @contextmenu=${
315
- isStructural
316
- ? (e: MouseEvent) =>
317
- showContextMenu(e, path, {
318
- onEditComponent: ctx.navigateToComponent,
319
- rerender: ctx.rerender,
320
- })
321
- : nothing
322
- }
323
- >
324
- <span class="layer-indent" style="width:${depth * 16}px"></span>
325
- <span class="layer-toggle"
326
- >${
327
- isExpandable
328
- ? html`
329
- ${
330
- collapsed.has(key)
331
- ? html`<sp-icon-chevron-right></sp-icon-chevron-right>`
332
- : html`<sp-icon-chevron-down></sp-icon-chevron-down>`
333
- }
334
- `
335
- : nothing
336
- }</span
337
- >
338
- <span class=${badgeClass} title=${ifDefined(badgeTitle ?? undefined)}>${badgeText}</span>
339
- <span class="layer-label" style=${labelItalic ? "font-style:italic" : nothing}
340
- >${labelText}</span
341
- >
342
- ${
343
- isStructural && !isRoot
344
- ? html`<span class="layer-drag-handle" title="Drag to reorder">⠿</span>`
345
- : nothing
346
- }
347
- ${
348
- isStructural && !isRoot && isSelected
349
- ? html`
350
- <span class="layer-actions">
351
- ${
352
- canMoveUp
353
- ? html`<sp-action-button
354
- quiet
355
- size="xs"
356
- title="Move up"
357
- @click=${(e: MouseEvent) => {
358
- e.stopPropagation();
359
- (e.currentTarget as HTMLElement).blur();
360
- const pp = parentPath as JxPath;
361
- transactDoc(activeTab.value!, (t) =>
362
- mutateMoveNode(t, path, pp, idx - 1),
363
- );
364
- }}
365
- >
366
- <sp-icon-arrow-up slot="icon"></sp-icon-arrow-up>
367
- </sp-action-button>`
368
- : nothing
369
- }
370
- ${
371
- canMoveDown
372
- ? html`<sp-action-button
373
- quiet
374
- size="xs"
375
- title="Move down"
376
- @click=${(e: MouseEvent) => {
377
- e.stopPropagation();
378
- (e.currentTarget as HTMLElement).blur();
379
- const pp = parentPath as JxPath;
380
- transactDoc(activeTab.value!, (t) =>
381
- mutateMoveNode(t, path, pp, idx + 2),
382
- );
383
- }}
384
- >
385
- <sp-icon-arrow-down slot="icon"></sp-icon-arrow-down>
386
- </sp-action-button>`
387
- : nothing
388
- }
389
- ${
390
- canMoveIn
391
- ? html`<sp-action-button
392
- quiet
393
- size="xs"
394
- title="Move into previous sibling"
395
- @click=${(e: MouseEvent) => {
396
- e.stopPropagation();
397
- (e.currentTarget as HTMLElement).blur();
398
- const pp = parentPath as JxPath;
399
- const prevPath = [...pp, "children", idx - 1];
400
- const prev = getNodeAtPath(activeTab.value!.doc.document, prevPath);
401
- const len = childList(prev).length;
402
- transactDoc(activeTab.value!, (t) =>
403
- mutateMoveNode(t, path, prevPath, len),
404
- );
405
- }}
406
- >
407
- <sp-icon-arrow-right slot="icon"></sp-icon-arrow-right>
408
- </sp-action-button>`
409
- : nothing
410
- }
411
- ${
412
- canMoveOut
413
- ? html`<sp-action-button
414
- quiet
415
- size="xs"
416
- title="Move out of parent"
417
- @click=${(e: MouseEvent) => {
418
- e.stopPropagation();
419
- (e.currentTarget as HTMLElement).blur();
420
- const gp = grandparentPath as JxPath;
421
- const parentIdx = childIndex(parentPath!) as number;
422
- transactDoc(activeTab.value!, (t) =>
423
- mutateMoveNode(t, path, gp, parentIdx + 1),
424
- );
425
- }}
426
- >
427
- <sp-icon-arrow-left slot="icon"></sp-icon-arrow-left>
428
- </sp-action-button>`
429
- : nothing
430
- }
431
- <sp-action-button
432
- quiet
433
- size="xs"
434
- class="layer-delete"
435
- title="Delete"
436
- @click=${(e: MouseEvent) => {
437
- e.stopPropagation();
438
- transactDoc(activeTab.value!, (t) => mutateRemoveNode(t, path));
439
- }}
440
- >
441
- <sp-icon-close slot="icon"></sp-icon-close>
442
- </sp-action-button>
443
- </span>
444
- `
445
- : nothing
446
- }
447
- </div>
448
- `,
449
- });
1102
+ </div>
1103
+ `;
1104
+ }
1105
+
1106
+ /**
1107
+ * @param {{ navigateToComponent: (path: string) => void; rerender: () => void }} ctx
1108
+ * @returns {import("lit-html").TemplateResult}
1109
+ */
1110
+ export function renderLayersTemplate(ctx: {
1111
+ navigateToComponent: (path: string) => void;
1112
+ rerender: () => void;
1113
+ }) {
1114
+ const tab = activeTab.value;
1115
+ // The scroll watch outlives this call and must never repaint through a closure from an earlier
1116
+ // One — the Navigator's scheduler is the only thing that knows how to draw the Outline.
1117
+ _outlineRerender = ctx.rerender;
1118
+
1119
+ for (const fn of view.dndCleanups) {
1120
+ fn();
450
1121
  }
1122
+ view.dndCleanups = [];
1123
+
1124
+ view._layersCollapsed ||= new Set();
1125
+ const collapsed = view._layersCollapsed;
1126
+ const registry = selectionCommandRegistry();
1127
+
1128
+ _outlineRows = buildOutlineRows(tab!.doc.document, tab?.doc.mode ?? "", collapsed);
1129
+ // The window is computed from the PREVIOUS render's tree element, because that is the only one
1130
+ // That exists while this template is being built. On the first paint of a session there is none
1131
+ // And `listWindow` answers "all of them" — which is exactly what the Outline did before it
1132
+ // Windowed, and what the panel's `afterRender` then measures in order to ask for a second pass.
1133
+ const range = listWindow(_outlineList, {
1134
+ count: _outlineRows.length,
1135
+ rowHeight: outlineRowHeight(),
1136
+ });
1137
+ const doc = { mode: tab?.doc.mode ?? "", selection: tab!.session.selection };
1138
+ const layerRows = _outlineRows
1139
+ .slice(range.start, range.end)
1140
+ .map((row) => ({ key: row.key, tpl: outlineRowTemplate(row, doc, collapsed, registry, ctx) }));
451
1141
 
452
1142
  return html`
453
1143
  <div class="layers-container" style="position:relative">
454
1144
  <div
455
1145
  class="layers-tree"
1146
+ role="tree"
1147
+ aria-label="Document outline"
1148
+ ${ref((el) => {
1149
+ if (el) {
1150
+ afterTreeRender(el as HTMLElement);
1151
+ }
1152
+ })}
1153
+ @keydown=${(e: KeyboardEvent) => onTreeKeydown(e, collapsed, ctx.rerender)}
1154
+ @mouseover=${onTreeHover}
1155
+ @mouseleave=${clearHoverActions}
456
1156
  @click=${(e: MouseEvent) => {
457
1157
  const toggle = (e.target as HTMLElement).closest(".layer-toggle");
458
1158
  if (!toggle) {
@@ -475,12 +1175,90 @@ export function renderLayersTemplate(ctx: {
475
1175
  ctx.rerender();
476
1176
  }}
477
1177
  >
478
- ${repeat(
479
- layerRows,
480
- (r) => r.key,
481
- (r) => r.tpl,
482
- )}
1178
+ ${
1179
+ _outlineRows.length === 0
1180
+ ? renderEmptyState({
1181
+ actions: [
1182
+ {
1183
+ label: "Add an element",
1184
+ run: () => {
1185
+ // `"insert"`, not `"blocks"`. The panel was renamed in P3.1 and this call
1186
+ // Kept the old id for three phases, so the one action an empty page offers
1187
+ // Landed the Navigator on "No Navigator panel is registered as blocks".
1188
+ // `setActivityTab` takes a `NavigatorPanelId` now, so this cannot recur.
1189
+ setActivityTab("insert");
1190
+ },
1191
+ },
1192
+ ],
1193
+ message: "This page is empty. Everything you add to it is listed here, in order.",
1194
+ })
1195
+ : html`
1196
+ <!-- The rows the window left above and below, as the height they would have
1197
+ occupied: the scrollbar stays the length of the whole document, and
1198
+ aria-hidden keeps two empty spacers out of a tree that owns treeitems. -->
1199
+ <div style="height:${range.padTop}px" aria-hidden="true"></div>
1200
+ ${repeat(
1201
+ layerRows,
1202
+ (r) => r.key,
1203
+ (r) => r.tpl,
1204
+ )}
1205
+ <div style="height:${range.padBottom}px" aria-hidden="true"></div>
1206
+ `
1207
+ }
483
1208
  </div>
484
1209
  </div>
485
1210
  `;
486
1211
  }
1212
+
1213
+ /**
1214
+ * Keep the selected row on screen after a repaint.
1215
+ *
1216
+ * Two cases now, where there used to be one. If the row is drawn, it scrolls itself into view, as
1217
+ * it always has. If it is NOT — the canvas selected a node three thousand rows down, a jump from
1218
+ * Problems, a collaborator's edit — then the row the author is meant to see is precisely the one
1219
+ * with no element to call `scrollIntoView` on, and the reveal has to be arithmetic: scroll to where
1220
+ * the model says the row is, and let the scroll watch draw it. Silently doing nothing would be the
1221
+ * windowing bug that looks like a selection bug.
1222
+ */
1223
+ function revealSelectedRow(host: HTMLElement): void {
1224
+ const drawn = host.querySelector(".layer-row.selected");
1225
+ if (drawn) {
1226
+ drawn.scrollIntoView({ behavior: "smooth", block: "nearest" });
1227
+ return;
1228
+ }
1229
+ revealOutlineRow(outlineIndexOfPath(primarySelection(activeTab.value?.session.selection)));
1230
+ }
1231
+
1232
+ /**
1233
+ * Contribute the Outline panel.
1234
+ *
1235
+ * `level: "document"` — it writes the open document's tree (reorder, rename, delete, duplicate).
1236
+ * "Outline" rather than "Layers" is §3.2 ③'s name for it; the id stays `layers` because that is
1237
+ * what `view.setActivity` and 26 screenshot steps address it by, and an id is not a label.
1238
+ */
1239
+ export function registerLayersPanel(): void {
1240
+ registerPanel({
1241
+ id: "layers",
1242
+ title: "Outline",
1243
+ level: "document",
1244
+ dock: "navigator",
1245
+ icon: "sp-icon-layers",
1246
+ requiresDocument: "Open a page to see the elements it is built from.",
1247
+ render: (ctx) =>
1248
+ ctx.deps.getCanvasMode() === "stylebook"
1249
+ ? renderStylebookLayersTemplate({
1250
+ selectStylebookTag,
1251
+ stylebookMeta,
1252
+ } as Parameters<typeof renderStylebookLayersTemplate>[0])
1253
+ : renderLayersTemplate({
1254
+ navigateToComponent: ctx.deps.navigateToComponent,
1255
+ rerender: ctx.rerender,
1256
+ }),
1257
+ afterRender: (ctx, host) => {
1258
+ if (ctx.deps.getCanvasMode() !== "stylebook") {
1259
+ ctx.deps.registerLayersDnD();
1260
+ }
1261
+ revealSelectedRow(host);
1262
+ },
1263
+ });
1264
+ }