@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,43 +1,72 @@
1
1
  /// <reference lib="dom" />
2
- /** Properties panel — inspector for element attributes, component props, media, and frontmatter. */
2
+ /**
3
+ * The Content tab — everything about WHAT this element is and says.
4
+ *
5
+ * Plan §6.5 re-split the inspector by task. Content is the element itself (tag, id, class, text),
6
+ * its HTML attributes, its link target, its custom attributes, its component props and its media.
7
+ * Three things left, and each left for a place that already had a better claim on it:
8
+ *
9
+ * - **Repeater · Switch · Observed Attributes · CSS Properties · CSS Parts → the Logic tab.** Wiring
10
+ * a `$switch` and wiring a click handler are the same task (`panels/events-panel.ts`).
11
+ * - **The Page section → the Document Header card.** `panels/head-panel.ts` owns the one layout
12
+ * picker; this panel drew a second one that could disagree with it.
13
+ * - **Media breakpoint DEFINITIONS → Project Settings › Contexts** (P4). Nothing remains here: the
14
+ * only reason adding a breakpoint used to cost you your element selection.
15
+ *
16
+ * What is left is one accordion of rows, all of which go through `ui/field-row.ts`.
17
+ */
3
18
 
4
19
  import { html, nothing } from "lit-html";
20
+ import type { TemplateResult } from "lit-html";
5
21
  import { live } from "lit-html/directives/live.js";
6
- import { debouncedStyleCommit, getNodeAtPath, projectState, renderOnly } from "../store";
7
- import { isRef } from "@jxsuite/schema/guards";
22
+ import { debouncedStyleCommit, getNodeAtPath, renderOnly } from "../store";
23
+ import { displayTagName, isRef, isTagExpression } from "@jxsuite/schema/guards";
8
24
  import type { DirEntry, JsonValue } from "../types";
9
25
  import {
10
- mutateAddSwitchCase,
11
- mutateRemoveSwitchCase,
12
- mutateRenameSwitchCase,
13
26
  mutateUpdateAttribute,
14
- mutateUpdateMedia,
27
+ mutateUpdateDef,
15
28
  mutateUpdateProp,
16
29
  mutateUpdateProperty,
17
30
  transactDoc,
18
31
  } from "../tabs/transact";
19
- import { activeTab } from "../workspace/workspace";
20
- import { view } from "../view";
32
+ import { activeTab, workspace } from "../workspace/workspace";
33
+ import { deriveRefusal } from "../workspace/pane-derive";
34
+ import { primarySelection, unifyValues } from "../tabs/selection";
35
+ import { shell } from "../shell";
36
+ import {
37
+ argsSchema,
38
+ booleanArg,
39
+ booleanProperty,
40
+ stringArg,
41
+ stringProperty,
42
+ } from "../commands/command-args";
43
+ import type { LayoutSelection } from "../shell";
44
+ import type { AnyCommand, CommandRegistry } from "../commands/registry";
45
+ import { activeRegistry } from "../commands/active-registry";
21
46
  import { componentRegistry } from "../files/components";
22
47
  import { widgetForType } from "./style-inputs";
23
- import { renderFieldRow } from "../ui/field-row";
48
+ import { renderFieldRow, renderKvRow } from "../ui/field-row";
49
+ import type { FieldProvenance } from "./provenance";
24
50
  import { renderDynamicSlot, slotMode } from "../ui/dynamic-slot";
25
51
  import { spTextArea, spTextField } from "../ui/field-input";
26
- import {
27
- attrLabel,
28
- camelToLabel,
29
- friendlyNameToVar,
30
- inferInputType,
31
- parseCemType,
32
- } from "../utils/studio-utils";
52
+ import { attrLabel, camelToLabel, inferInputType, parseCemType } from "../utils/studio-utils";
33
53
  import { classifyHref, composeHref } from "../utils/link-target";
34
54
  import type { LinkKind } from "../utils/link-target";
35
- import { collectCssParts, isCustomElementDoc } from "./signals-panel";
36
- import { mediaDisplayName } from "./shared";
37
- import { getCssInitialMap } from "./style-utils";
55
+ import {
56
+ clickAnythingTo,
57
+ openPageAction,
58
+ renderEmptyState,
59
+ staleSelectionMessage,
60
+ } from "./empty-state";
38
61
  import { renderMediaPicker } from "../ui/media-picker";
39
62
  import { renderColorSelector } from "../ui/color-selector";
40
- import { getEffectiveLayoutPath, invalidateLayoutCache } from "../site-context";
63
+ import {
64
+ loadUsages,
65
+ peekUsages,
66
+ retryUsages,
67
+ usageFiles,
68
+ usageHeadline,
69
+ } from "../services/references";
41
70
  import { getPlatform } from "../platform";
42
71
  import htmlMeta from "../../data/html-meta.json";
43
72
 
@@ -48,14 +77,8 @@ import type {
48
77
  JxStateDefinition,
49
78
  JxStateObject,
50
79
  } from "@jxsuite/schema/types";
51
- import type { SlotMode } from "../ui/dynamic-slot";
80
+ import type { SignalOption } from "../ui/dynamic-slot";
52
81
  import type { JxPath } from "../state";
53
- import type { SignalDef } from "./signals-panel.js";
54
-
55
- interface SignalOption {
56
- value: string;
57
- label: string;
58
- }
59
82
 
60
83
  interface HtmlMetaEntry {
61
84
  $section: string;
@@ -69,23 +92,35 @@ interface HtmlMetaEntry {
69
92
  [key: string]: unknown;
70
93
  }
71
94
 
95
+ // ─── The binding vocabulary, shared with the Logic tab ───────────────────────
96
+
72
97
  /**
73
- * Convert a human-friendly name like "Tablet" to a $media key "--tablet"
98
+ * The two extra signals a position inside a repeater template can bind to, or `null` outside one.
74
99
  *
75
- * @param {string} name
100
+ * A "map" segment addresses a repeater template (`[…, "children", i, "map", …]`, or the legacy `[…,
101
+ * "children", "map", …]`), so anything at or below it is inside one.
102
+ *
103
+ * Exported because Content and Logic both offer them and there is one right answer: a `$switch`
104
+ * inside a `$map` can read `$map/item`, and so can the `alt` attribute two nodes below it.
76
105
  */
77
- function friendlyNameToMedia(name: string) {
78
- return friendlyNameToVar(name, "--");
106
+ export function mapSignalsFor(path: JxPath): SignalOption[] | null {
107
+ return path.includes("map")
108
+ ? [
109
+ { label: "$map/item", value: "$map/item" },
110
+ { label: "$map/index", value: "$map/index" },
111
+ ]
112
+ : null;
79
113
  }
80
114
 
81
- /** Check if a selection path is inside a $map template (contains [..., "children", "map", ...]). */
82
- function isInsideMapTemplate(path: JxPath | null) {
83
- if (!path) {
84
- return false;
85
- }
86
- // A "map" segment addresses a repeater template (`[…, "children", i, "map", …]`, or the legacy
87
- // `[…, "children", "map", …]`), so anything at or below it is inside a map template.
88
- return path.includes("map");
115
+ /** State keys a value position may bind to — handlers and Functions are not values. */
116
+ export function bindableSignalNames(doc: JxMutableNode): string[] {
117
+ return Object.entries(doc.state || {})
118
+ .filter(
119
+ ([, d]) =>
120
+ !(d as Record<string, unknown>)?.$handler &&
121
+ (d as JxPrototypeDef)?.$prototype !== "Function",
122
+ )
123
+ .map(([defName]) => defName);
89
124
  }
90
125
 
91
126
  /**
@@ -94,7 +129,7 @@ function isInsideMapTemplate(path: JxPath | null) {
94
129
  * @param {import("@jxsuite/schema/types").JxStateDefinition | undefined} def
95
130
  * @returns {string}
96
131
  */
97
- function defaultAsString(def: JxStateDefinition | undefined) {
132
+ export function defaultAsString(def: JxStateDefinition | undefined) {
98
133
  if (!def || typeof def !== "object" || Array.isArray(def)) {
99
134
  return "";
100
135
  }
@@ -105,305 +140,78 @@ function defaultAsString(def: JxStateDefinition | undefined) {
105
140
  return typeof dv === "object" ? JSON.stringify(dv) : String(dv);
106
141
  }
107
142
 
143
+ /** Who a bound value comes from, in the fewest words that are true — the chip's donor text. */
144
+ function bindingDonor(value: unknown): string {
145
+ if (isRef(value)) {
146
+ const ref = value.$ref;
147
+ return ref.startsWith("#/state/") ? ref.slice(8) : ref || "nothing yet";
148
+ }
149
+ return typeof value === "string" ? "a template" : "a formula";
150
+ }
151
+
108
152
  /**
109
- * Field row with binding toggle — allows switching between static value and signal binding.
110
- * rawValue can be a JSON literal (static) or { $ref: "..." } (bound).
153
+ * The state entry a bound value reads, when the chip can name one — a `$ref` or a `${…}` template.
154
+ *
155
+ * §6.2's table says a bound chip "click opens the source", and on the Content tab it did not: both
156
+ * bound branches returned a `donor` and a `title` and no `onClick`, so `renderProvenanceChip` drew
157
+ * a handler-less span. The Style tab's chip has jumped to the Data panel since P5; the same promise
158
+ * on the other tab was a label. Same regex as `style-panel.ts`'s `templateSignalOf`, because both
159
+ * spellings of a template (`${x}` and `${state.x}`) name the same entry.
111
160
  */
112
- function bindableFieldRow(
113
- label: string,
114
- type: string,
115
- rawValue: unknown,
116
- onChange: (v?: JsonValue) => void,
117
- filterFn: ((d: SignalDef) => boolean) | null = null,
118
- extraSignals: SignalOption[] | null = null,
119
- fieldKey = `prop:${label}`,
120
- caps: SlotMode[] = ["literal", "ref"],
121
- ) {
122
- const tab = activeTab.value;
123
- const defs = tab!.doc.document.state || {};
124
- const boundRef = isRef(rawValue) ? rawValue.$ref : null;
125
- const isDynamic = slotMode(rawValue) !== "literal";
161
+ function boundSignalOf(value: unknown): string | null {
162
+ if (isRef(value)) {
163
+ const ref = value.$ref;
164
+ return ref.startsWith("#/state/") ? (ref.slice(8).split("/")[0] ?? null) : null;
165
+ }
166
+ if (typeof value !== "string") {
167
+ return null;
168
+ }
169
+ return /\$\{\s*(?:state\.)?([A-Za-z_$][\w$]*)/.exec(value)?.[1] ?? null;
170
+ }
126
171
 
127
- const signalDefs = Object.entries(defs).filter(([, d]) =>
128
- filterFn
129
- ? filterFn(d as SignalDef)
130
- : !(d as Record<string, unknown>)?.$handler &&
131
- (d as JxPrototypeDef)?.$prototype !== "Function",
132
- );
133
-
134
- const staticVal = isDynamic ? "" : (rawValue ?? "");
135
- const draftKey = `prop:${label}`;
136
- const staticTpl =
137
- type === "textarea"
138
- ? spTextArea(draftKey, String(staticVal), (v: string) => onChange(v))
139
- : type === "checkbox"
140
- ? html`<sp-checkbox
141
- ?checked=${Boolean(staticVal)}
142
- @change=${(e: Event) => onChange((e.target as HTMLInputElement).checked)}
143
- ></sp-checkbox>`
144
- : spTextField(draftKey, String(staticVal), (v: string) => onChange(v));
145
-
146
- // De-escalating to literal restores the bound signal's declared default (old unbind behavior).
147
- const literalDefault = boundRef
148
- ? defaultAsString(defs[boundRef.startsWith("#/state/") ? boundRef.slice(8) : boundRef]) ||
149
- undefined
150
- : undefined;
151
-
152
- const slot = renderDynamicSlot({
153
- caps,
154
- extraSignals,
155
- fieldKey,
156
- literalDefault,
157
- onChange,
158
- staticWidget: staticTpl,
159
- stateDefs: signalDefs.map(([defName]) => defName),
160
- value: rawValue,
161
- });
162
- return html`
163
- <div class="field-row">
164
- <sp-field-label size="s">${label}</sp-field-label>
165
- ${slot.modeButton} ${slot.widget}
166
- </div>
167
- `;
172
+ /**
173
+ * Reveal the state entry a bound field reads: the Data panel, and that entry's row.
174
+ *
175
+ * Both verbs, because the rail tab alone leaves you looking at a collapsed list — the same pair the
176
+ * Style tab's chip runs.
177
+ */
178
+ function revealSignal(name: string): void {
179
+ const registry = activeRegistry();
180
+ void registry?.run("view.setActivity", { tab: "data" });
181
+ void registry?.run("data.expandRow", { name });
168
182
  }
169
183
 
170
- /** Key-value pair row for styles / attributes */
171
- function kvRow(
172
- key: string,
173
- value: string,
174
- onChange: (newKey: string, newVal: string) => void,
175
- onDelete: () => void,
176
- /** @type {string | null} */ datalistId = null,
177
- ) {
178
- /** @type {ReturnType<typeof setTimeout> | undefined} */
179
- let debounceTimer: ReturnType<typeof setTimeout> | undefined;
180
- let currentKey = key;
181
- let currentVal = value;
182
- const commit = () => {
183
- clearTimeout(debounceTimer);
184
- debounceTimer = setTimeout(() => onChange(currentKey, currentVal), 400);
184
+ /**
185
+ * The bound chip, naming its donor and — when the donor is a state entry — opening it.
186
+ *
187
+ * The entry list is read here rather than threaded through both callers' signatures: the two
188
+ * provenance functions differ in everything else, and the question "does this document define
189
+ * `title`?" has one answer that neither of them should have to be handed.
190
+ */
191
+ function boundProvenance(value: unknown, title: string): FieldProvenance {
192
+ const signal = boundSignalOf(value);
193
+ const document = activeTab.value?.doc.document;
194
+ const known = signal && document ? bindableSignalNames(document).includes(signal) : false;
195
+ return {
196
+ donor: bindingDonor(value),
197
+ state: "bound",
198
+ title,
199
+ // Only when the document actually defines it: a chip that jumps to a row that is not there
200
+ // Would be a worse answer than a chip that does not jump.
201
+ ...(known && signal ? { onClick: () => revealSignal(signal) } : {}),
185
202
  };
186
- const placeholder = datalistId === "css-props" ? getCssInitialMap().get(key) || "" : "";
187
- return html`
188
- <div class="kv-row">
189
- <sp-textfield
190
- size="s"
191
- class="kv-key"
192
- .value=${live(key)}
193
- @input=${(e: Event) => {
194
- currentKey = (e.target as HTMLInputElement).value;
195
- commit();
196
- }}
197
- @change=${
198
- datalistId === "css-props"
199
- ? (e: Event) => {
200
- const el = (e.target as HTMLInputElement)
201
- .closest(".kv-row")
202
- ?.querySelector(".kv-val");
203
- if (el) {
204
- el.setAttribute(
205
- "placeholder",
206
- getCssInitialMap().get((e.target as HTMLInputElement).value) || "",
207
- );
208
- }
209
- }
210
- : nothing
211
- }
212
- ></sp-textfield>
213
- <sp-textfield
214
- size="s"
215
- class="kv-val"
216
- .value=${live(value)}
217
- placeholder=${placeholder}
218
- @input=${(e: Event) => {
219
- currentVal = (e.target as HTMLInputElement).value;
220
- commit();
221
- }}
222
- ></sp-textfield>
223
- <sp-action-button size="xs" quiet @click=${onDelete}>
224
- <sp-icon-close slot="icon"></sp-icon-close>
225
- </sp-action-button>
226
- </div>
227
- `;
228
203
  }
229
204
 
230
205
  // ─── Sub-templates ──────────────────────────────────────────────────────────
231
206
 
232
- /** Repeater fields template */
233
- function renderRepeaterFieldsTemplate(
234
- node: JxMutableNode,
235
- path: JxPath,
236
- _mapSignals: SignalOption[] | null,
237
- ) {
238
- return html`
239
- ${bindableFieldRow(
240
- "Items",
241
- "text",
242
- node.items,
243
- (v: JsonValue) =>
244
- transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, path, "items", v)),
245
- null,
246
- null,
247
- `prop|${path.join("/")}|items`,
248
- )}
249
- ${
250
- node.filter
251
- ? bindableFieldRow(
252
- "Filter",
253
- "text",
254
- node.filter,
255
- (v: JsonValue) =>
256
- transactDoc(activeTab.value, (t) =>
257
- mutateUpdateProperty(t, path, "filter", v || undefined),
258
- ),
259
- null,
260
- null,
261
- `prop|${path.join("/")}|filter`,
262
- )
263
- : nothing
264
- }
265
- ${
266
- node.sort
267
- ? bindableFieldRow(
268
- "Sort",
269
- "text",
270
- node.sort,
271
- (v: JsonValue) =>
272
- transactDoc(activeTab.value, (t) =>
273
- mutateUpdateProperty(t, path, "sort", v || undefined),
274
- ),
275
- null,
276
- null,
277
- `prop|${path.join("/")}|sort`,
278
- )
279
- : nothing
280
- }
281
- <div style="display:flex;gap:8px;margin-top:4px">
282
- ${
283
- !node.filter
284
- ? html`<span
285
- class="kv-add"
286
- @click=${() =>
287
- transactDoc(activeTab.value, (t) =>
288
- mutateUpdateProperty(t, path, "filter", { $ref: "#/state/" }),
289
- )}
290
- >+ Add filter</span
291
- >`
292
- : nothing
293
- }
294
- ${
295
- !node.sort
296
- ? html`<span
297
- class="kv-add"
298
- @click=${() =>
299
- transactDoc(activeTab.value, (t) =>
300
- mutateUpdateProperty(t, path, "sort", { $ref: "#/state/" }),
301
- )}
302
- >+ Add sort</span
303
- >`
304
- : nothing
305
- }
306
- </div>
307
- ${
308
- node.map
309
- ? html`
310
- <sp-action-button
311
- size="s"
312
- style="margin-top:8px;width:100%"
313
- @click=${() => {
314
- activeTab.value!.session.selection = [...path, "map"];
315
- }}
316
- >Edit template →</sp-action-button
317
- >
318
- `
319
- : nothing
320
- }
321
- `;
322
- }
323
-
324
- /** Switch fields template */
325
- function renderSwitchFieldsTemplate(
326
- node: JxMutableNode,
327
- path: JxPath,
328
- mapSignals: SignalOption[] | null,
329
- ) {
330
- const caseNames = Object.keys(node.cases || {});
331
- return html`
332
- ${bindableFieldRow(
333
- "Expression",
334
- "text",
335
- node.$switch,
336
- (v: JsonValue) =>
337
- transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, path, "$switch", v)),
338
- null,
339
- mapSignals,
340
- `prop|${path.join("/")}|$switch`,
341
- // No literal rung: a $switch is inherently dynamic.
342
- // De-escalating to literal would delete the key and demote the node mid-cycle.
343
- ["ref", "template"],
344
- )}
345
- <div
346
- style="font-size:var(--spectrum-font-size-50, 11px);font-weight:600;color:var(--fg-dim);margin:8px 0 4px;text-transform:uppercase;letter-spacing:0.05em"
347
- >
348
- Cases
349
- </div>
350
- ${caseNames.map((caseName) => {
351
- /** @type {ReturnType<typeof setTimeout> | undefined} */
352
- let debounce: ReturnType<typeof setTimeout> | undefined;
353
- return html`
354
- <div class="field-row" style="display:flex;align-items:center;gap:4px;margin-bottom:3px">
355
- <input
356
- class="field-input"
357
- .value=${live(caseName)}
358
- style="flex:1"
359
- @input=${(e: Event) => {
360
- clearTimeout(debounce);
361
- debounce = setTimeout(() => {
362
- if (
363
- (e.target as HTMLInputElement).value &&
364
- (e.target as HTMLInputElement).value !== caseName
365
- ) {
366
- transactDoc(activeTab.value, (t) =>
367
- mutateRenameSwitchCase(t, path, caseName, (e.target as HTMLInputElement).value),
368
- );
369
- }
370
- }, 500);
371
- }}
372
- />
373
- <span
374
- class="bind-toggle"
375
- title="Edit case"
376
- style="cursor:pointer"
377
- @click=${(e: Event) => {
378
- e.stopPropagation();
379
- activeTab.value!.session.selection = [...path, "cases", caseName];
380
- }}
381
- >→</span
382
- >
383
- <span
384
- style="cursor:pointer;color:var(--danger);font-size:var(--spectrum-font-size-50, 11px)"
385
- @click=${(e: Event) => {
386
- e.stopPropagation();
387
- transactDoc(activeTab.value!, (t) => mutateRemoveSwitchCase(t, path, caseName));
388
- }}
389
- >✕</span
390
- >
391
- </div>
392
- `;
393
- })}
394
- <span
395
- class="kv-add"
396
- @click=${() => {
397
- transactDoc(activeTab.value, (t) =>
398
- mutateAddSwitchCase(t, path, `case${caseNames.length + 1}`),
399
- );
400
- }}
401
- >+ Add case</span
402
- >
403
- `;
404
- }
405
-
406
- /** Component props fields template */
207
+ /**
208
+ * Component props, each row carrying §6.2's provenance chip — the SECOND cascade.
209
+ *
210
+ * A component instance's value for a prop either overrides the component's declared default or does
211
+ * not exist, and until now the two were the same blank field. The chip states which, and in the
212
+ * inherited case it names the donor AND jumps to it: the donor of a component default is the
213
+ * component definition, which this panel can already open.
214
+ */
407
215
  function renderComponentPropsFieldsTemplate(
408
216
  node: JxMutableNode,
409
217
  path: JxPath,
@@ -411,140 +219,147 @@ function renderComponentPropsFieldsTemplate(
411
219
  navigateToComponent: (path: string) => void,
412
220
  ) {
413
221
  const tab = activeTab.value;
414
- const comp = componentRegistry.find((c) => c.tagName === node.tagName);
222
+ const comp = componentRegistry.find((c) => c.tagName === displayTagName(node.tagName));
223
+ const definitionOfThis = isComponentDefinitionOpen(node);
415
224
  if (!comp || !comp.props) {
416
- return html`<div class="empty-state">Component not found</div>`;
225
+ return renderEmptyState({
226
+ compact: true,
227
+ message: "This component is not in the project's library, so it has no settings to show.",
228
+ });
417
229
  }
418
230
  const isNpm = comp.source === "npm";
419
- const currentVals = isNpm ? node.attributes || {} : node.$props || {};
420
- const updateFn = isNpm
231
+ /* In the definition the "value" of a setting is its DECLARED DEFAULT — the state entry's
232
+ `default` — not a per-instance override, so the row reads and writes there instead. */
233
+ const stateEntries = (tab?.doc.document.state ?? {}) as Record<string, { default?: JsonValue }>;
234
+ const currentVals: Record<string, unknown> = definitionOfThis
235
+ ? Object.fromEntries(comp.props.map((prop) => [prop.name, stateEntries[prop.name]?.default]))
236
+ : isNpm
237
+ ? node.attributes || {}
238
+ : node.$props || {};
239
+ const updateFn = definitionOfThis
421
240
  ? (name: string, v?: JsonValue) =>
422
- transactDoc(activeTab.value, (t) =>
423
- mutateUpdateAttribute(
424
- t,
425
- path,
426
- name,
427
- v === "" ? undefined : (v as JxAttributeValue | undefined),
428
- ),
429
- )
430
- : (name: string, v?: JsonValue) =>
431
- transactDoc(activeTab.value, (t) => mutateUpdateProp(t, path, name, v));
241
+ transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { default: v }))
242
+ : isNpm
243
+ ? (name: string, v?: JsonValue) =>
244
+ transactDoc(activeTab.value, (t) =>
245
+ mutateUpdateAttribute(
246
+ t,
247
+ path,
248
+ name,
249
+ v === "" ? undefined : (v as JxAttributeValue | undefined),
250
+ ),
251
+ )
252
+ : (name: string, v?: JsonValue) =>
253
+ transactDoc(activeTab.value, (t) => mutateUpdateProp(t, path, name, v));
432
254
 
433
255
  const defs = tab!.doc.document.state || {};
434
- const signalDefs = Object.entries(defs).filter(
435
- ([, d]) =>
436
- !(d as Record<string, unknown>)?.$handler && (d as JxPrototypeDef)?.$prototype !== "Function",
437
- );
256
+ const signalNames = bindableSignalNames(tab!.doc.document);
438
257
  const extraSignals = mapSignals;
439
258
 
440
259
  return html`
441
- ${comp.props.map(
442
- (
443
- /** @type {{ name: string; type?: string; format?: string; description?: string }} */ prop,
444
- ) => {
445
- const rawValue = currentVals[prop.name];
446
- const boundRef = isRef(rawValue) ? rawValue.$ref : null;
447
- const hasVal = rawValue !== undefined && rawValue !== null;
448
- const parsed = parseCemType(prop.type);
449
- const onChange = (v?: JsonValue) => updateFn(prop.name, v);
450
- const staticVal = slotMode(rawValue) === "literal" ? String(rawValue ?? "") : "";
451
-
452
- const clearProp = (e: Event) => {
453
- e.stopPropagation();
454
- updateFn(prop.name);
455
- };
456
-
457
- // De-escalating to literal restores the bound signal's declared default (old unbind behavior).
458
- const literalDefault = boundRef
459
- ? defaultAsString(defs[boundRef.startsWith("#/state/") ? boundRef.slice(8) : boundRef]) ||
460
- undefined
461
- : undefined;
462
-
463
- /** @type {ReturnType<typeof setTimeout> | undefined} */
464
- let debounce: ReturnType<typeof setTimeout> | undefined;
465
- let widgetTpl;
466
- if (prop.format === "image") {
467
- widgetTpl = renderMediaPicker(prop.name, staticVal, onChange);
468
- } else if (prop.format === "color") {
469
- widgetTpl = renderColorSelector(prop.name, staticVal, onChange);
470
- } else if (prop.format === "date") {
471
- widgetTpl = spTextField(
472
- `cprop:${prop.name}`,
473
- String(staticVal),
474
- (v: string) => onChange(v),
475
- {
476
- placeholder: "YYYY-MM-DD",
477
- },
478
- );
479
- } else if (parsed.kind === "boolean") {
480
- widgetTpl = html`<sp-checkbox
481
- size="s"
482
- .checked=${live(Boolean(staticVal))}
483
- @change=${(e: Event) => onChange((e.target as HTMLInputElement).checked || undefined)}
484
- ></sp-checkbox>`;
485
- } else if (parsed.kind === "number") {
486
- widgetTpl = html`<sp-number-field
487
- size="s"
488
- .value=${live(staticVal)}
489
- @input=${(e: Event) => {
490
- clearTimeout(debounce);
491
- debounce = setTimeout(() => onChange((e.target as HTMLInputElement).value), 400);
492
- }}
493
- ></sp-number-field>`;
494
- } else if (parsed.kind === "combobox") {
495
- const options = /** @type {{ options?: string[] }} */ parsed.options as string[];
496
- widgetTpl = html`<jx-value-selector
497
- .value=${String(staticVal)}
498
- size="s"
499
- placeholder="—"
500
- .options=${options.map((o) => ({
501
- label: camelToLabel(o),
502
- value: o,
503
- }))}
504
- @change=${(e: Event & { detail?: { value?: string } }) =>
505
- onChange(e.detail?.value ?? (e.target as HTMLInputElement).value)}
506
- ></jx-value-selector>`;
507
- } else {
508
- widgetTpl = spTextField(`cprop:${prop.name}`, String(staticVal), (v: string) =>
509
- onChange(v),
510
- );
511
- }
260
+ ${comp.props.map((prop) => {
261
+ const rawValue = currentVals[prop.name];
262
+ const boundRef = isRef(rawValue) ? rawValue.$ref : null;
263
+ const hasVal = rawValue !== undefined && rawValue !== null;
264
+ const parsed = parseCemType(prop.type);
265
+ const onChange = (v?: JsonValue) => updateFn(prop.name, v);
266
+ const staticVal = slotMode(rawValue) === "literal" ? String(rawValue ?? "") : "";
267
+
268
+ // De-escalating to literal restores the bound signal's declared default (old unbind behavior).
269
+ const literalDefault = boundRef
270
+ ? defaultAsString(defs[boundRef.startsWith("#/state/") ? boundRef.slice(8) : boundRef]) ||
271
+ undefined
272
+ : undefined;
512
273
 
513
- const slot = renderDynamicSlot({
514
- caps: ["literal", "ref", "template"],
515
- extraSignals,
516
- fieldKey: `cprop|${path.join("/")}|${prop.name}`,
517
- literalDefault,
518
- onChange,
519
- staticWidget: widgetTpl,
520
- stateDefs: signalDefs.map(([defName]) => defName),
521
- value: rawValue,
522
- });
523
- return html`
524
- <div class="style-row" data-prop=${prop.name}>
525
- <div class="style-row-label">
526
- ${
527
- hasVal
528
- ? html`<span
529
- class="set-dot"
530
- title="Clear ${prop.name}"
531
- @click=${clearProp}
532
- ></span>`
533
- : nothing
534
- }
535
- <sp-field-label size="s" title=${prop.description || prop.name}
536
- >${camelToLabel(prop.name)}</sp-field-label
537
- >
538
- ${slot.modeButton}
539
- </div>
540
- ${slot.widget}
541
- </div>
542
- `;
543
- },
544
- )}
545
- ${comp.props.length === 0 ? html`<div class="empty-state">No props defined</div>` : nothing}
274
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
275
+ let debounce: ReturnType<typeof setTimeout> | undefined;
276
+ let widgetTpl;
277
+ if (prop.format === "image") {
278
+ widgetTpl = renderMediaPicker(prop.name, staticVal, onChange);
279
+ } else if (prop.format === "color") {
280
+ widgetTpl = renderColorSelector(prop.name, staticVal, onChange);
281
+ } else if (prop.format === "date") {
282
+ widgetTpl = spTextField(
283
+ `cprop:${prop.name}`,
284
+ String(staticVal),
285
+ (v: string) => onChange(v),
286
+ {
287
+ placeholder: "YYYY-MM-DD",
288
+ },
289
+ );
290
+ } else if (parsed.kind === "boolean") {
291
+ widgetTpl = html`<sp-checkbox
292
+ size="s"
293
+ .checked=${live(Boolean(staticVal))}
294
+ @change=${(e: Event) => onChange((e.target as HTMLInputElement).checked || undefined)}
295
+ ></sp-checkbox>`;
296
+ } else if (parsed.kind === "number") {
297
+ widgetTpl = html`<sp-number-field
298
+ size="s"
299
+ .value=${live(staticVal)}
300
+ @input=${(e: Event) => {
301
+ clearTimeout(debounce);
302
+ debounce = setTimeout(() => onChange((e.target as HTMLInputElement).value), 400);
303
+ }}
304
+ ></sp-number-field>`;
305
+ } else if (parsed.kind === "combobox") {
306
+ const options = parsed.options as string[];
307
+ widgetTpl = html`<jx-value-selector
308
+ .value=${String(staticVal)}
309
+ size="s"
310
+ placeholder="—"
311
+ .options=${options.map((o) => ({ label: camelToLabel(o), value: o }))}
312
+ @change=${(e: Event & { detail?: { value?: string } }) =>
313
+ onChange(e.detail?.value ?? (e.target as HTMLInputElement).value)}
314
+ ></jx-value-selector>`;
315
+ } else {
316
+ widgetTpl = spTextField(`cprop:${prop.name}`, String(staticVal), (v: string) =>
317
+ onChange(v),
318
+ );
319
+ }
320
+
321
+ const slot = renderDynamicSlot({
322
+ caps: "componentProp",
323
+ extraSignals,
324
+ fieldKey: `cprop|${path.join("/")}|${prop.name}`,
325
+ literalDefault,
326
+ onChange,
327
+ staticWidget: widgetTpl,
328
+ stateDefs: signalNames,
329
+ value: rawValue,
330
+ });
331
+
332
+ return renderFieldRow({
333
+ hasValue: hasVal,
334
+ label: camelToLabel(prop.name),
335
+ labelExtra: slot.modeButton,
336
+ prop: prop.name,
337
+ provenance: componentPropProvenance(prop, rawValue, hasVal, {
338
+ // No donor and no jump in the definition: the value IS the component's default, and
339
+ // "→ the component definition" would open the document already in front of you.
340
+ isDefinition: definitionOfThis,
341
+ onClear: () => updateFn(prop.name),
342
+ ...(definitionOfThis || !comp.path
343
+ ? {}
344
+ : { openDefinition: () => navigateToComponent(comp.path!) }),
345
+ }),
346
+ widget: slot.widget,
347
+ });
348
+ })}
546
349
  ${
547
- comp.path
350
+ comp.props.length === 0
351
+ ? renderEmptyState({
352
+ compact: true,
353
+ message: definitionOfThis
354
+ ? "This component declares no settings yet. Add a state entry with a default to " +
355
+ "give whoever places it something to fill in."
356
+ : "This component has no settings to fill in yet.",
357
+ })
358
+ : nothing
359
+ }
360
+ ${
361
+ // A link to the definition, from inside the definition, is a link to here.
362
+ comp.path && !definitionOfThis
548
363
  ? html`<span class="kv-add" @click=${() => navigateToComponent(comp.path!)}
549
364
  >→ Edit definition</span
550
365
  >`
@@ -553,9 +368,60 @@ function renderComponentPropsFieldsTemplate(
553
368
  `;
554
369
  }
555
370
 
556
- /** Custom attrs fields template */
371
+ /**
372
+ * Is the selected node an INSTANCE of a component, or the definition of one?
373
+ *
374
+ * A component's own root tag has a hyphen, so "the tag contains a dash" answers yes to both — and
375
+ * that is what put the instance form in front of someone editing the component itself. The
376
+ * distinguishing fact is whose document this is: if the component's definition path IS the open
377
+ * file, there is no instance, no override and no donor to jump to.
378
+ */
379
+ export function isComponentDefinitionOpen(node: JxMutableNode): boolean {
380
+ const comp = componentRegistry.find((c) => c.tagName === displayTagName(node.tagName));
381
+ const open = activeTab.value?.documentPath;
382
+ return comp?.path !== undefined && open !== undefined && comp.path === open;
383
+ }
384
+
385
+ /**
386
+ * Which of §6.2's four states a component-prop row is in.
387
+ *
388
+ * Bound beats set (a `$ref` IS a value, but "bound to `title`" is the more useful sentence); set
389
+ * beats inherited; a prop with no declared default and no value is plain Default and draws
390
+ * nothing.
391
+ */
392
+ function componentPropProvenance(
393
+ prop: { name: string; default?: unknown; description?: string },
394
+ rawValue: unknown,
395
+ hasVal: boolean,
396
+ actions: {
397
+ onClear: () => void;
398
+ openDefinition?: (() => void) | undefined;
399
+ /** Editing the component itself, where there is no instance and therefore no donor. */
400
+ isDefinition?: boolean;
401
+ },
402
+ ): FieldProvenance {
403
+ if (hasVal && slotMode(rawValue) !== "literal") {
404
+ return boundProvenance(rawValue, `Bound — ${prop.name}`);
405
+ }
406
+ if (hasVal) {
407
+ return { onClick: actions.onClear, state: "set", title: `Clear ${prop.name}` };
408
+ }
409
+ /* INHERITED is a statement about somewhere ELSE, so it cannot be true here. In the definition an
410
+ unset field means this component ships no default for that setting — which is `default`, the
411
+ state that draws nothing, not a badge pointing at itself. */
412
+ if (prop.default !== undefined && !actions.isDefinition) {
413
+ return {
414
+ donor: "the component default",
415
+ state: "inherited",
416
+ title: `Not set here — the component's default (${String(prop.default)}) applies`,
417
+ ...(actions.openDefinition ? { onClick: actions.openDefinition } : {}),
418
+ };
419
+ }
420
+ return { state: "default" };
421
+ }
422
+
423
+ /** Custom attrs fields template — attributes this element's schema does not know about. */
557
424
  function renderCustomAttrsFieldsTemplate(
558
- _node: JxMutableNode,
559
425
  path: JxPath,
560
426
  attrs: Record<string, unknown>,
561
427
  knownAttrNames: Set<string>,
@@ -563,10 +429,9 @@ function renderCustomAttrsFieldsTemplate(
563
429
  const customAttrs = Object.entries(attrs).filter(([k]) => !knownAttrNames.has(k));
564
430
  return html`
565
431
  ${customAttrs.map(([attr, val]) =>
566
- kvRow(
567
- attr,
568
- String(val),
569
- (newAttr: string, newVal: string) => {
432
+ renderKvRow({
433
+ name: attr,
434
+ onCommit: (newAttr: string, newVal: string) => {
570
435
  if (newAttr !== attr) {
571
436
  transactDoc(activeTab.value, (t) => {
572
437
  mutateUpdateAttribute(t, path, attr);
@@ -576,230 +441,18 @@ function renderCustomAttrsFieldsTemplate(
576
441
  transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, attr, newVal));
577
442
  }
578
443
  },
579
- () => transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, attr)),
580
- ),
444
+ onDelete: () => transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, attr)),
445
+ value: String(val),
446
+ }),
581
447
  )}
582
448
  <span
583
449
  class="kv-add"
584
- @click=${() =>
585
- transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, "data-", ""))}
450
+ @click=${() => transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, "data-", ""))}
586
451
  >+ Add attribute</span
587
452
  >
588
453
  `;
589
454
  }
590
455
 
591
- // ─── Media breakpoints ──────────────────────────────────────────────────────
592
-
593
- /** Media breakpoint fields template */
594
- function renderMediaFieldsTemplate(node: JxMutableNode) {
595
- const media = node.$media || {};
596
- /** @type {ReturnType<typeof setTimeout> | undefined} */
597
- let baseDebounce: ReturnType<typeof setTimeout> | undefined;
598
- const breakpoints = Object.entries(media).filter(([k]) => k !== "--");
599
-
600
- return html`
601
- <div class="kv-row" style="align-items:center">
602
- <span class="field-label" style="width:auto;margin-right:4px">Base width</span>
603
- <input
604
- class="field-input"
605
- style="width:70px;flex:none"
606
- placeholder="320px"
607
- .value=${live(media["--"] || "")}
608
- @input=${(e: Event) => {
609
- clearTimeout(baseDebounce);
610
- baseDebounce = setTimeout(() => {
611
- const val = (e.target as HTMLInputElement).value.trim();
612
- transactDoc(activeTab.value, (t) => mutateUpdateMedia(t, "--", val || undefined));
613
- }, 400);
614
- }}
615
- />
616
- ${
617
- media["--"]
618
- ? html`<span
619
- class="kv-del"
620
- @click=${() => transactDoc(activeTab.value, (t) => mutateUpdateMedia(t, "--"))}
621
- >✕</span
622
- >`
623
- : nothing
624
- }
625
- </div>
626
-
627
- ${breakpoints.map(([name, query]) => mediaBreakpointRowTemplate(name, query))}
628
-
629
- <div>
630
- <span
631
- class="kv-add"
632
- style=${view.showAddBreakpointForm ? "display:none" : ""}
633
- @click=${(_e: Event) => {
634
- view.showAddBreakpointForm = true;
635
- renderOnly("rightPanel");
636
- }}
637
- >+ Add breakpoint</span
638
- >
639
- ${
640
- view.showAddBreakpointForm
641
- ? html`
642
- <div style="margin-top:4px">
643
- <div style="display:flex;gap:4px;margin-bottom:3px;align-items:center">
644
- <input
645
- class="field-input"
646
- placeholder="Name (e.g. Tablet)"
647
- style="flex:1"
648
- @input=${(e: Event) => {
649
- view.addBreakpointPreview =
650
- friendlyNameToMedia((e.target as HTMLInputElement).value) || "";
651
- renderOnly("rightPanel");
652
- }}
653
- />
654
- <span
655
- style="font-size:10px;color:var(--fg-dim);font-family:var(--font-mono);white-space:nowrap"
656
- >${view.addBreakpointPreview}</span
657
- >
658
- </div>
659
- <div style="display:flex;gap:4px;margin-bottom:3px;align-items:center">
660
- <input
661
- class="field-input add-bp-query"
662
- value="(min-width: 768px)"
663
- style="flex:1"
664
- />
665
- </div>
666
- <div style="display:flex;gap:4px">
667
- <button
668
- class="kv-add"
669
- style="padding:2px 10px;cursor:pointer"
670
- @click=${(e: Event) => {
671
- const wrap = (e.target as HTMLElement).closest("div")?.parentElement;
672
- const nameVal = wrap?.querySelector("input")?.value;
673
- const queryVal = (
674
- wrap?.querySelector(".add-bp-query") as HTMLInputElement | null
675
- )?.value?.trim();
676
- const key = friendlyNameToMedia(nameVal || "");
677
- if (key && queryVal) {
678
- view.showAddBreakpointForm = false;
679
- view.addBreakpointPreview = "";
680
- transactDoc(activeTab.value, (t) => mutateUpdateMedia(t, key, queryVal));
681
- }
682
- }}
683
- >
684
- Add
685
- </button>
686
- <button
687
- class="kv-add"
688
- style="padding:2px 10px;cursor:pointer;color:var(--fg-dim)"
689
- @click=${() => {
690
- view.showAddBreakpointForm = false;
691
- view.addBreakpointPreview = "";
692
- renderOnly("rightPanel");
693
- }}
694
- >
695
- Cancel
696
- </button>
697
- </div>
698
- </div>
699
- `
700
- : nothing
701
- }
702
- </div>
703
- `;
704
- }
705
-
706
- /** Single media breakpoint row template */
707
- function mediaBreakpointRowTemplate(name: string, query: string) {
708
- /** @type {ReturnType<typeof setTimeout> | undefined} */
709
- let debounceTimer: ReturnType<typeof setTimeout> | undefined;
710
- let currentRawLabel = name;
711
- return html`
712
- <div style="margin-bottom:6px;padding:4px 0;border-bottom:1px solid var(--border)">
713
- <div style="display:flex;align-items:center;gap:4px;margin-bottom:2px">
714
- <input
715
- class="field-input"
716
- .value=${live(mediaDisplayName(name))}
717
- style="flex:1;font-weight:600;font-size:var(--spectrum-font-size-75, 12px)"
718
- @input=${(e: Event) => {
719
- const newKey = friendlyNameToMedia((e.target as HTMLInputElement).value);
720
- currentRawLabel = newKey || "";
721
- const rawEl = (e.target as HTMLElement).parentElement?.querySelector(".bp-raw-label");
722
- if (rawEl) {
723
- rawEl.textContent = currentRawLabel;
724
- }
725
- clearTimeout(debounceTimer);
726
- debounceTimer = setTimeout(() => {
727
- if (newKey && newKey !== name) {
728
- const queryEl = (e.target as HTMLElement)
729
- .closest("div[style]")
730
- ?.parentElement?.querySelector(".bp-query-input") as HTMLInputElement | null;
731
- transactDoc(activeTab.value, (t) => {
732
- mutateUpdateMedia(t, name);
733
- mutateUpdateMedia(t, newKey, queryEl?.value || query);
734
- });
735
- }
736
- }, 600);
737
- }}
738
- />
739
- <span
740
- class="bp-raw-label"
741
- style="font-size:10px;color:var(--fg-dim);font-family:var(--font-mono);white-space:nowrap"
742
- >${name}</span
743
- >
744
- <span
745
- class="kv-del"
746
- @click=${() => transactDoc(activeTab.value, (t) => mutateUpdateMedia(t, name))}
747
- >✕</span
748
- >
749
- </div>
750
- <div style="display:flex;gap:4px;align-items:center">
751
- <input
752
- class="field-input bp-query-input"
753
- .value=${live(query)}
754
- style="flex:1"
755
- @input=${(e: Event) => {
756
- clearTimeout(debounceTimer);
757
- debounceTimer = setTimeout(
758
- () =>
759
- transactDoc(activeTab.value, (t) =>
760
- mutateUpdateMedia(t, name, (e.target as HTMLInputElement).value),
761
- ),
762
- 400,
763
- );
764
- }}
765
- />
766
- </div>
767
- </div>
768
- `;
769
- }
770
-
771
- // ─── Layout picker ──────────────────────────────────────────────────────────
772
-
773
- /** @type {{ name: string; path: string }[] | null} */
774
- let layoutEntries: { name: string; path: string }[] | null = null;
775
-
776
- async function loadLayoutEntries() {
777
- try {
778
- const platform = getPlatform();
779
- const listing = await platform.listDirectory("layouts");
780
- layoutEntries = listing
781
- .filter((f: DirEntry) => f.type === "file" && f.name.endsWith(".json"))
782
- .map((f: DirEntry) => ({
783
- name: f.name.replace(/\.json$/, ""),
784
- path: `./layouts/${f.name}`,
785
- }));
786
- } catch {
787
- layoutEntries = [];
788
- }
789
- renderOnly("rightPanel");
790
- }
791
-
792
- export function invalidateLayoutPickerCache() {
793
- layoutEntries = null;
794
- }
795
-
796
- function isPageDocument(documentPath: string | undefined | null) {
797
- if (!documentPath || !projectState?.isSiteProject) {
798
- return false;
799
- }
800
- return documentPath.startsWith("pages/") || documentPath.startsWith("./pages/");
801
- }
802
-
803
456
  // ─── Page-route enumeration (for the Link-target Internal picker) ─────────────
804
457
 
805
458
  /** @type {string[] | null} — cached list of internal routes derived from the pages/ tree. */
@@ -996,128 +649,102 @@ function isBoundAttrValue(value: unknown): boolean {
996
649
  return isRef(value) || (typeof value === "string" && value.includes("${"));
997
650
  }
998
651
 
999
- function renderPageSection(node: JxMutableNode) {
1000
- const tab = activeTab.value;
1001
- if (!isPageDocument(tab!.documentPath)) {
1002
- return nothing;
1003
- }
1004
-
1005
- if (layoutEntries === null) {
1006
- void loadLayoutEntries();
1007
- return nothing;
1008
- }
652
+ // ─── Layout selection panel ─────────────────────────────────────────────────
1009
653
 
1010
- const currentLayout = node.$layout;
1011
- const defaultLayout = projectState?.projectConfig?.defaults?.layout;
1012
- const effectivePath = getEffectiveLayoutPath(currentLayout);
1013
- const displayValue = currentLayout === false ? "__none__" : currentLayout || "__default__";
654
+ /*
655
+ * There is no `openLayoutAtNode`, and every line of it was wrong by the time it was deleted.
656
+ *
657
+ * Its docstring said `navigateToComponent` "swaps the tab's document (pushing the page onto the
658
+ * document stack)" — two releases stale: nothing has swapped since §14.1 landed and there is no
659
+ * document stack. Then it did four things, and the last three were the defect:
660
+ *
661
+ * 1. **Navigate.** Now the command's job, and the command opens the layout BESIDE the page rather
662
+ * than over the page it is teaching about — §8.2's promise since P3, never shipped.
663
+ * 2. **`setLayoutSelection(null)`.** Its stated reason — "the layout is now the open document, so
664
+ * its nodes are ordinary editable content" — stops being true the moment the layout opens beside
665
+ * the page rather than replacing it. Clearing it is precisely what killed the follow on its first
666
+ * frame, and it is what made the Inspector blank at the instant the layout appeared.
667
+ * 3. **Re-select against `activeTab`.** The focus deliberately stays in the page now, so the tab
668
+ * this reached was the wrong one. The selection is carried by the derivation instead —
669
+ * `pane-derive.ts`'s `DerivedTarget.select`, read from `shell.layoutSelection`'s `layoutPath`
670
+ * and applied to the companion pane's tab once the open resolves. For one release that sentence
671
+ * was written here and nothing implemented it: `layoutPath` had no consumer outside the canvas
672
+ * hit test, `derivationFor("layout")` stored `{resolved: null}`, and the exact regression this
673
+ * ledger entry describes — dropped into a layout file with nothing selected — was back while
674
+ * the comment denied it.
675
+ *
676
+ * The companion resolves `shell.layoutSelection.layoutFile` in preference to the page's own
677
+ * `$layout` for the same reason the old code did: they are different answers for a nested chain,
678
+ * and the one the author clicked is the one they meant.
679
+ * 4. **`renderOnly("rightPanel")`.** `panels/right-panel.ts:106` tracks `shell.layoutSelection`
680
+ * itself and `:212` reads it, so the repaint was already subscribed — a second, imperative
681
+ * subscription that only looked necessary because the function above had just cleared the state
682
+ * the real one is keyed on.
683
+ */
1014
684
 
1015
- return html`
1016
- <sp-accordion-item label="Page" open>
1017
- <div class="style-section-body">
1018
- <div class="style-row" data-prop="$layout">
1019
- <div class="style-row-label">
1020
- ${
1021
- currentLayout !== undefined
1022
- ? html`<span
1023
- class="set-dot"
1024
- title="Reset to default"
1025
- @click=${(e: Event) => {
1026
- e.stopPropagation();
1027
- transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, [], "$layout"));
1028
- }}
1029
- ></span>`
1030
- : nothing
1031
- }
1032
- <sp-field-label size="s">Layout</sp-field-label>
1033
- </div>
1034
- <sp-picker
1035
- size="s"
1036
- value=${displayValue}
1037
- @change=${(e: Event) => {
1038
- const val = (e.target as HTMLInputElement).value;
1039
- if (val === "__default__") {
1040
- transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, [], "$layout"));
1041
- } else if (val === "__none__") {
1042
- transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, [], "$layout", false));
1043
- } else {
1044
- transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, [], "$layout", val));
1045
- }
1046
- invalidateLayoutCache();
1047
- }}
1048
- >
1049
- <sp-menu-item value="__default__"
1050
- >Default${
1051
- defaultLayout
1052
- ? ` (${defaultLayout.replace(/^\.\/layouts\//, "").replace(/\.json$/, "")})`
1053
- : ""
1054
- }</sp-menu-item
1055
- >
1056
- <sp-menu-item value="__none__">None</sp-menu-item>
1057
- <sp-menu-divider></sp-menu-divider>
1058
- ${layoutEntries!.map(
1059
- (l: { name: string; path: string }) =>
1060
- html`<sp-menu-item value=${l.path}>${l.name}</sp-menu-item>`,
1061
- )}
1062
- </sp-picker>
1063
- </div>
1064
- ${
1065
- effectivePath
1066
- ? html`<div style="font-size:10px;color:var(--fg-dim);padding:2px 0;font-style:italic">
1067
- Wraps page content via &lt;slot&gt; distribution
1068
- </div>`
1069
- : nothing
1070
- }
1071
- </div>
1072
- </sp-accordion-item>
1073
- `;
685
+ /**
686
+ * The Layout-element panel — what the inspector says when you click page chrome.
687
+ *
688
+ * It spent a release cycle unreachable: `shell.layoutSelection` had this reader and no writer, so
689
+ * clicking the site name in the header — the first click a new user makes — did nothing at all. The
690
+ * canvas hit test writes it now (§8.2), and `panels/right-panel.ts` tracks it, so the panel
691
+ * renders.
692
+ */
693
+ /**
694
+ * The chip that opens the layout beside the page.
695
+ *
696
+ * **Drawn only where it can run**, which is why the condition lives at the call site rather than
697
+ * inside the handler. `canvas/iframe-host.ts` focuses the pane a layout click landed in — including
698
+ * a LENS, which draws layout chrome because it draws the same document — and from a derived pane
699
+ * `pane.derive` can only throw `an open document in a pane that is not itself derived`, into a
700
+ * floating `void registry.run(…)` that swallows it. A chip that does nothing when you press it is
701
+ * worse than no chip: the sentence above it already says where the element comes from, and in that
702
+ * shell the layout is either already beside you or one Unsplit away.
703
+ */
704
+ function openLayoutTpl(): TemplateResult {
705
+ return html`<span
706
+ class="kv-add"
707
+ @click=${() => {
708
+ /* ONE command, addressed by id — the chip is a control, not a second definition site for
709
+ what "open the layout" means. It opens the layout BESIDE the page and leaves the keyboard
710
+ in the page, so the next click on layout chrome moves the side pane's selection instead of
711
+ teaching the author what a pane is. */
712
+ void activeRegistry()?.run("pane.derive", { preset: "layout" });
713
+ }}
714
+ >Open Layout →</span
715
+ >`;
1074
716
  }
1075
717
 
1076
- // ─── Layout selection panel ─────────────────────────────────────────────────
1077
-
1078
- function renderLayoutSelectionPanel(ctx: { navigateToComponent: (path: string) => void }) {
1079
- const { el, layoutPath } = view.layoutSelection as {
1080
- el: HTMLElement;
1081
- layoutPath: string;
1082
- };
1083
- const tagName = el?.tagName?.toLowerCase() || "element";
1084
- const className = el?.className || "";
1085
- const displayPath = layoutPath || "layout";
718
+ function renderLayoutSelectionPanel() {
719
+ const selection = shell.layoutSelection as LayoutSelection;
720
+ const tagName = selection.tagName || "element";
721
+ const { className } = selection;
722
+ const displayPath = selection.layoutFile || "layout";
1086
723
 
1087
724
  return html`
1088
725
  <div class="style-sidebar">
1089
726
  <sp-accordion allow-multiple size="s">
1090
727
  <sp-accordion-item label="Layout Element" open>
1091
728
  <div class="style-section-body">
1092
- <div style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
1093
- <span
1094
- style="font-size:9px;padding:2px 6px;background:var(--spectrum-purple-600);color:white;border-radius:3px;text-transform:uppercase;letter-spacing:0.5px"
1095
- >Layout</span
1096
- >
1097
- <code
1098
- style="font-size:var(--spectrum-font-size-75, 12px);font-family:var(--font-mono)"
1099
- >&lt;${tagName}&gt;</code
1100
- >
729
+ <div class="layout-origin">
730
+ <span class="layout-origin-badge">Layout</span>
731
+ <code class="layout-origin-tag">&lt;${tagName}&gt;</code>
1101
732
  </div>
1102
733
  ${
1103
734
  className
1104
- ? html`<div class="style-row">
1105
- <div class="style-row-label">
1106
- <sp-field-label size="s">Class</sp-field-label>
1107
- </div>
1108
- <span
1109
- style="font-size:var(--spectrum-font-size-50, 11px);color:var(--fg-dim);word-break:break-all"
1110
- >${className}</span
1111
- >
1112
- </div>`
735
+ ? renderFieldRow({
736
+ hasValue: false,
737
+ label: "Class",
738
+ prop: "className",
739
+ widget: html`<span class="layout-origin-class">${className}</span>`,
740
+ })
1113
741
  : nothing
1114
742
  }
1115
- <div style="font-size:10px;color:var(--fg-dim);padding:4px 0;font-style:italic">
1116
- This element is part of the page layout. Edit it by opening the layout file.
1117
- </div>
1118
- <span class="kv-add" @click=${() => ctx.navigateToComponent(displayPath)}
1119
- >Open Layout →</span
1120
- >
743
+ <p class="layout-origin-note">
744
+ This element comes from ${displayPath}, which wraps every page that uses it. Open the
745
+ layout to edit it.
746
+ </p>
747
+ ${deriveRefusal(workspace.activePaneId) === null ? openLayoutTpl() : nothing}
1121
748
  </div>
1122
749
  </sp-accordion-item>
1123
750
  </sp-accordion>
@@ -1125,10 +752,112 @@ function renderLayoutSelectionPanel(ctx: { navigateToComponent: (path: string) =
1125
752
  `;
1126
753
  }
1127
754
 
755
+ // ─── Usages (§9.6) ───────────────────────────────────────────────────────────
756
+
757
+ /**
758
+ * The Usage section's key, in the same per-tab `inspectorSections` record every other section uses,
759
+ * so `inspector.setSection` addresses it exactly like the rest and nothing needs a second store.
760
+ */
761
+ const USAGES_SECTION = "__usages";
762
+
763
+ /**
764
+ * The component instance's usage line — "Used on 7 pages →", expanding to the files.
765
+ *
766
+ * Three things this deliberately does NOT do. It does not render when the host cannot answer
767
+ * (`usagesSupported()` is `capability.findReferences`): a confident "0" for a component used
768
+ * everywhere is worse than no line at all. It does not ask on every paint — `peekUsages` is
769
+ * side-effect-free and only a cold target starts a request, which repaints once when it lands. And
770
+ * it does not swallow a failure: a failed count says so and offers Retry, because "we could not
771
+ * check" and "nothing uses this" are the two answers a user must never confuse.
772
+ *
773
+ * @param tagName — the instance's custom-element tag.
774
+ * @param componentPath — its definition file, when the registry knows one. Passing both asks the
775
+ * one question that covers file references AND element instances.
776
+ */
777
+ function renderUsagesSection(tagName: string, componentPath: string | null) {
778
+ const query = { tagName, ...(componentPath ? { path: componentPath } : {}) };
779
+ const state = peekUsages(query);
780
+ // ONE gate, and it is the peek itself: a host with no `findReferences` answers "unsupported"
781
+ // Synchronously, and the section does not exist. A separate `usagesSupported()` guard above this
782
+ // Would make the arm below unreachable, which is a branch nothing can ever test.
783
+ if (state?.status === "unsupported") {
784
+ return nothing;
785
+ }
786
+ if (state === null) {
787
+ // Cold: ask once, and repaint the inspector when the answer arrives.
788
+ void loadUsages(query).then(() => renderOnly("rightPanel"));
789
+ }
790
+
791
+ let body;
792
+ let heading = "Usage";
793
+ if (state === null || state.status === "pending") {
794
+ body = html`<div class="usage-note">Counting references…</div>`;
795
+ } else if (state.status === "failed") {
796
+ heading = "Usage · unknown";
797
+ body = html`
798
+ <div class="usage-note">
799
+ References could not be counted: ${state.message}. This is not the same as “unused”.
800
+ </div>
801
+ <sp-action-button
802
+ size="s"
803
+ quiet
804
+ @click=${() => {
805
+ void retryUsages(query).then(() => renderOnly("rightPanel"));
806
+ }}
807
+ >Retry</sp-action-button
808
+ >
809
+ `;
810
+ } else {
811
+ const files = usageFiles(state.result);
812
+ heading = usageHeadline(state.result);
813
+ body =
814
+ files.length === 0
815
+ ? html`<div class="usage-note">
816
+ Nothing else in this project places <code>&lt;${tagName}&gt;</code> yet.
817
+ </div>`
818
+ : html`${files.map(
819
+ (file) => html`
820
+ <button
821
+ class="usage-row"
822
+ type="button"
823
+ title=${file.refs.map((r) => `${r.refType} ${r.ref} ×${r.count}`).join(", ")}
824
+ @click=${() => openUsage(file.path)}
825
+ >
826
+ <span class="usage-row-path">${file.path}</span>
827
+ <span class="usage-row-count">${file.count}</span>
828
+ </button>
829
+ `,
830
+ )}`;
831
+ }
832
+
833
+ return html`
834
+ <sp-accordion-item
835
+ label=${heading}
836
+ ?open=${isInspectorSectionOpen(USAGES_SECTION, false)}
837
+ @sp-accordion-item-toggle=${() =>
838
+ setInspectorSection(USAGES_SECTION, !isInspectorSectionOpen(USAGES_SECTION, false))}
839
+ >
840
+ <div class="style-section-body">${body}</div>
841
+ </sp-accordion-item>
842
+ `;
843
+ }
844
+
845
+ /** Open a referencing file in a tab — the "→" half of "Used on N pages →". */
846
+ function openUsage(path: string): void {
847
+ // Lazy import: `files/files.ts` imports this panel's siblings, and a static edge here would close
848
+ // The inspector → files → inspector cycle.
849
+ void import("../files/files").then((m) => m.openFileInTab(path));
850
+ }
851
+
852
+ /** Send the user to the tab that DOES answer, without this module importing the dock. */
853
+ function showLogicTab(): void {
854
+ void import("./right-panel").then((m) => m.setInspectorTab("events"));
855
+ }
856
+
1128
857
  // ─── Main entry point ───────────────────────────────────────────────────────
1129
858
 
1130
859
  /**
1131
- * Properties panel — lit-html template with accordion sections.
860
+ * The Content tab — lit-html template with accordion sections.
1132
861
  *
1133
862
  * @param {{ navigateToComponent: (path: string) => void }} ctx
1134
863
  */
@@ -1137,53 +866,61 @@ export function renderPropertiesPanelTemplate(ctx: {
1137
866
  }) {
1138
867
  const tab = activeTab.value;
1139
868
  if (!tab) {
1140
- return html`<div class="empty-state">No document loaded</div>`;
869
+ return renderEmptyState({
870
+ actions: [openPageAction()],
871
+ message: "Open a page to inspect and style what you click.",
872
+ });
1141
873
  }
1142
874
 
1143
875
  // Layout element selected — show read-only info with link to open layout
1144
- if (view.layoutSelection) {
1145
- return renderLayoutSelectionPanel(ctx);
876
+ if (shell.layoutSelection) {
877
+ return renderLayoutSelectionPanel();
1146
878
  }
1147
879
 
1148
- if (!tab.session.selection) {
1149
- return html`<div class="empty-state">Select an element to inspect</div>`;
880
+ const selected = primarySelection(tab.session.selection);
881
+ if (!selected) {
882
+ return renderEmptyState({ message: clickAnythingTo("edit its content") });
1150
883
  }
1151
- const node = getNodeAtPath(tab.doc.document, tab.session.selection);
884
+ const path: JxPath = selected;
885
+ const node = getNodeAtPath(tab.doc.document, path);
1152
886
  if (!node) {
1153
- return html`<div class="empty-state">Node not found</div>`;
887
+ return renderEmptyState({ message: staleSelectionMessage() });
1154
888
  }
1155
889
 
1156
- const path = tab.session.selection;
1157
- const isMapNode = node.$prototype === "Array";
1158
- const isSwitchNode = Boolean(node.$switch);
1159
- const isCustomInstance = (node.tagName || "").includes("-");
1160
- const isRoot = path.length === 0;
1161
- const tagName = node.tagName || "div";
1162
- const attrs = node.attributes || {};
890
+ // A repeating list has no content of its own — it has a source and a template, and both are
891
+ // Wiring. Content says where the answer lives rather than drawing an empty accordion (§6.5).
892
+ if (node.$prototype === "Array") {
893
+ return renderEmptyState({
894
+ actions: [{ label: "Open Logic", run: showLogicTab }],
895
+ detail: "Its items, filter, sort and template are wiring, so they live in Logic.",
896
+ message: "A repeating list has no content of its own.",
897
+ });
898
+ }
1163
899
 
1164
- const mapSignals = isInsideMapTemplate(path)
1165
- ? [
1166
- { label: "$map/item", value: "$map/item" },
1167
- { label: "$map/index", value: "$map/index" },
1168
- ]
1169
- : null;
900
+ // The whole selection the Content tab edits. `[path]` when one element is selected, which is
901
+ // Every existing call site's behaviour unchanged.
902
+ const targets: JxPath[] = tab.session.selection.length > 0 ? tab.session.selection : [path];
903
+ const doc = tab.doc.document;
904
+ const isCustomInstance = displayTagName(node.tagName).includes("-");
905
+ const chosenTag = isTagExpression(node.tagName) ? node.tagName.$expression : null;
906
+ const tagName = displayTagName(node.tagName) || "div";
907
+ const attrs = node.attributes || {};
1170
908
 
909
+ const mapSignals = mapSignalsFor(path);
1171
910
  // Signals offered to attribute/textContent bindings (handlers and Functions excluded).
1172
- const bindableSignals = Object.entries(tab.doc.document.state || {})
1173
- .filter(
1174
- ([, d]) =>
1175
- !(d as Record<string, unknown>)?.$handler &&
1176
- (d as JxPrototypeDef)?.$prototype !== "Function",
1177
- )
1178
- .map(([defName]) => defName);
911
+ const bindableSignals = bindableSignalNames(tab.doc.document);
1179
912
 
1180
913
  function renderAttrRow(attr: string, entry: HtmlMetaEntry, value: unknown) {
1181
914
  const type = inferInputType(entry);
1182
915
  const hasVal = value !== undefined && value !== "";
916
+ // One write per selected element, inside ONE transaction (§6.5). `targets` is `[path]` for a
917
+ // Single selection, so this is the same single mutation it has always been.
1183
918
  const commitAttr = (v?: JsonValue) =>
1184
- transactDoc(activeTab.value!, (t) =>
1185
- mutateUpdateAttribute(t, path, attr, v as JxAttributeValue | undefined),
1186
- );
919
+ transactDoc(activeTab.value!, (t) => {
920
+ for (const target of targets) {
921
+ mutateUpdateAttribute(t, target, attr, v as JxAttributeValue | undefined);
922
+ }
923
+ });
1187
924
 
1188
925
  // Enhanced Link handling: only for anchors (a/area) with a plain (non-binding) value. Bindings
1189
926
  // ($ref objects or ${…} template strings) fall through to the raw widget to stay editable.
@@ -1197,10 +934,9 @@ export function renderPropertiesPanelTemplate(ctx: {
1197
934
  }
1198
935
  }
1199
936
 
1200
- // Attribute strings are schema-legal at three rungs: literal, $ref binding, ${} template.
1201
937
  const attrSlot = (staticWidget: unknown) =>
1202
938
  renderDynamicSlot({
1203
- caps: ["literal", "ref", "template"],
939
+ caps: "attribute",
1204
940
  extraSignals: mapSignals,
1205
941
  fieldKey: `attr|${path.join("/")}|${attr}`,
1206
942
  onChange: commitAttr,
@@ -1209,41 +945,37 @@ export function renderPropertiesPanelTemplate(ctx: {
1209
945
  value,
1210
946
  });
1211
947
 
1212
- if (entry.type === "boolean") {
1213
- const checkboxWidget = html`
1214
- <sp-checkbox
1215
- size="s"
1216
- .checked=${live(Boolean(value))}
1217
- @change=${(e: Event) =>
1218
- commitAttr((e.target as HTMLInputElement).checked ? "" : undefined)}
1219
- >
1220
- </sp-checkbox>
1221
- `;
1222
- const slot = attrSlot(checkboxWidget);
1223
- return renderFieldRow({
1224
- hasValue: hasVal,
1225
- label: attrLabel(entry, attr),
1226
- labelExtra: slot.modeButton,
1227
- onClear: () => transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, attr)),
1228
- prop: attr,
1229
- widget: slot.widget,
1230
- });
1231
- }
1232
-
1233
- const literalWidget = widgetForType(
1234
- type,
1235
- entry,
1236
- attr,
1237
- isRef(value) ? "" : String(value || ""),
1238
- (v: string) => commitAttr(v || undefined),
1239
- );
948
+ const literalWidget =
949
+ entry.type === "boolean"
950
+ ? html`
951
+ <sp-checkbox
952
+ size="s"
953
+ .checked=${live(Boolean(value))}
954
+ @change=${(e: Event) => commitAttr((e.target as HTMLInputElement).checked ? "" : undefined)}
955
+ >
956
+ </sp-checkbox>
957
+ `
958
+ : widgetForType(type, entry, attr, isRef(value) ? "" : String(value || ""), (v: string) =>
959
+ commitAttr(v || undefined),
960
+ );
1240
961
  const slot = attrSlot(literalWidget);
1241
962
  return renderFieldRow({
1242
963
  hasValue: hasVal,
1243
964
  label: attrLabel(entry, attr),
1244
965
  labelExtra: slot.modeButton,
1245
- onClear: () => transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, attr)),
1246
966
  prop: attr,
967
+ provenance: attributeProvenance(
968
+ attr,
969
+ value,
970
+ hasVal,
971
+ () =>
972
+ transactDoc(activeTab.value, (t) => {
973
+ for (const target of targets) {
974
+ mutateUpdateAttribute(t, target, attr);
975
+ }
976
+ }),
977
+ mixedAcrossSelection(doc, targets, (n) => (n?.attributes ?? {})[attr] ?? null),
978
+ ),
1247
979
  widget: slot.widget,
1248
980
  });
1249
981
  }
@@ -1251,7 +983,7 @@ export function renderPropertiesPanelTemplate(ctx: {
1251
983
  // ── Collect applicable attributes from html-meta ──
1252
984
  const applicableAttrs = {} as Record<string, HtmlMetaEntry>;
1253
985
  for (const [attr, entry] of Object.entries(htmlMeta.$defs) as [string, HtmlMetaEntry][]) {
1254
- if (!entry.$elements || entry.$elements.includes(tagName)) {
986
+ if (!entry.$elements || entry.$elements.includes(displayTagName(tagName))) {
1255
987
  // The $attr field aliases a $defs key to a different attribute name.
1256
988
  // This lets the same attribute (e.g. "name") carry per-element metadata.
1257
989
  applicableAttrs[entry.$attr ?? attr] = entry;
@@ -1298,24 +1030,19 @@ export function renderPropertiesPanelTemplate(ctx: {
1298
1030
  }
1299
1031
 
1300
1032
  function isSectionOpen(key: string) {
1301
- if (tab!.session.ui.inspectorSections[key] !== undefined) {
1302
- return tab!.session.ui.inspectorSections[key];
1303
- }
1304
- return autoOpen.has(key);
1033
+ return isInspectorSectionOpen(key, autoOpen.has(key));
1305
1034
  }
1306
1035
 
1307
1036
  function toggleSection(key: string) {
1308
- const current = isSectionOpen(key);
1309
- activeTab.value!.session.ui.inspectorSections = {
1310
- ...activeTab.value!.session.ui.inspectorSections,
1311
- [key]: !current,
1312
- };
1037
+ // One writer: the accordion's own click and `inspector.setSection` land in the same function,
1038
+ // So the command and the control cannot disagree about what "open" means.
1039
+ setInspectorSection(key, !isSectionOpen(key));
1313
1040
  }
1314
1041
 
1315
1042
  // ── Build section templates ─────────────────────────────────────────
1316
1043
 
1317
1044
  const textSlot = renderDynamicSlot({
1318
- caps: ["literal", "ref", "template"],
1045
+ caps: "textProperty",
1319
1046
  extraSignals: mapSignals,
1320
1047
  fieldKey: `prop|${path.join("/")}|textContent`,
1321
1048
  onChange: (v?: JsonValue) =>
@@ -1332,6 +1059,31 @@ export function renderPropertiesPanelTemplate(ctx: {
1332
1059
  value: node.textContent,
1333
1060
  });
1334
1061
 
1062
+ /**
1063
+ * A draft key for one field ON THIS NODE.
1064
+ *
1065
+ * The three Element rows used constant keys — `"prop:tagName"`, `"prop:$id"`, `"prop:className"`
1066
+ * — and `ui/field-input.ts`'s draft map is module-global, so every element shared one draft slot
1067
+ * per field. Type a class name, click a sibling before blurring, and the sibling's Class row
1068
+ * showed the text you had typed for the first one; blur it there and the commit landed on the
1069
+ * WRONG element. Plan §11.4 asks for "drafts keyed by node path (today all elements share one
1070
+ * draft slot per field)".
1071
+ */
1072
+ const fieldKey = (name: string) => `prop:${path.join("/")}:${name}`;
1073
+
1074
+ /**
1075
+ * The set-here chip for a document property, with the tooltip in the label's own words.
1076
+ *
1077
+ * The derived tooltip would read "Clear textContent"; the label above it reads "Text Content".
1078
+ * The row's key and the row's name are different vocabularies, and the tooltip belongs to the one
1079
+ * the user is looking at.
1080
+ */
1081
+ const propertyChip = (name: string, human: string): FieldProvenance => ({
1082
+ onClick: () => transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, path, name)),
1083
+ state: "set",
1084
+ title: `Clear ${human}`,
1085
+ });
1086
+
1335
1087
  const elemT = html`
1336
1088
  <sp-accordion-item
1337
1089
  label="Element"
@@ -1339,210 +1091,122 @@ export function renderPropertiesPanelTemplate(ctx: {
1339
1091
  @sp-accordion-item-toggle=${() => toggleSection("__element")}
1340
1092
  >
1341
1093
  <div class="style-section-body">
1342
- <div class="style-row" data-prop="tagName">
1343
- <div class="style-row-label">
1344
- <sp-field-label size="s">Tag</sp-field-label>
1345
- </div>
1346
- <sp-textfield
1347
- size="s"
1348
- .value=${live(tagName)}
1349
- autocomplete="off"
1350
- list="tag-names"
1351
- @input=${debouncedStyleCommit("prop:tagName", 400, (e: Event) => {
1094
+ ${(() => {
1095
+ /* THE SHARED SLOT, not a hand-rolled control.
1096
+ `tagName` is a bindable position like any other — a literal name or a `TagExpression`
1097
+ — so it gets the same Value Source chip and the same expression editor as `href`, a
1098
+ style declaration or an event handler. The rungs are DERIVED from
1099
+ `SLOT_POSITION_SCHEMAS.elementTag`, which is why only Fixed value and Formula are
1100
+ offered: `TagName` carries a pattern, so `isFreeStringSchema` refuses a template rung,
1101
+ and a `${…}` in tag position is precisely what that pattern exists to reject.
1102
+
1103
+ The seed is ours because the generic one is `{ operator: "??" }`, which a
1104
+ `TagExpression` does not admit — clicking the chip would otherwise write a document
1105
+ that fails its own validator. */
1106
+ const tagSlot = renderDynamicSlot({
1107
+ caps: "elementTag",
1108
+ fieldKey: `${path.join(".")}:tagName`,
1109
+ onChange: (next) => {
1352
1110
  transactDoc(activeTab.value, (t) =>
1353
- mutateUpdateProperty(
1354
- t,
1355
- path,
1356
- "tagName",
1357
- (e.target as HTMLInputElement).value || undefined,
1358
- ),
1111
+ mutateUpdateProperty(t, path, "tagName", next ?? undefined),
1359
1112
  );
1360
- })}
1361
- ></sp-textfield>
1362
- </div>
1363
- <div class="style-row" data-prop="$id">
1364
- <div class="style-row-label">
1365
- ${
1366
- node.$id
1367
- ? html`<span
1368
- class="set-dot"
1369
- title="Clear $id"
1370
- @click=${(e: Event) => {
1371
- e.stopPropagation();
1372
- transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, path, "$id"));
1373
- }}
1374
- ></span>`
1375
- : nothing
1376
- }
1377
- <sp-field-label size="s">ID</sp-field-label>
1378
- </div>
1379
- ${spTextField("prop:$id", String(node.$id || ""), (v: string) =>
1113
+ },
1114
+ seedFor: (mode) =>
1115
+ mode === "expression"
1116
+ ? {
1117
+ $expression: {
1118
+ initial: tagName,
1119
+ operator: "?:",
1120
+ target: null,
1121
+ value: tagName,
1122
+ },
1123
+ }
1124
+ : undefined,
1125
+ staticWidget: spTextField(fieldKey("tagName"), tagName, (v: string) => {
1126
+ transactDoc(activeTab.value, (t) =>
1127
+ mutateUpdateProperty(t, path, "tagName", v || undefined),
1128
+ );
1129
+ }),
1130
+ stateDefs: bindableSignalNames(activeTab.value!.doc.document),
1131
+ value: node.tagName as JsonValue | undefined,
1132
+ });
1133
+ return renderFieldRow({
1134
+ hasValue: chosenTag !== null,
1135
+ label: "Tag",
1136
+ labelExtra: tagSlot.modeButton,
1137
+ prop: "tagName",
1138
+ widget: tagSlot.widget,
1139
+ });
1140
+ })()}
1141
+ ${renderFieldRow({
1142
+ hasValue: Boolean(node.$id),
1143
+ label: "ID",
1144
+ prop: "$id",
1145
+ ...(node.$id ? { provenance: propertyChip("$id", "ID") } : {}),
1146
+ widget: spTextField(fieldKey("$id"), String(node.$id || ""), (v: string) =>
1380
1147
  transactDoc(activeTab.value, (t) =>
1381
1148
  mutateUpdateProperty(t, path, "$id", v || undefined),
1382
1149
  ),
1383
- )}
1384
- </div>
1385
- <div class="style-row" data-prop="className">
1386
- <div class="style-row-label">
1387
- ${
1388
- node.className
1389
- ? html`<span
1390
- class="set-dot"
1391
- title="Clear class"
1392
- @click=${(e: Event) => {
1393
- e.stopPropagation();
1394
- transactDoc(activeTab.value, (t) =>
1395
- mutateUpdateProperty(t, path, "className"),
1396
- );
1397
- }}
1398
- ></span>`
1399
- : nothing
1400
- }
1401
- <sp-field-label size="s">Class</sp-field-label>
1402
- </div>
1403
- ${spTextField("prop:className", String(node.className || ""), (v: string) =>
1150
+ ),
1151
+ })}
1152
+ ${renderFieldRow({
1153
+ hasValue: Boolean(node.className),
1154
+ label: "Class",
1155
+ prop: "className",
1156
+ ...(node.className ? { provenance: propertyChip("className", "class") } : {}),
1157
+ widget: spTextField(fieldKey("className"), String(node.className || ""), (v: string) =>
1404
1158
  transactDoc(activeTab.value, (t) =>
1405
1159
  mutateUpdateProperty(t, path, "className", v || undefined),
1406
1160
  ),
1407
- )}
1408
- </div>
1161
+ ),
1162
+ })}
1409
1163
  ${
1410
1164
  !Array.isArray(node.children) || node.children.length === 0
1411
- ? html`
1412
- <div class="style-row" data-prop="textContent">
1413
- <div class="style-row-label">
1414
- ${
1415
- node.textContent !== undefined
1416
- ? html`<span
1417
- class="set-dot"
1418
- title="Clear text"
1419
- @click=${(e: Event) => {
1420
- e.stopPropagation();
1421
- transactDoc(activeTab.value, (t) =>
1422
- mutateUpdateProperty(t, path, "textContent"),
1423
- );
1424
- }}
1425
- ></span>`
1426
- : nothing
1427
- }
1428
- <sp-field-label size="s">Text Content</sp-field-label>
1429
- ${textSlot.modeButton}
1430
- </div>
1431
- ${textSlot.widget}
1432
- </div>
1433
- `
1165
+ ? renderFieldRow({
1166
+ hasValue: node.textContent !== undefined,
1167
+ label: "Text Content",
1168
+ labelExtra: textSlot.modeButton,
1169
+ prop: "textContent",
1170
+ widget: textSlot.widget,
1171
+ ...(node.textContent === undefined
1172
+ ? {}
1173
+ : { provenance: propertyChip("textContent", "text") }),
1174
+ })
1434
1175
  : nothing
1435
1176
  }
1436
- <div class="style-row" data-prop="hidden">
1437
- <div class="style-row-label">
1438
- ${
1439
- node.hidden
1440
- ? html`<span
1441
- class="set-dot"
1442
- title="Clear hidden"
1443
- @click=${(e: Event) => {
1444
- e.stopPropagation();
1445
- transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, path, "hidden"));
1446
- }}
1447
- ></span>`
1448
- : nothing
1449
- }
1450
- <sp-field-label size="s">Hidden</sp-field-label>
1451
- </div>
1452
- <sp-checkbox
1453
- size="s"
1454
- .checked=${live(Boolean(node.hidden))}
1455
- @change=${(e: Event) =>
1456
- transactDoc(activeTab.value, (t) =>
1457
- mutateUpdateProperty(
1458
- t,
1459
- path,
1460
- "hidden",
1461
- (e.target as HTMLInputElement).checked || undefined,
1462
- ),
1463
- )}
1464
- >
1465
- </sp-checkbox>
1466
- </div>
1177
+ ${renderFieldRow({
1178
+ hasValue: Boolean(node.hidden),
1179
+ label: "Hidden",
1180
+ prop: "hidden",
1181
+ ...(node.hidden ? { provenance: propertyChip("hidden", "hidden") } : {}),
1182
+ widget: html`
1183
+ <sp-checkbox
1184
+ size="s"
1185
+ .checked=${live(Boolean(node.hidden))}
1186
+ @change=${(e: Event) =>
1187
+ transactDoc(activeTab.value, (t) =>
1188
+ mutateUpdateProperty(
1189
+ t,
1190
+ path,
1191
+ "hidden",
1192
+ (e.target as HTMLInputElement).checked || undefined,
1193
+ ),
1194
+ )}
1195
+ >
1196
+ </sp-checkbox>
1197
+ `,
1198
+ })}
1467
1199
  </div>
1468
1200
  </sp-accordion-item>
1469
1201
  `;
1470
1202
 
1471
- const repeaterT = isMapNode
1472
- ? html`
1473
- <sp-accordion-item label="Repeater" open>
1474
- <div class="style-section-body">
1475
- ${renderRepeaterFieldsTemplate(node, path, mapSignals)}
1476
- </div>
1477
- </sp-accordion-item>
1478
- `
1479
- : nothing;
1480
-
1481
- const switchT = isSwitchNode
1482
- ? html`
1483
- <sp-accordion-item label="Switch" open>
1484
- <div class="style-section-body">
1485
- ${renderSwitchFieldsTemplate(node, path, mapSignals)}
1486
- </div>
1487
- </sp-accordion-item>
1488
- `
1489
- : nothing;
1490
-
1491
- const observedAttrsT =
1492
- isCustomElementDoc({ document: tab.doc.document }) && isRoot
1493
- ? (() => {
1494
- const state = tab.doc.document.state || {};
1495
- const entries = Object.entries(state).filter(
1496
- ([, d]) => (d as Record<string, unknown>).attribute,
1497
- );
1498
- return html`
1499
- <sp-accordion-item label="Observed Attributes" ?open=${isSectionOpen("__observed")}>
1500
- <div class="style-section-body">
1501
- ${
1502
- entries.length === 0
1503
- ? html`<div class="empty-state">
1504
- No attributes declared. Set "attribute" on a state entry.
1505
- </div>`
1506
- : entries.map(([key, d]) => {
1507
- const def = d as Record<string, unknown>;
1508
- return html`
1509
- <div
1510
- style="display:flex;gap:6px;align-items:center;padding:2px 0;font-size:var(--spectrum-font-size-50, 11px)"
1511
- >
1512
- <code style="font-family:var(--font-mono);color:var(--accent)"
1513
- >${def.attribute}</code
1514
- >
1515
- <span style="color:var(--fg-dim)"> → </span>
1516
- <span>${key}</span>
1517
- ${
1518
- def.type
1519
- ? html`<span
1520
- style="margin-left:auto;color:var(--fg-dim);font-size:10px"
1521
- >${def.type}</span
1522
- >`
1523
- : nothing
1524
- }
1525
- ${
1526
- def.reflects
1527
- ? html`<span
1528
- style="font-size:9px;background:var(--hover-bg);padding:1px 4px;border-radius:var(--radius)"
1529
- >reflects</span
1530
- >`
1531
- : nothing
1532
- }
1533
- </div>
1534
- `;
1535
- })
1536
- }
1537
- </div>
1538
- </sp-accordion-item>
1539
- `;
1540
- })()
1541
- : nothing;
1542
-
1203
+ const editingDefinition = isCustomInstance && isComponentDefinitionOpen(node);
1543
1204
  const compPropsT = isCustomInstance
1544
1205
  ? html`
1545
- <sp-accordion-item label="Component Props" open>
1206
+ <sp-accordion-item
1207
+ label=${editingDefinition ? "Component Defaults" : "Component Settings"}
1208
+ open
1209
+ >
1546
1210
  <div class="style-section-body">
1547
1211
  ${renderComponentPropsFieldsTemplate(node, path, mapSignals, ctx.navigateToComponent)}
1548
1212
  </div>
@@ -1550,24 +1214,29 @@ export function renderPropertiesPanelTemplate(ctx: {
1550
1214
  `
1551
1215
  : nothing;
1552
1216
 
1217
+ // "Used on N pages →". Only for a component instance: an ordinary <div> is not a thing that can
1218
+ // Be reused, so the question does not arise.
1219
+ const usagesT = isCustomInstance
1220
+ ? renderUsagesSection(
1221
+ displayTagName(tagName),
1222
+ componentRegistry.find((c) => c.tagName === displayTagName(tagName))?.path ?? null,
1223
+ )
1224
+ : nothing;
1225
+
1553
1226
  const attrSectionTemplates = htmlMeta.$sections
1554
1227
  .filter((sec) => attrSections[sec.key]!.length > 0)
1555
1228
  .map((sec) => {
1556
1229
  const sectionAttrs = attrSections[sec.key]!;
1557
- const hasAnySet = sectionAttrs.some(
1230
+ const setCount = sectionAttrs.filter(
1558
1231
  (a: { name: string; entry: HtmlMetaEntry }) => attrs[a.name] !== undefined,
1559
- );
1232
+ ).length;
1560
1233
  return html`
1561
1234
  <sp-accordion-item
1562
1235
  label=${sec.label}
1563
1236
  ?open=${isSectionOpen(sec.key)}
1564
1237
  @sp-accordion-item-toggle=${() => toggleSection(sec.key)}
1565
1238
  >
1566
- ${
1567
- hasAnySet
1568
- ? html`<span slot="heading" class="set-dot set-dot--section"></span>`
1569
- : nothing
1570
- }
1239
+ ${sectionDot(setCount)}
1571
1240
  <div class="style-section-body">
1572
1241
  ${sectionAttrs.map((a: { name: string; entry: HtmlMetaEntry }) =>
1573
1242
  renderAttrRow(a.name, a.entry, attrs[a.name]),
@@ -1585,107 +1254,245 @@ export function renderPropertiesPanelTemplate(ctx: {
1585
1254
  ?open=${isSectionOpen("__custom")}
1586
1255
  @sp-accordion-item-toggle=${() => toggleSection("__custom")}
1587
1256
  >
1588
- ${
1589
- customAttrs.length > 0
1590
- ? html`<span slot="heading" class="set-dot set-dot--section"></span>`
1591
- : nothing
1592
- }
1257
+ ${sectionDot(customAttrs.length)}
1593
1258
  <div class="style-section-body">
1594
- ${renderCustomAttrsFieldsTemplate(node, path, attrs, knownAttrNames)}
1259
+ ${renderCustomAttrsFieldsTemplate(path, attrs, knownAttrNames)}
1595
1260
  </div>
1596
1261
  </sp-accordion-item>
1597
1262
  `
1598
1263
  : nothing;
1599
1264
 
1600
- const mediaT = isRoot
1601
- ? html`
1602
- <sp-accordion-item
1603
- label="Media"
1604
- ?open=${isSectionOpen("__media")}
1605
- @sp-accordion-item-toggle=${() => toggleSection("__media")}
1606
- >
1607
- <div class="style-section-body">${renderMediaFieldsTemplate(node)}</div>
1608
- </sp-accordion-item>
1609
- `
1610
- : nothing;
1611
-
1612
- const cssPropsT =
1613
- isCustomElementDoc({ document: tab.doc.document }) && isRoot
1614
- ? (() => {
1615
- const style = node.style || {};
1616
- const cssProps = Object.entries(style).filter(([k]) => k.startsWith("--"));
1617
- if (cssProps.length === 0) {
1618
- return nothing;
1619
- }
1620
- return html`
1621
- <sp-accordion-item
1622
- label="CSS Properties"
1623
- ?open=${isSectionOpen("__cssprops")}
1624
- @sp-accordion-item-toggle=${() => toggleSection("__cssprops")}
1625
- >
1626
- <div class="style-section-body">
1627
- ${cssProps.map(
1628
- ([prop, val]) => html`
1629
- <div
1630
- style="display:flex;gap:6px;align-items:center;padding:2px 0;font-size:var(--spectrum-font-size-50, 11px)"
1631
- >
1632
- <code style="font-family:var(--font-mono);color:var(--accent)">${prop}</code>
1633
- <span style="margin-left:auto;color:var(--fg-dim)">${String(val)}</span>
1634
- </div>
1635
- `,
1636
- )}
1637
- </div>
1638
- </sp-accordion-item>
1639
- `;
1640
- })()
1641
- : nothing;
1642
-
1643
- const cssPartsT =
1644
- isCustomElementDoc({ document: tab.doc.document }) && isRoot
1645
- ? (() => {
1646
- const parts = collectCssParts(tab.doc.document);
1647
- if (parts.length === 0) {
1648
- return nothing;
1649
- }
1650
- return html`
1651
- <sp-accordion-item
1652
- label="CSS Parts"
1653
- ?open=${isSectionOpen("__cssparts")}
1654
- @sp-accordion-item-toggle=${() => toggleSection("__cssparts")}
1655
- >
1656
- <div class="style-section-body">
1657
- ${parts.map(
1658
- (p) => html`
1659
- <div
1660
- style="display:flex;gap:6px;align-items:center;padding:2px 0;font-size:var(--spectrum-font-size-50, 11px)"
1661
- >
1662
- <code style="font-family:var(--font-mono);color:var(--accent)"
1663
- >${p.name}</code
1664
- >
1665
- <span style="color:var(--fg-dim)">&lt;${p.tag}&gt;</span>
1666
- </div>
1667
- `,
1668
- )}
1669
- </div>
1670
- </sp-accordion-item>
1671
- `;
1672
- })()
1673
- : nothing;
1674
-
1675
- const pageT = isRoot ? renderPageSection(node) : nothing;
1676
-
1677
- // ── Assemble ──
1678
- const tpl = html`
1265
+ return html`
1679
1266
  <div class="style-sidebar">
1680
1267
  <sp-accordion allow-multiple size="s">
1681
- ${pageT} ${isMapNode ? repeaterT : elemT} ${isMapNode ? nothing : observedAttrsT}
1682
- ${isMapNode ? nothing : switchT} ${isMapNode ? nothing : compPropsT}
1683
- ${isMapNode ? nothing : attrSectionTemplates} ${isMapNode ? nothing : customSectionT}
1684
- ${isMapNode ? nothing : mediaT} ${isMapNode ? nothing : cssPropsT}
1685
- ${isMapNode ? nothing : cssPartsT}
1268
+ ${elemT} ${compPropsT} ${usagesT} ${attrSectionTemplates} ${customSectionT}
1686
1269
  </sp-accordion>
1687
1270
  </div>
1688
1271
  `;
1272
+ }
1689
1273
 
1690
- return tpl;
1274
+ /**
1275
+ * The collapsed-header provenance dot (§6.2), and the end of a false affordance.
1276
+ *
1277
+ * It inherited `.set-dot`'s `cursor: pointer` and its "turns red on hover" rule, which together say
1278
+ * "click me to clear this" — and nothing happened, because it never had a handler. It is not a
1279
+ * control and it is not deleted either: §6.2 puts a provenance dot on collapsed accordion headers
1280
+ * precisely so a section's state is legible while closed, and clicking anywhere in the header
1281
+ * already toggles the section, so a second, different meaning for the same click would be worse
1282
+ * than the silence. So it keeps the information and gives up the affordance: an inert, labelled
1283
+ * indicator that says how many values in the section are set.
1284
+ */
1285
+ function sectionDot(setCount: number) {
1286
+ return setCount > 0
1287
+ ? html`<span
1288
+ slot="heading"
1289
+ class="set-dot set-dot--section"
1290
+ aria-hidden="true"
1291
+ title=${`${setCount} value${setCount === 1 ? "" : "s"} set in this section`}
1292
+ ></span>`
1293
+ : nothing;
1294
+ }
1295
+
1296
+ /** Which of §6.2's states an HTML-attribute row is in. Attributes have no third cascade layer. */
1297
+ function attributeProvenance(
1298
+ attr: string,
1299
+ value: unknown,
1300
+ hasVal: boolean,
1301
+ onClear: () => void,
1302
+ mixedCount = 0,
1303
+ ): FieldProvenance {
1304
+ // Mixed first, for the same reason the Style tab decides it first: it is a fact about the
1305
+ // SELECTION, and the primary's own value says nothing about whether the others agree. A count of
1306
+ // 0 (a selection of one) can never reach this branch, so single-selection rows are untouched.
1307
+ if (mixedCount > 0) {
1308
+ return { donor: String(mixedCount), onClick: onClear, state: "mixed" };
1309
+ }
1310
+ if (hasVal && (isRef(value) || (typeof value === "string" && value.includes("${")))) {
1311
+ return boundProvenance(value, `Bound — ${attr}`);
1312
+ }
1313
+ return hasVal ? { onClick: onClear, state: "set", title: `Clear ${attr}` } : { state: "default" };
1314
+ }
1315
+
1316
+ /**
1317
+ * How many selected elements disagree about `key` on `read` — 0 when they agree or there is one.
1318
+ *
1319
+ * The Content tab's two cascades (HTML attributes and component props) both need the same answer
1320
+ * about the same set, so the question is asked once here and answered with `unifyValues`, the same
1321
+ * comparison the Style tab uses.
1322
+ *
1323
+ * @param {JxMutableNode} doc
1324
+ * @param {readonly JxPath[]} targets
1325
+ * @param {(node: JxMutableNode | undefined) => unknown} read
1326
+ * @returns {number}
1327
+ */
1328
+ export function mixedAcrossSelection(
1329
+ doc: JxMutableNode,
1330
+ targets: readonly JxPath[],
1331
+ read: (node: JxMutableNode | undefined) => unknown,
1332
+ ): number {
1333
+ if (targets.length < 2) {
1334
+ return 0;
1335
+ }
1336
+ const values = targets.map((path) => read(getNodeAtPath(doc, path) as JxMutableNode | undefined));
1337
+ return unifyValues(values).mixed ? targets.length : 0;
1338
+ }
1339
+
1340
+ // ─── Section state ────────────────────────────────────────────────────────────
1341
+
1342
+ /**
1343
+ * The Inspector's fixed section keys — the accordion rows the inspector always draws.
1344
+ *
1345
+ * Attribute-schema sections add their own keys at render time (a class's `$section`), so this is a
1346
+ * floor, not a closed set: {@link inspectorSectionKeys} unions it with whatever the OPEN DOCUMENT
1347
+ * declares, and that union is what `inspector.setSection` validates against. A fixed enum would
1348
+ * refuse a section the user is looking at; no validation at all would accept the label the old
1349
+ * `inspector.toggleSection` step passed ("Element", not `__element`) and silently do nothing.
1350
+ *
1351
+ * `__media` left with the breakpoint definitions, which are Project Settings › Contexts now (P4).
1352
+ * `__observed`, `__cssprops` and `__cssparts` are drawn by the Logic tab; the key space is the
1353
+ * INSPECTOR's, not one tab's, so `inspector.setSection` keeps addressing all of them.
1354
+ */
1355
+ export const INSPECTOR_SECTION_KEYS = [
1356
+ "__element",
1357
+ "__observed",
1358
+ "__usages",
1359
+ "__custom",
1360
+ "__cssprops",
1361
+ "__cssparts",
1362
+ ] as const;
1363
+
1364
+ export type InspectorSectionKey = (typeof INSPECTOR_SECTION_KEYS)[number];
1365
+
1366
+ /** Section keys addressable right now: the fixed rows plus any already recorded for this tab. */
1367
+ export function inspectorSectionKeys(): string[] {
1368
+ const recorded = Object.keys(activeTab.value?.session.ui.inspectorSections ?? {});
1369
+ return [...new Set<string>([...INSPECTOR_SECTION_KEYS, ...recorded])];
1370
+ }
1371
+
1372
+ /**
1373
+ * Whether one Inspector section is expanded, falling back to the caller's default.
1374
+ *
1375
+ * The fallback is the caller's because it is not a constant: an attribute section opens itself when
1376
+ * one of its attributes is set, and Usage never does. Only the panel drawing the row knows.
1377
+ */
1378
+ export function isInspectorSectionOpen(section: string, fallback: boolean): boolean {
1379
+ const recorded = activeTab.value?.session.ui.inspectorSections?.[section];
1380
+ return recorded === undefined ? fallback : recorded;
1381
+ }
1382
+
1383
+ /**
1384
+ * Open or close one Inspector section, per tab.
1385
+ *
1386
+ * The pair `{ section, open }` is the whole point: `inspector.toggleSection` named a DELTA against
1387
+ * a section whose state depended on the selected node's own attributes (`autoOpen`), so the same
1388
+ * three manifest steps opened a section on one document and closed it on the next. Writing a new
1389
+ * object rather than mutating in place is what the reactive read in `isSectionOpen` depends on.
1390
+ */
1391
+ export function setInspectorSection(section: string, open: boolean): void {
1392
+ const tab = activeTab.value;
1393
+ if (!tab) {
1394
+ return;
1395
+ }
1396
+ tab.session.ui.inspectorSections = { ...tab.session.ui.inspectorSections, [section]: open };
1397
+ }
1398
+
1399
+ // ─── Commands ─────────────────────────────────────────────────────────────────
1400
+
1401
+ /**
1402
+ * The Inspector's section verb. **This is the setter that retires `inspector.toggleSection`.**
1403
+ *
1404
+ * @returns {AnyCommand[]}
1405
+ */
1406
+ export function inspectorCommands(): AnyCommand[] {
1407
+ return [
1408
+ {
1409
+ args: argsSchema({
1410
+ open: booleanProperty("True to expand the section, false to collapse it."),
1411
+ section: stringProperty(
1412
+ "The section key — one of the fixed rows (__element, __observed, __usages, " +
1413
+ "__custom, __cssprops, __cssparts) or an attribute schema's own $section.",
1414
+ ),
1415
+ }),
1416
+ category: "View",
1417
+ id: "inspector.setSection",
1418
+ level: "document",
1419
+ menus: ["palette"],
1420
+ group: "4_docks",
1421
+ requires: "an open document",
1422
+ when: (ctx) => ctx.document.open,
1423
+ run: (_commandCtx, args) => {
1424
+ const open = booleanArg("inspector.setSection", args, "open");
1425
+ const section = stringArg("inspector.setSection", args, "section");
1426
+ const known = inspectorSectionKeys();
1427
+ if (!known.includes(section)) {
1428
+ throw new RangeError(
1429
+ `command "inspector.setSection" argument "section": "${section}" is not a section ` +
1430
+ `this document declares — declared: ${known.join(", ")}`,
1431
+ );
1432
+ }
1433
+ setInspectorSection(section, open);
1434
+ },
1435
+ title: "Show Inspector Section",
1436
+ },
1437
+ {
1438
+ /**
1439
+ * Find Usages. Defined ONCE, here, and rendered twice: the palette gets it from the app
1440
+ * registry, and `editor/context-menu.ts` pulls the `context/element` records out of this same
1441
+ * set into the menu it builds — so the row and the palette entry cannot drift in title, chord
1442
+ * or availability. It reads `ctx.selection.kind`, which both contexts populate from the node
1443
+ * under the cursor, so one `run` serves the menu's target and the canvas selection alike.
1444
+ *
1445
+ * `when` is the capability, not a `try`: a host with no `findReferences` route hides the
1446
+ * command rather than offering a verb that answers "0 usages" for a component on every page.
1447
+ */
1448
+ category: "Selection",
1449
+ id: "selection.findUsages",
1450
+ level: "selection",
1451
+ menus: ["context/element", "palette"],
1452
+ group: "4_identity",
1453
+ undo: "none",
1454
+ when: (ctx) => ctx.capability.findReferences && ctx.selection.isComponentInstance,
1455
+ requires: "a component instance, on a backend that can search the project",
1456
+ run: () => {
1457
+ const tagName = componentSelectionTag();
1458
+ if (!tagName) {
1459
+ return;
1460
+ }
1461
+ const componentPath = componentRegistry.find((c) => c.tagName === tagName)?.path ?? null;
1462
+ setInspectorSection(USAGES_SECTION, true);
1463
+ void loadUsages({ tagName, ...(componentPath ? { path: componentPath } : {}) }).then(() =>
1464
+ renderOnly("rightPanel"),
1465
+ );
1466
+ },
1467
+ title: "Find Usages",
1468
+ },
1469
+ ];
1470
+ }
1471
+
1472
+ /**
1473
+ * The tag of the selected component instance, or null.
1474
+ *
1475
+ * Read from the document rather than from a menu target so the one record works in both registries:
1476
+ * the context menu's own `getContext` reports the right-clicked node's tag as `selection.kind`, and
1477
+ * the app's reports the canvas selection's. This is the fallback for the app registry, where the
1478
+ * selection lives on the tab.
1479
+ */
1480
+ function componentSelectionTag(): string | null {
1481
+ const tab = activeTab.value;
1482
+ const selection = primarySelection(tab?.session.selection);
1483
+ if (!tab || !selection) {
1484
+ return null;
1485
+ }
1486
+ const node = getNodeAtPath(tab.doc.document, selection);
1487
+ const tagName = typeof node?.tagName === "string" ? node.tagName : "";
1488
+ return tagName.includes("-") ? tagName : null;
1489
+ }
1490
+
1491
+ /**
1492
+ * Register the Inspector's section verb.
1493
+ *
1494
+ * @param {CommandRegistry} registry
1495
+ */
1496
+ export function registerInspectorCommands(registry: CommandRegistry): void {
1497
+ registry.registerAll(inspectorCommands());
1691
1498
  }