@jxsuite/studio 4.1.0 → 5.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 (612) hide show
  1. package/README.md +1 -1
  2. package/data/elements-meta.json +5 -15
  3. package/data/html-meta.json +51 -6
  4. package/dist/chunks/{2020-n6tfwnda.js → 2020-g79n01v9.js} +14 -14
  5. package/dist/chunks/action-group-sp6tna8v.js +118 -0
  6. package/dist/chunks/adapter-nj7tnjfn.js +13 -0
  7. package/dist/chunks/cf-account-picker-qwkm9zrf.js +495 -0
  8. package/dist/chunks/cf-settings-71bv5zn9.js +20 -0
  9. package/dist/chunks/color-area-v17df7b8.js +154 -0
  10. package/dist/chunks/color-field-anwybnkn.js +208 -0
  11. package/dist/chunks/color-slider-44ez96fc.js +79 -0
  12. package/dist/chunks/color-xgg3kfw7.js +35 -0
  13. package/dist/chunks/combobox-5e7jev23.js +236 -0
  14. package/dist/chunks/components-vj72gqtq.js +20 -0
  15. package/dist/chunks/content-source-5mex4x1h.js +46 -0
  16. package/dist/chunks/convert-to-repeater-n64h8znc.js +639 -0
  17. package/dist/chunks/dialog-s890b918.js +85 -0
  18. package/dist/chunks/editor-3qhbg51x.js +35 -0
  19. package/dist/chunks/entry-commands-234hjnqw.js +42 -0
  20. package/dist/chunks/extension-sections-3hpcambp.js +651 -0
  21. package/dist/chunks/file-ops-8yhd83xr.js +47 -0
  22. package/dist/chunks/files-hnxqxtsg.js +72 -0
  23. package/dist/chunks/format-host-779sfp2n.js +40 -0
  24. package/dist/chunks/fs-events-b88z7g4h.js +18 -0
  25. package/dist/chunks/git-panel-xwj8fpab.js +49 -0
  26. package/dist/chunks/icons-yy10wk9r.js +402 -0
  27. package/dist/chunks/iframe-entry-15g3n6xv.js +114 -0
  28. package/dist/chunks/iframe-entry-b27vq81n.js +203 -0
  29. package/dist/chunks/iframe-entry-nbyxee1y.js +154 -0
  30. package/dist/chunks/iframe-host-syr0f9sb.js +115 -0
  31. package/dist/chunks/{index-wcnt3r9g.js → index-mdmyx6s2.js} +11 -11
  32. package/dist/chunks/index-n4j7qxyr.js +109 -0
  33. package/dist/chunks/{javascript-q8kpmfah.js → javascript-xvrfrpkb.js} +8 -9
  34. package/dist/chunks/{jsonMode-dkr040ga.js → jsonMode-tyx2kj6p.js} +526 -527
  35. package/dist/chunks/jx-validate-j0wxk72w.js +15 -0
  36. package/dist/chunks/layers-ff81wthn.js +30 -0
  37. package/dist/chunks/layers-panel-pnnghw12.js +40 -0
  38. package/dist/chunks/listbox-6v2ac08j.js +44 -0
  39. package/dist/chunks/lit-html-m244w9nh.js +17 -0
  40. package/dist/chunks/media-picker-cqhhxhhe.js +33 -0
  41. package/dist/chunks/menu-9p2w46gj.js +15 -0
  42. package/dist/chunks/menu-q99sy9rf.js +285 -0
  43. package/dist/chunks/{monaco-binding-rj6xpx9y.js → monaco-binding-sw6en4dw.js} +12 -15
  44. package/dist/chunks/monaco-buffer-bhy3hb6m.js +24 -0
  45. package/dist/chunks/{monaco-setup-vx9bhp1d.js → monaco-setup-8r62fj6y.js} +27 -37
  46. package/dist/chunks/notify-628t0anh.js +21 -0
  47. package/dist/chunks/number-field-5cndyft8.js +72 -0
  48. package/dist/chunks/parse-0zx14y15.js +10 -0
  49. package/dist/chunks/platform-ts9cg1a4.js +15 -0
  50. package/dist/chunks/popover-47z3ytva.js +25 -0
  51. package/dist/chunks/publish-panel-5zt840sy.js +27 -0
  52. package/dist/chunks/quick-search-3748564r.js +45 -0
  53. package/dist/chunks/references-qvvxej7h.js +26 -0
  54. package/dist/chunks/right-panel-eccz92qt.js +45 -0
  55. package/dist/chunks/section-registry-5pq32t0q.js +25 -0
  56. package/dist/chunks/select-2r16ez41.js +49 -0
  57. package/dist/chunks/split-g7nm1rgb.js +191 -0
  58. package/dist/chunks/studio-0hqbpbd0.js +62 -0
  59. package/dist/chunks/{studio-kryvsw6j.js → studio-1k325pwf.js} +48 -48
  60. package/dist/chunks/{studio-b55y6ewx.js → studio-1knfqgy7.js} +58 -58
  61. package/dist/chunks/{studio-2b946z26.js → studio-1z1kcjv4.js} +3628 -6095
  62. package/dist/chunks/studio-3773086z.js +111 -0
  63. package/dist/chunks/{studio-4r1pv6zf.js → studio-38emantv.js} +22 -5
  64. package/dist/chunks/{studio-x813zp48.js → studio-3rz5md06.js} +30 -30
  65. package/dist/chunks/{studio-ynxwkrzd.js → studio-4wbb9ct0.js} +1953 -1785
  66. package/dist/chunks/studio-5te5tqng.js +81 -0
  67. package/dist/chunks/studio-5wt9nvhd.js +4763 -0
  68. package/dist/chunks/{studio-p4ngc046.js → studio-6ewqv390.js} +9 -9
  69. package/dist/chunks/{studio-9ab8gqsm.js → studio-6nzaktvy.js} +7 -7
  70. package/dist/chunks/studio-6xa21ej9.js +28 -0
  71. package/dist/chunks/{studio-5ehhackm.js → studio-727xh0e3.js} +19 -19
  72. package/dist/chunks/{studio-gcye7qka.js → studio-7e11jpa9.js} +21 -21
  73. package/dist/chunks/{studio-q7v10cg2.js → studio-7r42c6ca.js} +17 -7
  74. package/dist/chunks/studio-7xnmgv66.js +13615 -0
  75. package/dist/chunks/studio-8scdew0e.js +258 -0
  76. package/dist/chunks/{studio-fbf92ckf.js → studio-93zafjcm.js} +275 -163
  77. package/dist/chunks/{studio-dg7zyt5x.js → studio-98b3z07n.js} +1 -1
  78. package/dist/chunks/studio-a79k90n9.js +1162 -0
  79. package/dist/chunks/studio-aj37eer3.js +422 -0
  80. package/dist/chunks/studio-b0k5tqgp.js +2432 -0
  81. package/dist/chunks/studio-b4s9eykd.js +2811 -0
  82. package/dist/chunks/{studio-f3wqjtk5.js → studio-d0300636.js} +5076 -18
  83. package/dist/chunks/{studio-b9b6t1dg.js → studio-e1f24eff.js} +5664 -5666
  84. package/dist/chunks/studio-e1pgga3k.js +839 -0
  85. package/dist/chunks/{studio-g1wdche0.js → studio-esrxgwh6.js} +80 -80
  86. package/dist/chunks/{studio-q43a5y9h.js → studio-g3jevyjt.js} +16 -16
  87. package/dist/chunks/studio-gb13095a.js +2806 -0
  88. package/dist/chunks/{studio-3n4ndrzh.js → studio-h4nvnjvz.js} +303 -303
  89. package/dist/chunks/{studio-dm19q43r.js → studio-hhvs4sdg.js} +10 -10
  90. package/dist/chunks/{studio-x4qkdat5.js → studio-js8nv53n.js} +1749 -2481
  91. package/dist/chunks/{studio-qjy2z9bv.js → studio-jxp12rnj.js} +32 -137
  92. package/dist/chunks/{studio-trxrabqf.js → studio-k75n7vhp.js} +527 -283
  93. package/dist/chunks/{studio-q9jd1a60.js → studio-kcgff4r0.js} +15 -18
  94. package/dist/chunks/studio-kkqkcp3d.js +17668 -0
  95. package/dist/chunks/studio-mhhyamxf.js +763 -0
  96. package/dist/chunks/{studio-cft9r4r7.js → studio-nq5yf5mp.js} +22 -22
  97. package/dist/chunks/{studio-685s0ytq.js → studio-nqgawfks.js} +572 -572
  98. package/dist/chunks/studio-nzzpvzx9.js +185 -0
  99. package/dist/chunks/{studio-xrapxwwc.js → studio-p1dw8wfw.js} +55 -29
  100. package/dist/chunks/{studio-3bqpr0zh.js → studio-pxbgypmg.js} +299 -110
  101. package/dist/chunks/studio-q0a3yk4n.js +33 -0
  102. package/dist/chunks/{studio-wcy8trfx.js → studio-q3x06vmj.js} +23 -22
  103. package/dist/chunks/{studio-6yw4y3my.js → studio-qhhm6hbt.js} +8 -8
  104. package/dist/chunks/studio-qk4bfsq2.js +362 -0
  105. package/dist/chunks/{studio-msd847zn.js → studio-qk90fgqm.js} +361 -90
  106. package/dist/chunks/{studio-jkftxyvz.js → studio-r6nvacqs.js} +5861 -2154
  107. package/dist/chunks/{studio-53n8bp04.js → studio-r6xmmtfh.js} +1 -1
  108. package/dist/chunks/{studio-tvgyw9xw.js → studio-rnyf3f8e.js} +83 -250
  109. package/dist/chunks/studio-rs76k67r.js +12 -0
  110. package/dist/chunks/studio-sbjyc8bt.js +954 -0
  111. package/dist/chunks/{studio-1wagb550.js → studio-syg2tadj.js} +11 -11
  112. package/dist/chunks/{studio-t5wqwzdh.js → studio-tjjk7jvk.js} +1 -1
  113. package/dist/chunks/studio-tqgryxwg.js +1171 -0
  114. package/dist/chunks/studio-try13h8c.js +8448 -0
  115. package/dist/chunks/studio-vfq8qpjt.js +1219 -0
  116. package/dist/chunks/studio-vhv5tgxm.js +51 -0
  117. package/dist/chunks/{studio-ahn3pdgx.js → studio-vw7mzp6z.js} +52 -38
  118. package/dist/chunks/studio-wqpngpsk.js +174 -0
  119. package/dist/chunks/studio-wqz6dggm.js +1356 -0
  120. package/dist/chunks/{studio-92ab2jqe.js → studio-y9zedyjn.js} +14 -14
  121. package/dist/chunks/studio-yxa81f45.js +12 -0
  122. package/dist/chunks/studio-zdn2g6dt.js +148 -0
  123. package/dist/chunks/swatch-group-t0n6h2rn.js +139 -0
  124. package/dist/chunks/tabs-wbp1cdws.js +156 -0
  125. package/dist/chunks/textfield-abk6qyee.js +73 -0
  126. package/dist/chunks/toast-780pvfbv.js +27 -0
  127. package/dist/chunks/toast-host-3sqv46sn.js +116 -0
  128. package/dist/chunks/toolbar-xbh5bhtr.js +194 -0
  129. package/dist/chunks/tooltip-eptb614w.js +369 -0
  130. package/dist/chunks/tree-a73p0pqm.js +367 -0
  131. package/dist/chunks/{tsMode-76kr53q0.js → tsMode-jy4m86rv.js} +83 -85
  132. package/dist/chunks/workspace-t55jdjbx.js +69 -0
  133. package/dist/chunks/{ws-client-6shc5rze.js → ws-client-ny4zc51r.js} +20 -22
  134. package/dist/iframe-entry.js +1873 -673
  135. package/dist/studio.js +52757 -36535
  136. package/dist/workers/editor.worker.js +1040 -1085
  137. package/dist/workers/json.worker.js +1946 -1991
  138. package/dist/workers/ts.worker.js +4960 -5005
  139. package/index.html +1 -0
  140. package/package.json +18 -52
  141. package/src/about/about-modal.ts +72 -123
  142. package/src/browse/library-commands.ts +20 -49
  143. package/src/browse/library-layouts.ts +87 -218
  144. package/src/browse/library-pane.ts +406 -438
  145. package/src/canvas/canvas-helpers.ts +0 -49
  146. package/src/canvas/canvas-live-render.ts +25 -2
  147. package/src/canvas/canvas-render.ts +666 -231
  148. package/src/canvas/canvas-surface.ts +0 -1
  149. package/src/canvas/canvas-utils.ts +333 -113
  150. package/src/canvas/dialog-path.ts +76 -0
  151. package/src/canvas/dialog-state.ts +67 -0
  152. package/src/canvas/diff-marks.ts +252 -0
  153. package/src/canvas/diff-toolbar.ts +349 -0
  154. package/src/canvas/diff-view.ts +123 -0
  155. package/src/canvas/edit-width-drag.ts +187 -0
  156. package/src/canvas/edit-width.ts +150 -0
  157. package/src/canvas/iframe-entry.ts +125 -8
  158. package/src/canvas/iframe-host.ts +271 -15
  159. package/src/canvas/iframe-interaction.ts +133 -13
  160. package/src/canvas/iframe-patch.ts +7 -1
  161. package/src/canvas/iframe-position.ts +5 -6
  162. package/src/canvas/iframe-protocol.ts +106 -5
  163. package/src/canvas/iframe-render.ts +373 -5
  164. package/src/canvas/iframe-slash.ts +2 -2
  165. package/src/canvas/iframe-subtree.ts +5 -8
  166. package/src/canvas/path-mapping.ts +25 -3
  167. package/src/canvas/popover-path.ts +106 -0
  168. package/src/canvas/popover-state.ts +155 -0
  169. package/src/canvas/surface-registry.ts +21 -10
  170. package/src/collab/presence-chips.ts +51 -73
  171. package/src/commands/app-commands.ts +10 -0
  172. package/src/commands/budget.ts +12 -0
  173. package/src/commands/command-args.ts +404 -6
  174. package/src/commands/defaults.ts +44 -14
  175. package/src/commands/live-context.ts +11 -5
  176. package/src/commands/reference.ts +12 -2
  177. package/src/commands/registry.ts +177 -10
  178. package/src/commands/run-reported.ts +120 -0
  179. package/src/content/entry-commands.ts +16 -23
  180. package/src/content/entry-editor.ts +144 -101
  181. package/src/editor/canvas-slash-bridge.ts +2 -2
  182. package/src/editor/context-menu.ts +49 -200
  183. package/src/editor/convert-to-component.ts +42 -82
  184. package/src/editor/convert-to-repeater.ts +132 -173
  185. package/src/editor/inline-edit.ts +11 -0
  186. package/src/editor/shortcuts.ts +76 -49
  187. package/src/editor/slash-menu.ts +150 -157
  188. package/src/files/components.ts +39 -0
  189. package/src/files/file-ops.ts +117 -11
  190. package/src/files/files.ts +944 -660
  191. package/src/files/json-layout.ts +474 -0
  192. package/src/files/serialize-document.ts +8 -1
  193. package/src/format/convert-file.ts +16 -10
  194. package/src/format/format-host.ts +50 -1
  195. package/src/github/github-auth.ts +71 -29
  196. package/src/github/github-publish.ts +57 -65
  197. package/src/grid/cell-editors.ts +46 -4
  198. package/src/grid/cell-popovers.ts +65 -62
  199. package/src/grid/grid-open.ts +86 -64
  200. package/src/grid/grid-panel.ts +390 -469
  201. package/src/grid/redirects-grid.ts +40 -44
  202. package/src/hosting/layout.ts +2 -0
  203. package/src/i18n/i18n-commands.ts +137 -62
  204. package/src/media/media-pane.ts +83 -133
  205. package/src/new-project/add-repo-modal.ts +125 -176
  206. package/src/new-project/import-tab.ts +181 -252
  207. package/src/new-project/location-fields.ts +93 -160
  208. package/src/new-project/new-project-modal.ts +376 -367
  209. package/src/packages/jxsuite-update.ts +76 -18
  210. package/src/packages/pull-package-sync.ts +5 -1
  211. package/src/panels/activity-panel.ts +137 -104
  212. package/src/panels/ai-chat/chat-markdown.ts +15 -13
  213. package/src/panels/ai-chat/chat-view.ts +307 -472
  214. package/src/panels/ai-chat/composer.ts +175 -163
  215. package/src/panels/ai-chat/sessions-view.ts +31 -62
  216. package/src/panels/ai-panel.ts +213 -185
  217. package/src/panels/block-action-bar.ts +444 -645
  218. package/src/panels/bottom-dock.ts +112 -47
  219. package/src/panels/chat-panel.ts +19 -14
  220. package/src/panels/data-explorer.ts +141 -102
  221. package/src/panels/data-grid.ts +183 -150
  222. package/src/panels/dnd.ts +75 -20
  223. package/src/panels/editors.ts +26 -19
  224. package/src/panels/elements-panel.ts +243 -186
  225. package/src/panels/empty-state.ts +26 -42
  226. package/src/panels/events-panel.ts +950 -729
  227. package/src/panels/formula-workspace.ts +434 -207
  228. package/src/panels/frontmatter-fields.ts +185 -153
  229. package/src/panels/frontmatter-panel.ts +411 -187
  230. package/src/panels/git-diff-open.ts +148 -0
  231. package/src/panels/git-panel.ts +679 -605
  232. package/src/panels/head-panel.ts +617 -435
  233. package/src/panels/i18n-panel.ts +148 -135
  234. package/src/panels/imports-panel.ts +388 -414
  235. package/src/panels/jump-bar.ts +175 -147
  236. package/src/panels/layers-panel.ts +785 -671
  237. package/src/panels/left-panel.ts +166 -94
  238. package/src/panels/navigator-panels.ts +1 -1
  239. package/src/panels/pane-context.ts +529 -677
  240. package/src/panels/pane-grid.ts +265 -241
  241. package/src/panels/panel-registry.ts +15 -4
  242. package/src/panels/problems-panel.ts +181 -99
  243. package/src/panels/properties-panel.ts +1525 -966
  244. package/src/panels/provenance.ts +31 -113
  245. package/src/panels/quick-search.ts +223 -184
  246. package/src/panels/right-panel.ts +135 -169
  247. package/src/panels/seo-modal.ts +346 -167
  248. package/src/panels/settings-menu.ts +114 -661
  249. package/src/panels/settings-pane.ts +102 -86
  250. package/src/panels/signals-panel.ts +1318 -1172
  251. package/src/panels/statement-editor.ts +556 -428
  252. package/src/panels/style-panel.ts +1367 -630
  253. package/src/panels/style-utils.ts +36 -32
  254. package/src/panels/stylebook-doc.ts +30 -73
  255. package/src/panels/stylebook-layers-panel.ts +202 -109
  256. package/src/panels/stylebook-panel.ts +124 -88
  257. package/src/panels/tab-strip.ts +386 -288
  258. package/src/platforms/cloud.ts +106 -16
  259. package/src/platforms/devserver.ts +13 -0
  260. package/src/publish/deploy-checklist.ts +124 -78
  261. package/src/publish/publish-commands.ts +4 -12
  262. package/src/publish/publish-panel.ts +134 -328
  263. package/src/services/a11y-report.ts +100 -43
  264. package/src/services/ai-command-tools.ts +538 -0
  265. package/src/services/ai-project-tools.ts +8 -2
  266. package/src/services/ai-session-store.ts +6 -0
  267. package/src/services/ai-system-prompt.ts +112 -24
  268. package/src/services/ai-tools.ts +36 -212
  269. package/src/services/ai-write-report.ts +174 -0
  270. package/src/services/document-assistant.ts +59 -8
  271. package/src/services/idle.ts +16 -14
  272. package/src/services/live-surfaces.ts +237 -0
  273. package/src/services/model-uri.ts +75 -0
  274. package/src/services/notify.ts +63 -6
  275. package/src/services/popover-report.ts +325 -0
  276. package/src/services/surface-registry.ts +56 -0
  277. package/src/services/tool-executor.ts +6 -0
  278. package/src/settings/contexts-section.ts +301 -299
  279. package/src/settings/contributed-section.ts +361 -222
  280. package/src/settings/css-vars-editor.ts +320 -325
  281. package/src/settings/defs-editor.ts +572 -535
  282. package/src/settings/dependencies-editor.ts +79 -107
  283. package/src/settings/extension-commands.ts +379 -0
  284. package/src/settings/extension-rows.ts +113 -0
  285. package/src/settings/extensions-section.ts +287 -0
  286. package/src/settings/general-settings.ts +130 -181
  287. package/src/settings/head-editor.ts +10 -305
  288. package/src/settings/locales-section.ts +146 -196
  289. package/src/settings/preferences-accounts.ts +1 -1
  290. package/src/settings/preferences-dialog.ts +350 -330
  291. package/src/settings/project-sections.ts +108 -181
  292. package/src/settings/schema-field-ui.ts +11 -399
  293. package/src/settings/section-registry.ts +8 -1
  294. package/src/settings/settings-document.ts +31 -29
  295. package/src/shell/tree.ts +42 -87
  296. package/src/shell.ts +48 -36
  297. package/src/studio.ts +104 -69
  298. package/src/style/token-ref.ts +0 -39
  299. package/src/surfaces/about.json +263 -0
  300. package/src/surfaces/about.ts +163 -0
  301. package/src/surfaces/add-repo.json +486 -0
  302. package/src/surfaces/add-repo.ts +236 -0
  303. package/src/surfaces/ai-chat.json +2185 -0
  304. package/src/surfaces/ai-chat.ts +549 -0
  305. package/src/surfaces/ai-credentials-form.json +332 -0
  306. package/src/surfaces/ai-credentials-form.ts +151 -0
  307. package/src/surfaces/ai-managed-connect.json +108 -0
  308. package/src/surfaces/ai-managed-connect.ts +123 -0
  309. package/src/surfaces/ai-model-picker.json +114 -0
  310. package/src/surfaces/ai-model-picker.ts +139 -0
  311. package/src/surfaces/block-action-bar.json +640 -0
  312. package/src/surfaces/block-action-bar.ts +368 -0
  313. package/src/surfaces/bottom-dock.json +154 -0
  314. package/src/surfaces/bottom-dock.ts +125 -0
  315. package/src/surfaces/canvas-stage.json +444 -0
  316. package/src/surfaces/canvas-stage.ts +253 -0
  317. package/src/surfaces/cf-account-picker.json +300 -0
  318. package/src/surfaces/cf-account-picker.ts +214 -0
  319. package/src/surfaces/commandbar.json +985 -0
  320. package/src/{panels/toolbar.ts → surfaces/commandbar.ts} +417 -391
  321. package/src/surfaces/convert-repeater.json +309 -0
  322. package/src/surfaces/convert-repeater.ts +276 -0
  323. package/src/surfaces/data-actions.json +157 -0
  324. package/src/surfaces/data-actions.ts +115 -0
  325. package/src/surfaces/dialog.json +289 -0
  326. package/src/surfaces/dialog.ts +343 -0
  327. package/src/surfaces/diff-toolbar.json +266 -0
  328. package/src/surfaces/diff-toolbar.ts +139 -0
  329. package/src/surfaces/doc-header.json +739 -0
  330. package/src/surfaces/doc-header.ts +223 -0
  331. package/src/surfaces/empty-state.json +152 -0
  332. package/src/surfaces/empty-state.ts +173 -0
  333. package/src/surfaces/entry-editor.json +281 -0
  334. package/src/surfaces/entry-editor.ts +191 -0
  335. package/src/surfaces/expression-editor.json +977 -0
  336. package/src/surfaces/expression-editor.ts +266 -0
  337. package/src/surfaces/files-panel.json +592 -0
  338. package/src/surfaces/files-panel.ts +301 -0
  339. package/src/surfaces/formula-palette.json +364 -0
  340. package/src/surfaces/formula-palette.ts +340 -0
  341. package/src/surfaces/git-panel.json +1822 -0
  342. package/src/surfaces/git-panel.ts +272 -0
  343. package/src/surfaces/github-auth.json +112 -0
  344. package/src/surfaces/github-auth.ts +139 -0
  345. package/src/surfaces/github-publish.json +206 -0
  346. package/src/surfaces/github-publish.ts +169 -0
  347. package/src/surfaces/grid-cell.json +218 -0
  348. package/src/surfaces/grid-cell.ts +120 -0
  349. package/src/surfaces/grid-open.json +147 -0
  350. package/src/surfaces/grid-open.ts +87 -0
  351. package/src/surfaces/grid-panel.json +706 -0
  352. package/src/surfaces/grid-panel.ts +206 -0
  353. package/src/surfaces/grid-replace.json +179 -0
  354. package/src/surfaces/grid-replace.ts +89 -0
  355. package/src/surfaces/grid-views.json +446 -0
  356. package/src/surfaces/grid-views.ts +172 -0
  357. package/src/surfaces/inspector-dock.json +187 -0
  358. package/src/surfaces/inspector-dock.ts +161 -0
  359. package/src/surfaces/jump-bar.json +243 -0
  360. package/src/surfaces/jump-bar.ts +129 -0
  361. package/src/surfaces/jxsuite-update.json +146 -0
  362. package/src/surfaces/jxsuite-update.ts +149 -0
  363. package/src/surfaces/library-pane.json +1509 -0
  364. package/src/surfaces/library-pane.ts +363 -0
  365. package/src/surfaces/logic-panel.json +1949 -0
  366. package/src/surfaces/logic-panel.ts +369 -0
  367. package/src/surfaces/logic-workspace.json +685 -0
  368. package/src/surfaces/logic-workspace.ts +238 -0
  369. package/src/surfaces/media-browser.json +303 -0
  370. package/src/surfaces/media-browser.ts +145 -0
  371. package/src/surfaces/media-field.json +163 -0
  372. package/src/surfaces/media-field.ts +116 -0
  373. package/src/surfaces/media-pane.json +513 -0
  374. package/src/surfaces/media-pane.ts +197 -0
  375. package/src/surfaces/menu.json +232 -0
  376. package/src/surfaces/menu.ts +375 -0
  377. package/src/surfaces/navigator-dock.json +113 -0
  378. package/src/surfaces/navigator-dock.ts +130 -0
  379. package/src/surfaces/new-project.json +1654 -0
  380. package/src/surfaces/new-project.ts +411 -0
  381. package/src/surfaces/palette.json +535 -0
  382. package/src/surfaces/pane-context.json +1497 -0
  383. package/src/surfaces/pane-context.ts +513 -0
  384. package/src/surfaces/pane-grid.json +188 -0
  385. package/src/surfaces/pane-grid.ts +250 -0
  386. package/src/surfaces/panel-activity.json +563 -0
  387. package/src/surfaces/panel-activity.ts +187 -0
  388. package/src/surfaces/panel-data.json +141 -0
  389. package/src/surfaces/panel-data.ts +156 -0
  390. package/src/surfaces/panel-deploy-checklist.json +283 -0
  391. package/src/surfaces/panel-deploy-checklist.ts +175 -0
  392. package/src/surfaces/panel-elements.json +367 -0
  393. package/src/surfaces/panel-elements.ts +175 -0
  394. package/src/surfaces/panel-i18n.json +569 -0
  395. package/src/surfaces/panel-i18n.ts +212 -0
  396. package/src/surfaces/panel-imports.json +780 -0
  397. package/src/surfaces/panel-imports.ts +209 -0
  398. package/src/surfaces/panel-outline.json +734 -0
  399. package/src/surfaces/panel-outline.ts +326 -0
  400. package/src/surfaces/panel-page.json +1017 -0
  401. package/src/surfaces/panel-page.ts +240 -0
  402. package/src/surfaces/panel-problems.json +497 -0
  403. package/src/surfaces/panel-problems.ts +157 -0
  404. package/src/surfaces/panel-signals.json +1973 -0
  405. package/src/surfaces/panel-signals.ts +436 -0
  406. package/src/surfaces/panel-stylebook-layers.json +317 -0
  407. package/src/surfaces/panel-stylebook-layers.ts +155 -0
  408. package/src/surfaces/preferences.json +850 -0
  409. package/src/surfaces/preferences.ts +332 -0
  410. package/src/surfaces/progress-modal.json +110 -0
  411. package/src/surfaces/progress-modal.ts +150 -0
  412. package/src/surfaces/properties-panel.json +1578 -0
  413. package/src/surfaces/properties-panel.ts +337 -0
  414. package/src/surfaces/publish.json +945 -0
  415. package/src/surfaces/publish.ts +392 -0
  416. package/src/surfaces/push-plan.json +110 -0
  417. package/src/surfaces/push-plan.ts +87 -0
  418. package/src/surfaces/rail.json +255 -0
  419. package/src/surfaces/rail.ts +206 -0
  420. package/src/surfaces/reference-field.json +191 -0
  421. package/src/surfaces/reference-field.ts +168 -0
  422. package/src/surfaces/schema-builder.json +1011 -0
  423. package/src/surfaces/schema-builder.ts +181 -0
  424. package/src/surfaces/schema-form.json +1013 -0
  425. package/src/surfaces/schema-form.ts +287 -0
  426. package/src/surfaces/secret-field.json +60 -0
  427. package/src/surfaces/secret-field.ts +146 -0
  428. package/src/surfaces/seo.json +932 -0
  429. package/src/surfaces/seo.ts +334 -0
  430. package/src/surfaces/settings-contexts.json +538 -0
  431. package/src/surfaces/settings-contexts.ts +196 -0
  432. package/src/surfaces/settings-contributed.json +489 -0
  433. package/src/surfaces/settings-contributed.ts +199 -0
  434. package/src/surfaces/settings-css-vars.json +820 -0
  435. package/src/surfaces/settings-css-vars.ts +199 -0
  436. package/src/surfaces/settings-defs.json +1363 -0
  437. package/src/surfaces/settings-defs.ts +232 -0
  438. package/src/surfaces/settings-deploy.json +108 -0
  439. package/src/surfaces/settings-deploy.ts +135 -0
  440. package/src/surfaces/settings-extensions.json +486 -0
  441. package/src/surfaces/settings-extensions.ts +201 -0
  442. package/src/surfaces/settings-head.json +779 -0
  443. package/src/surfaces/settings-head.ts +397 -0
  444. package/src/surfaces/settings-locales.json +420 -0
  445. package/src/surfaces/settings-locales.ts +137 -0
  446. package/src/surfaces/settings-overview.json +423 -0
  447. package/src/surfaces/settings-overview.ts +218 -0
  448. package/src/surfaces/settings-packages.json +549 -0
  449. package/src/surfaces/settings-packages.ts +158 -0
  450. package/src/surfaces/settings-pane.json +147 -0
  451. package/src/surfaces/settings-pane.ts +150 -0
  452. package/src/surfaces/settings-rawjson.json +104 -0
  453. package/src/surfaces/settings-rawjson.ts +107 -0
  454. package/src/surfaces/shell.json +99 -0
  455. package/src/surfaces/shell.ts +30 -0
  456. package/src/surfaces/slash-menu.json +180 -0
  457. package/src/surfaces/slash-menu.ts +272 -0
  458. package/src/surfaces/statements.json +607 -0
  459. package/src/surfaces/statements.ts +238 -0
  460. package/src/surfaces/statusbar.json +94 -0
  461. package/src/{panels → surfaces}/statusbar.ts +164 -90
  462. package/src/surfaces/style-panel.json +1643 -0
  463. package/src/surfaces/style-panel.ts +338 -0
  464. package/src/surfaces/stylebook-chrome.json +122 -0
  465. package/src/surfaces/stylebook-chrome.ts +122 -0
  466. package/src/surfaces/tab-strip.json +448 -0
  467. package/src/surfaces/tab-strip.ts +200 -0
  468. package/src/surfaces/target-line.json +419 -0
  469. package/src/surfaces/target-line.ts +349 -0
  470. package/src/surfaces/toasts.json +189 -0
  471. package/src/surfaces/welcome.json +571 -0
  472. package/src/surfaces/welcome.ts +338 -0
  473. package/src/tabs/project-config.ts +111 -21
  474. package/src/tabs/tab.ts +52 -1
  475. package/src/types.ts +22 -0
  476. package/src/ui/ai-credentials-form.ts +104 -100
  477. package/src/ui/ai-managed-connect.ts +54 -36
  478. package/src/ui/ai-model-picker.ts +75 -47
  479. package/src/ui/cf-account-picker.ts +76 -141
  480. package/src/ui/color-selector.ts +80 -301
  481. package/src/ui/dynamic-slot.ts +43 -283
  482. package/src/ui/expression-editor.ts +797 -576
  483. package/src/ui/field-input.ts +8 -181
  484. package/src/ui/form-controls.ts +838 -366
  485. package/src/ui/formula-chips.ts +80 -85
  486. package/src/ui/kit.ts +33 -0
  487. package/src/ui/layers.ts +296 -593
  488. package/src/ui/media-picker.ts +203 -284
  489. package/src/ui/panel-resize.ts +251 -64
  490. package/src/ui/popover-surface.ts +153 -0
  491. package/src/ui/progress-modal.ts +22 -60
  492. package/src/ui/regions.ts +13 -7
  493. package/src/ui/schema-form.ts +906 -440
  494. package/src/ui/surface.ts +122 -0
  495. package/src/ui/unit-selector.ts +15 -131
  496. package/src/ui/value-source.ts +14 -0
  497. package/src/ui/virtual-window.ts +88 -9
  498. package/src/utils/canvas-media.ts +72 -0
  499. package/src/utils/edit-display.ts +7 -2
  500. package/src/utils/element-selectors.ts +69 -0
  501. package/src/utils/link-target.ts +2 -2
  502. package/src/utils/studio-utils.ts +0 -10
  503. package/src/view.ts +15 -0
  504. package/src/workspace/pane-derive.ts +36 -14
  505. package/src/workspace/workspace.ts +75 -6
  506. package/styles/canvas.css +72 -467
  507. package/styles/forced-colors.css +48 -82
  508. package/styles/forced-colors.json +41 -0
  509. package/styles/inspector.css +2 -1301
  510. package/styles/overlays.css +96 -2823
  511. package/styles/panels.css +6 -1423
  512. package/styles/shell-frame.css +272 -0
  513. package/styles/shell-frame.json +227 -0
  514. package/styles/shell.css +0 -1568
  515. package/styles/tokens.css +65 -136
  516. package/styles/tokens.json +84 -0
  517. package/dist/chunks/Overlay.dev-etqjwyf9.js +0 -23
  518. package/dist/chunks/adapter-jkcyhgtr.js +0 -14
  519. package/dist/chunks/cf-account-picker-en86v7x7.js +0 -163
  520. package/dist/chunks/cf-settings-45fgyx8y.js +0 -21
  521. package/dist/chunks/components-zmx10t62.js +0 -21
  522. package/dist/chunks/content-source-48xg86wj.js +0 -49
  523. package/dist/chunks/convert-to-repeater-43tfn7cs.js +0 -268
  524. package/dist/chunks/editor-cptq5jp1.js +0 -37
  525. package/dist/chunks/entry-commands-e63f1xc0.js +0 -46
  526. package/dist/chunks/extension-sections-txv08xxg.js +0 -311
  527. package/dist/chunks/file-ops-wbh4hhsx.js +0 -48
  528. package/dist/chunks/files-3kfkwhk4.js +0 -93
  529. package/dist/chunks/flow-qbfgrpeh.js +0 -634
  530. package/dist/chunks/focus-trap.esm-9t2drp0g.js +0 -1025
  531. package/dist/chunks/focus-visible-6eg70z18.js +0 -167
  532. package/dist/chunks/format-host-w16q81tf.js +0 -43
  533. package/dist/chunks/fs-events-hrrg328g.js +0 -21
  534. package/dist/chunks/git-panel-532whr1y.js +0 -59
  535. package/dist/chunks/index-zqxmqxmh.js +0 -109
  536. package/dist/chunks/jx-validate-8er6gbxy.js +0 -19
  537. package/dist/chunks/layers-panel-07hm7g5j.js +0 -67
  538. package/dist/chunks/layers-q75tj2mf.js +0 -45
  539. package/dist/chunks/lit-html-j055mkh8.js +0 -22
  540. package/dist/chunks/monaco-buffer-dqw0xxpx.js +0 -25
  541. package/dist/chunks/notify-yjm79n4s.js +0 -30
  542. package/dist/chunks/parse-24d0gay3.js +0 -19
  543. package/dist/chunks/platform-ba1j1gbh.js +0 -16
  544. package/dist/chunks/publish-panel-v9st96ja.js +0 -29
  545. package/dist/chunks/quick-search-zccjp6gv.js +0 -66
  546. package/dist/chunks/references-3qx8fkyp.js +0 -29
  547. package/dist/chunks/right-panel-vm3b6kwy.js +0 -46
  548. package/dist/chunks/section-registry-sx1ykvkw.js +0 -30
  549. package/dist/chunks/sp-overlay.dev-qgvhnxf4.js +0 -18
  550. package/dist/chunks/sp-popover.dev-n2etmqdp.js +0 -9
  551. package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +0 -15
  552. package/dist/chunks/sp-tooltip.dev-4sastdnw.js +0 -15
  553. package/dist/chunks/sp-tray.dev-tkbscm06.js +0 -199
  554. package/dist/chunks/studio-0a5qrh01.js +0 -3
  555. package/dist/chunks/studio-0bq6eh7v.js +0 -12
  556. package/dist/chunks/studio-18j3bmcz.js +0 -14
  557. package/dist/chunks/studio-1ngbejft.js +0 -181
  558. package/dist/chunks/studio-4b5z1x43.js +0 -424
  559. package/dist/chunks/studio-4n2b5gzb.js +0 -357
  560. package/dist/chunks/studio-4rzyrtjz.js +0 -426
  561. package/dist/chunks/studio-5fx3g9jg.js +0 -4325
  562. package/dist/chunks/studio-5myknm0a.js +0 -1903
  563. package/dist/chunks/studio-7afd21nw.js +0 -67
  564. package/dist/chunks/studio-7nte2hj4.js +0 -613
  565. package/dist/chunks/studio-8jpzqk5s.js +0 -128
  566. package/dist/chunks/studio-8q517dfr.js +0 -24
  567. package/dist/chunks/studio-950k0dx6.js +0 -6939
  568. package/dist/chunks/studio-9n4mrmnb.js +0 -184
  569. package/dist/chunks/studio-a0tswy0w.js +0 -58
  570. package/dist/chunks/studio-ahnzd9rg.js +0 -23
  571. package/dist/chunks/studio-bgxt93ga.js +0 -62
  572. package/dist/chunks/studio-cmb03bg5.js +0 -13890
  573. package/dist/chunks/studio-d1ks410h.js +0 -175
  574. package/dist/chunks/studio-ef49pnzx.js +0 -387
  575. package/dist/chunks/studio-eqq1w638.js +0 -113
  576. package/dist/chunks/studio-ew0djmhg.js +0 -341
  577. package/dist/chunks/studio-gfgb8pe6.js +0 -1652
  578. package/dist/chunks/studio-h4rxscfc.js +0 -10
  579. package/dist/chunks/studio-jc994dnr.js +0 -1066
  580. package/dist/chunks/studio-kyh9avvv.js +0 -28
  581. package/dist/chunks/studio-m3hwn7y1.js +0 -120
  582. package/dist/chunks/studio-mtfnaqs9.js +0 -659
  583. package/dist/chunks/studio-n6j9t10q.js +0 -318
  584. package/dist/chunks/studio-n8b9wqwy.js +0 -291
  585. package/dist/chunks/studio-qcy05dp8.js +0 -1743
  586. package/dist/chunks/studio-qq6jym04.js +0 -53
  587. package/dist/chunks/studio-rdx8jcxy.js +0 -83
  588. package/dist/chunks/studio-rn9ta2mz.js +0 -111
  589. package/dist/chunks/studio-t7qb9db8.js +0 -665
  590. package/dist/chunks/studio-tmc5emrw.js +0 -8
  591. package/dist/chunks/studio-z6nw2ek2.js +0 -18
  592. package/dist/chunks/studio-zczps70q.js +0 -378
  593. package/dist/chunks/studio-zqazsct2.js +0 -1823
  594. package/dist/chunks/workspace-fjx52ryv.js +0 -89
  595. package/src/panels/activity-bar.ts +0 -251
  596. package/src/panels/panel-scheduler.ts +0 -219
  597. package/src/panels/style-inputs.ts +0 -237
  598. package/src/panels/target-line.ts +0 -274
  599. package/src/panels/welcome-screen.ts +0 -346
  600. package/src/ui/button-group.ts +0 -102
  601. package/src/ui/choice-field.ts +0 -72
  602. package/src/ui/field-row.ts +0 -249
  603. package/src/ui/formula-palette.ts +0 -213
  604. package/src/ui/icons.ts +0 -52
  605. package/src/ui/jx-theme.ts +0 -130
  606. package/src/ui/spectrum.ts +0 -340
  607. package/src/ui/value-selector.ts +0 -174
  608. package/src/ui/widgets.ts +0 -172
  609. /package/dist/chunks/{javascript-s56a8dpk.css → javascript-4abzjcy9.css} +0 -0
  610. /package/dist/chunks/{jsonMode-zr9sw0c7.css → jsonMode-vvmpmk34.css} +0 -0
  611. /package/dist/chunks/{monaco-setup-pav68602.css → monaco-setup-8vbyqnhr.css} +0 -0
  612. /package/dist/chunks/{tsMode-ef187txx.css → tsMode-hnxxb73h.css} +0 -0
@@ -13,13 +13,36 @@
13
13
  * - **Media breakpoint DEFINITIONS → Project Settings › Contexts** (P4). Nothing remains here: the
14
14
  * only reason adding a breakpoint used to cost you your element selection.
15
15
  *
16
- * What is left is one accordion of rows, all of which go through `ui/field-row.ts`.
16
+ * **This module decides; `surfaces/properties-panel.json` draws.** Every question with an answer —
17
+ * which attributes apply to this tag, which cascade a value came from, which rungs of the value
18
+ * ladder the position permits, what a keystroke is worth, and what a commit writes — is answered
19
+ * here and handed over as sections whose every row is already decided. The document holds the
20
+ * markup, the ARIA and the style, and holds no idea what an attribute is.
21
+ *
22
+ * **It does not go through the panel scheduler, and that is the point.**
23
+ * `panels/panel-scheduler.ts` withholds a repaint while a text input inside the dock has focus,
24
+ * because a lit repaint rebuilds the field the reader is typing into and takes the caret with it. A
25
+ * document has one effect per bound property and skips a write equal to what is already there, so
26
+ * the Content tab is updated in place: the rows that changed change, the field being typed in is
27
+ * untouched, and there is nothing to guard against. What survives is the DRAFT layer
28
+ * (`ui/field-input.ts`), which is a different promise — it is what keeps a half-typed value from
29
+ * being replaced by the last committed one, and what keys that half-typed value to the NODE rather
30
+ * than to the field's name (§11.4).
31
+ *
32
+ * **Two leaves are drawn elsewhere**, and each is its own surface with readers outside this tab:
33
+ * the media picker and the expression editor. The document draws an empty `[part="control-host"]`
34
+ * for them and this module fills it, which is the seam `ui/schema-form.ts` established for an
35
+ * extension's registered control. The expression editor is lit and is RENDERED into the box; the
36
+ * media picker is a document and is MOUNTED into it, so a `control` takes the host instead of
37
+ * returning a template — a document cannot be handed back as a value, and it clears the host it is
38
+ * given. There were three: a `format: "color"` prop was the middle one, and it is a
39
+ * `jx-color-field` in the document now that `ui.md` §5.6 has landed.
40
+ *
41
+ * @docs studio/design/properties
17
42
  */
18
43
 
19
- import { html, nothing } from "lit-html";
20
- import type { TemplateResult } from "lit-html";
21
- import { live } from "lit-html/directives/live.js";
22
- import { debouncedStyleCommit, getNodeAtPath, renderOnly } from "../store";
44
+ import { getNodeAtPath } from "../store";
45
+ import { effect, effectScope, reactive } from "../reactivity";
23
46
  import { displayTagName, isRef, isTagExpression } from "@jxsuite/schema/guards";
24
47
  import type { DirEntry, JsonValue } from "../types";
25
48
  import {
@@ -44,11 +67,34 @@ import type { LayoutSelection } from "../shell";
44
67
  import type { AnyCommand, CommandRegistry } from "../commands/registry";
45
68
  import { activeRegistry } from "../commands/active-registry";
46
69
  import { componentRegistry } from "../files/components";
47
- import { widgetForType } from "./style-inputs";
48
- import { renderFieldRow, renderKvRow } from "../ui/field-row";
70
+ import { provenanceText, provenanceTitle } from "./provenance";
49
71
  import type { FieldProvenance } from "./provenance";
50
- import { renderDynamicSlot, slotMode } from "../ui/dynamic-slot";
51
- import { spTextArea, spTextField } from "../ui/field-input";
72
+ import {
73
+ effectiveSlotMode,
74
+ hasStashedSlotValue,
75
+ slotModeSeed,
76
+ stashSlotValue,
77
+ switchSlotMode,
78
+ } from "../ui/dynamic-slot";
79
+ import {
80
+ TAG_EXPRESSION_OPERATORS,
81
+ VALUE_SOURCE_LABELS,
82
+ slotCaps,
83
+ slotMode,
84
+ } from "../ui/value-source";
85
+ import type { SlotCapsSource, SlotMode } from "../ui/value-source";
86
+ import {
87
+ clearDraft,
88
+ commitField,
89
+ getFieldValue,
90
+ hasDraft,
91
+ scheduleDraftCommit,
92
+ setDraft,
93
+ } from "../ui/field-input";
94
+ import { INPUT_DEBOUNCE, LIVE_PREVIEW } from "../ui/timing";
95
+ import { cloneValue } from "../tabs/doc-op-apply";
96
+ import { openMenu } from "../surfaces/menu";
97
+ import type { MenuHandle } from "../surfaces/menu";
52
98
  import {
53
99
  attrLabel,
54
100
  camelToLabel,
@@ -58,14 +104,10 @@ import {
58
104
  } from "../utils/studio-utils";
59
105
  import { classifyHref, composeHref } from "../utils/link-target";
60
106
  import type { LinkKind } from "../utils/link-target";
61
- import {
62
- clickAnythingTo,
63
- openPageAction,
64
- renderEmptyState,
65
- staleSelectionMessage,
66
- } from "./empty-state";
67
- import { renderMediaPicker } from "../ui/media-picker";
68
- import { renderColorSelector } from "../ui/color-selector";
107
+ import { clickAnythingTo, openPageAction, staleSelectionMessage } from "./empty-state";
108
+ import { mountMediaPicker, unmountMediaPicker } from "../ui/media-picker";
109
+ import { colorTokens, resolvedColor } from "../ui/color-selector";
110
+ import { mountExpressionEditor } from "../ui/expression-editor";
69
111
  import {
70
112
  loadUsages,
71
113
  peekUsages,
@@ -75,9 +117,22 @@ import {
75
117
  } from "../services/references";
76
118
  import { getPlatform } from "../platform";
77
119
  import htmlMeta from "../../data/html-meta.json";
120
+ import { popoverIdsIn } from "@jxsuite/schema/overlays";
121
+ import { POPOVER_TAGS } from "@jxsuite/ui";
122
+ import { showPromptDialog } from "../ui/layers";
123
+ import { emptyContentView, mountContentSurface } from "../surfaces/properties-panel";
78
124
 
125
+ import type {
126
+ ContentOption,
127
+ ContentPanelActions,
128
+ ContentPanelSurface,
129
+ ContentPanelView,
130
+ ContentRowView,
131
+ ContentSectionView,
132
+ } from "../surfaces/properties-panel";
79
133
  import type {
80
134
  JxAttributeValue,
135
+ JxElement,
81
136
  JxMutableNode,
82
137
  JxPrototypeDef,
83
138
  JxStateDefinition,
@@ -85,6 +140,13 @@ import type {
85
140
  } from "@jxsuite/schema/types";
86
141
  import type { SignalOption } from "../ui/dynamic-slot";
87
142
  import type { JxPath } from "../state";
143
+ import type { EffectScope } from "@vue/reactivity";
144
+
145
+ /** The picker's escape hatch: a popover declared in another file, typed by hand. */
146
+ const POPOVER_TARGET_OTHER = "jx:other";
147
+
148
+ /** The empty row a picker offers so a value can be taken away with the same control that set it. */
149
+ const CLEAR_OPTION: ContentOption = { label: "—", value: "" };
88
150
 
89
151
  interface HtmlMetaEntry {
90
152
  $section: string;
@@ -159,8 +221,8 @@ function bindingDonor(value: unknown): string {
159
221
  * The state entry a bound value reads, when the chip can name one — a `$ref` or a `${…}` template.
160
222
  *
161
223
  * §6.2's table says a bound chip "click opens the source", and on the Content tab it did not: both
162
- * bound branches returned a `donor` and a `title` and no `onClick`, so `renderProvenanceChip` drew
163
- * a handler-less span. The Style tab's chip has jumped to the Data panel since P5; the same promise
224
+ * bound branches returned a `donor` and a `title` and no `onClick`, so the chip was drawn as a
225
+ * handler-less span. The Style tab's chip has jumped to the Data panel since P5; the same promise
164
226
  * on the other tab was a label. Same regex as `style-panel.ts`'s `templateSignalOf`, because both
165
227
  * spellings of a template (`${x}` and `${state.x}`) name the same entry.
166
228
  */
@@ -208,172 +270,6 @@ function boundProvenance(value: unknown, title: string): FieldProvenance {
208
270
  };
209
271
  }
210
272
 
211
- // ─── Sub-templates ──────────────────────────────────────────────────────────
212
-
213
- /**
214
- * Component props, each row carrying §6.2's provenance chip — the SECOND cascade.
215
- *
216
- * A component instance's value for a prop either overrides the component's declared default or does
217
- * not exist, and until now the two were the same blank field. The chip states which, and in the
218
- * inherited case it names the donor AND jumps to it: the donor of a component default is the
219
- * component definition, which this panel can already open.
220
- */
221
- function renderComponentPropsFieldsTemplate(
222
- node: JxMutableNode,
223
- path: JxPath,
224
- mapSignals: SignalOption[] | null,
225
- navigateToComponent: (path: string) => void,
226
- ) {
227
- const tab = activeTab.value;
228
- const comp = componentRegistry.find((c) => c.tagName === displayTagName(node.tagName));
229
- const definitionOfThis = isComponentDefinitionOpen(node);
230
- if (!comp || !comp.props) {
231
- return renderEmptyState({
232
- compact: true,
233
- message: "This component is not in the project's library, so it has no settings to show.",
234
- });
235
- }
236
- const isNpm = comp.source === "npm";
237
- /* In the definition the "value" of a setting is its DECLARED DEFAULT — the state entry's
238
- `default` — not a per-instance override, so the row reads and writes there instead. */
239
- const stateEntries = (tab?.doc.document.state ?? {}) as Record<string, { default?: JsonValue }>;
240
- const currentVals: Record<string, unknown> = definitionOfThis
241
- ? Object.fromEntries(comp.props.map((prop) => [prop.name, stateEntries[prop.name]?.default]))
242
- : isNpm
243
- ? node.attributes || {}
244
- : node.$props || {};
245
- const updateFn = definitionOfThis
246
- ? (name: string, v?: JsonValue) =>
247
- transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { default: v }))
248
- : isNpm
249
- ? (name: string, v?: JsonValue) =>
250
- transactDoc(activeTab.value, (t) =>
251
- mutateUpdateAttribute(
252
- t,
253
- path,
254
- name,
255
- v === "" ? undefined : (v as JxAttributeValue | undefined),
256
- ),
257
- )
258
- : (name: string, v?: JsonValue) =>
259
- transactDoc(activeTab.value, (t) => mutateUpdateProp(t, path, name, v));
260
-
261
- const defs = tab!.doc.document.state || {};
262
- const signalNames = bindableSignalNames(tab!.doc.document);
263
- const extraSignals = mapSignals;
264
-
265
- return html`
266
- ${comp.props.map((prop) => {
267
- const rawValue = currentVals[prop.name];
268
- const boundRef = isRef(rawValue) ? rawValue.$ref : null;
269
- const hasVal = rawValue !== undefined && rawValue !== null;
270
- const parsed = parseCemType(prop.type);
271
- const onChange = (v?: JsonValue) => updateFn(prop.name, v);
272
- const staticVal = slotMode(rawValue) === "literal" ? String(rawValue ?? "") : "";
273
-
274
- // De-escalating to literal restores the bound signal's declared default (old unbind behavior).
275
- const literalDefault = boundRef
276
- ? defaultAsString(defs[boundRef.startsWith("#/state/") ? boundRef.slice(8) : boundRef]) ||
277
- undefined
278
- : undefined;
279
-
280
- /** @type {ReturnType<typeof setTimeout> | undefined} */
281
- let debounce: ReturnType<typeof setTimeout> | undefined;
282
- let widgetTpl;
283
- if (isMediaFormat(prop.format)) {
284
- widgetTpl = renderMediaPicker(prop.name, staticVal, onChange);
285
- } else if (prop.format === "color") {
286
- widgetTpl = renderColorSelector(prop.name, staticVal, onChange);
287
- } else if (prop.format === "date") {
288
- widgetTpl = spTextField(
289
- `cprop:${prop.name}`,
290
- String(staticVal),
291
- (v: string) => onChange(v),
292
- {
293
- placeholder: "YYYY-MM-DD",
294
- },
295
- );
296
- } else if (parsed.kind === "boolean") {
297
- widgetTpl = html`<sp-checkbox
298
- size="s"
299
- .checked=${live(Boolean(staticVal))}
300
- @change=${(e: Event) => onChange((e.target as HTMLInputElement).checked || undefined)}
301
- ></sp-checkbox>`;
302
- } else if (parsed.kind === "number") {
303
- widgetTpl = html`<sp-number-field
304
- size="s"
305
- .value=${live(staticVal)}
306
- @input=${(e: Event) => {
307
- clearTimeout(debounce);
308
- debounce = setTimeout(() => onChange((e.target as HTMLInputElement).value), 400);
309
- }}
310
- ></sp-number-field>`;
311
- } else if (parsed.kind === "combobox") {
312
- const options = parsed.options as string[];
313
- widgetTpl = html`<jx-value-selector
314
- .value=${String(staticVal)}
315
- size="s"
316
- placeholder="—"
317
- .options=${options.map((o) => ({ label: camelToLabel(o), value: o }))}
318
- @change=${(e: Event & { detail?: { value?: string } }) =>
319
- onChange(e.detail?.value ?? (e.target as HTMLInputElement).value)}
320
- ></jx-value-selector>`;
321
- } else {
322
- widgetTpl = spTextField(`cprop:${prop.name}`, String(staticVal), (v: string) =>
323
- onChange(v),
324
- );
325
- }
326
-
327
- const slot = renderDynamicSlot({
328
- caps: "componentProp",
329
- extraSignals,
330
- fieldKey: `cprop|${path.join("/")}|${prop.name}`,
331
- literalDefault,
332
- onChange,
333
- staticWidget: widgetTpl,
334
- stateDefs: signalNames,
335
- value: rawValue,
336
- });
337
-
338
- return renderFieldRow({
339
- hasValue: hasVal,
340
- label: camelToLabel(prop.name),
341
- labelExtra: slot.modeButton,
342
- prop: prop.name,
343
- provenance: componentPropProvenance(prop, rawValue, hasVal, {
344
- // No donor and no jump in the definition: the value IS the component's default, and
345
- // "→ the component definition" would open the document already in front of you.
346
- isDefinition: definitionOfThis,
347
- onClear: () => updateFn(prop.name),
348
- ...(definitionOfThis || !comp.path
349
- ? {}
350
- : { openDefinition: () => navigateToComponent(comp.path!) }),
351
- }),
352
- widget: slot.widget,
353
- });
354
- })}
355
- ${
356
- comp.props.length === 0
357
- ? renderEmptyState({
358
- compact: true,
359
- message: definitionOfThis
360
- ? "This component declares no settings yet. Add a state entry with a default to " +
361
- "give whoever places it something to fill in."
362
- : "This component has no settings to fill in yet.",
363
- })
364
- : nothing
365
- }
366
- ${
367
- // A link to the definition, from inside the definition, is a link to here.
368
- comp.path && !definitionOfThis
369
- ? html`<span class="kv-add" @click=${() => navigateToComponent(comp.path!)}
370
- >→ Edit definition</span
371
- >`
372
- : nothing
373
- }
374
- `;
375
- }
376
-
377
273
  /**
378
274
  * Is the selected node an INSTANCE of a component, or the definition of one?
379
275
  *
@@ -426,51 +322,65 @@ function componentPropProvenance(
426
322
  return { state: "default" };
427
323
  }
428
324
 
429
- /** Custom attrs fields template — attributes this element's schema does not know about. */
430
- function renderCustomAttrsFieldsTemplate(
431
- path: JxPath,
432
- attrs: Record<string, unknown>,
433
- knownAttrNames: Set<string>,
434
- ) {
435
- const customAttrs = Object.entries(attrs).filter(([k]) => !knownAttrNames.has(k));
436
- return html`
437
- ${customAttrs.map(([attr, val]) =>
438
- renderKvRow({
439
- name: attr,
440
- onCommit: (newAttr: string, newVal: string) => {
441
- if (newAttr !== attr) {
442
- transactDoc(activeTab.value, (t) => {
443
- mutateUpdateAttribute(t, path, attr);
444
- mutateUpdateAttribute(t, path, newAttr, newVal);
445
- });
446
- } else {
447
- transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, attr, newVal));
448
- }
449
- },
450
- onDelete: () => transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, attr)),
451
- value: String(val),
452
- }),
453
- )}
454
- <span
455
- class="kv-add"
456
- @click=${() => transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, "data-", ""))}
457
- >+ Add attribute</span
458
- >
459
- `;
325
+ /** Which of §6.2's states an HTML-attribute row is in. Attributes have no third cascade layer. */
326
+ function attributeProvenance(
327
+ attr: string,
328
+ value: unknown,
329
+ hasVal: boolean,
330
+ onClear: () => void,
331
+ mixedCount = 0,
332
+ ): FieldProvenance {
333
+ // Mixed first, for the same reason the Style tab decides it first: it is a fact about the
334
+ // SELECTION, and the primary's own value says nothing about whether the others agree. A count of
335
+ // 0 (a selection of one) can never reach this branch, so single-selection rows are untouched.
336
+ if (mixedCount > 0) {
337
+ return { donor: String(mixedCount), onClick: onClear, state: "mixed" };
338
+ }
339
+ if (hasVal && (isRef(value) || (typeof value === "string" && value.includes("${")))) {
340
+ return boundProvenance(value, `Bound — ${attr}`);
341
+ }
342
+ return hasVal ? { onClick: onClear, state: "set", title: `Clear ${attr}` } : { state: "default" };
343
+ }
344
+
345
+ /**
346
+ * How many selected elements disagree about `key` on `read` — 0 when they agree or there is one.
347
+ *
348
+ * The Content tab's two cascades (HTML attributes and component props) both need the same answer
349
+ * about the same set, so the question is asked once here and answered with `unifyValues`, the same
350
+ * comparison the Style tab uses.
351
+ */
352
+ export function mixedAcrossSelection(
353
+ doc: JxMutableNode,
354
+ targets: readonly JxPath[],
355
+ read: (node: JxMutableNode | undefined) => unknown,
356
+ ): number {
357
+ if (targets.length < 2) {
358
+ return 0;
359
+ }
360
+ const values = targets.map((path) => read(getNodeAtPath(doc, path) as JxMutableNode | undefined));
361
+ return unifyValues(values).mixed ? targets.length : 0;
362
+ }
363
+
364
+ /**
365
+ * True when an attribute value is a binding (a `$ref` object or a template string containing
366
+ * `${…}`), so the Link-target special-case must fall back to the raw widget to keep it editable.
367
+ */
368
+ function isBoundAttrValue(value: unknown): boolean {
369
+ return isRef(value) || (typeof value === "string" && value.includes("${"));
460
370
  }
461
371
 
462
372
  // ─── Page-route enumeration (for the Link-target Internal picker) ─────────────
463
373
 
464
- /** @type {string[] | null} — cached list of internal routes derived from the pages/ tree. */
374
+ /** Cached list of internal routes derived from the pages/ tree; `null` until one is asked for. */
465
375
  let pageRouteEntries: string[] | null = null;
466
376
 
377
+ /** Whether a walk is already in flight, so a re-projection does not start a second one. */
378
+ let pageRoutesLoading = false;
379
+
467
380
  /**
468
381
  * Derive a site route from a page file path relative to `pages/`, following the file-based routing
469
382
  * convention: `index.json` → the directory route, `[slug].json` → `:slug`, all others drop their
470
383
  * extension. Directory routes get a trailing slash (`/about/`); the root is `/`.
471
- *
472
- * @param {string} relPath — path relative to `pages/`, forward-slashed (e.g. "blog/[slug].json").
473
- * @returns {string}
474
384
  */
475
385
  function routeForPagePath(relPath: string): string {
476
386
  const withoutExt = relPath.replace(/\.[^./]+$/, "");
@@ -489,363 +399,541 @@ function routeForPagePath(relPath: string): string {
489
399
  return isIndex || body.includes(":") ? `/${body}${isIndex ? "/" : ""}` : `/${body}/`;
490
400
  }
491
401
 
492
- /** Recursively walk the pages/ tree and populate {@link pageRouteEntries} with derived routes. */
402
+ /**
403
+ * Recursively walk the pages/ tree and populate {@link pageRouteEntries} with derived routes.
404
+ *
405
+ * The in-flight flag is released in a `finally` rather than after the walk: the projection starts
406
+ * this whenever the cache is empty, so a host with no platform registered — which throws on the
407
+ * first line — would otherwise leave the flag raised and the routes unenumerable for the life of
408
+ * the window.
409
+ */
493
410
  async function loadPageRouteEntries() {
494
- const platform = getPlatform();
411
+ pageRoutesLoading = true;
495
412
  const routes: string[] = [];
496
413
  const docExts = new Set([".json", ".md", ".html"]);
497
- async function walk(dir: string, rel: string) {
498
- let listing: DirEntry[];
499
- try {
500
- listing = await platform.listDirectory(dir);
501
- } catch {
502
- return;
503
- }
504
- for (const entry of listing) {
505
- const childRel = rel ? `${rel}/${entry.name}` : entry.name;
506
- if (entry.type === "directory") {
507
- await walk(entry.path ?? `${dir}/${entry.name}`, childRel);
508
- } else if (docExts.has(entry.name.slice(entry.name.lastIndexOf(".")))) {
509
- routes.push(routeForPagePath(childRel));
414
+ try {
415
+ const platform = getPlatform();
416
+ const walk = async (dir: string, rel: string): Promise<void> => {
417
+ let listing: DirEntry[];
418
+ try {
419
+ listing = await platform.listDirectory(dir);
420
+ } catch {
421
+ return;
510
422
  }
511
- }
423
+ for (const entry of listing) {
424
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
425
+ if (entry.type === "directory") {
426
+ await walk(entry.path ?? `${dir}/${entry.name}`, childRel);
427
+ } else if (docExts.has(entry.name.slice(entry.name.lastIndexOf(".")))) {
428
+ routes.push(routeForPagePath(childRel));
429
+ }
430
+ }
431
+ };
432
+ await walk("pages", "");
433
+ pageRouteEntries = [...new Set(routes)].toSorted((a, b) => a.localeCompare(b));
434
+ } finally {
435
+ pageRoutesLoading = false;
512
436
  }
513
- await walk("pages", "");
514
- pageRouteEntries = [...new Set(routes)].toSorted((a, b) => a.localeCompare(b));
515
- renderOnly("rightPanel");
437
+ renderPropertiesPanel();
516
438
  }
517
439
 
518
440
  export function invalidatePageRouteCache() {
519
441
  pageRouteEntries = null;
442
+ renderPropertiesPanel();
520
443
  }
521
444
 
445
+ // ─── Section state ────────────────────────────────────────────────────────────
446
+
522
447
  /**
523
- * Composite Link-target control for an anchor's `href` — a kind selector (Internal / External /
524
- * Anchor / Email / Phone) plus the matching input, backed by classifyHref/composeHref so edits
525
- * round-trip. Internal targets render an sp-picker of page routes enumerated from the pages/ tree.
448
+ * The Inspector's fixed section keys — the accordion rows the inspector always draws.
449
+ *
450
+ * Attribute-schema sections add their own keys at render time (a class's `$section`), so this is a
451
+ * floor, not a closed set: {@link inspectorSectionKeys} unions it with whatever the OPEN DOCUMENT
452
+ * declares, and that union is what `inspector.setSection` validates against. A fixed enum would
453
+ * refuse a section the user is looking at; no validation at all would accept the label the old
454
+ * `inspector.toggleSection` step passed ("Element", not `__element`) and silently do nothing.
526
455
  *
527
- * @param {JxMutableNode} node
528
- * @param {JxPath} path
456
+ * `__media` left with the breakpoint definitions, which are Project Settings › Contexts now (P4).
457
+ * `__observed`, `__cssprops` and `__cssparts` are drawn by the Logic tab; the key space is the
458
+ * INSPECTOR's, not one tab's, so `inspector.setSection` keeps addressing all of them.
529
459
  */
530
- function renderLinkTargetField(node: JxMutableNode, path: JxPath) {
531
- const raw = typeof node.attributes?.href === "string" ? node.attributes.href : "";
532
- const { kind, value } = classifyHref(raw);
460
+ export const INSPECTOR_SECTION_KEYS = [
461
+ "__element",
462
+ "__observed",
463
+ "__usages",
464
+ "__custom",
465
+ "__cssprops",
466
+ "__cssparts",
467
+ ] as const;
533
468
 
534
- const commit = (nextKind: LinkKind, nextValue: string) => {
535
- const composed = composeHref(nextKind, nextValue);
536
- transactDoc(activeTab.value!, (t) =>
537
- mutateUpdateAttribute(t, path, "href", composed || undefined),
538
- );
539
- };
469
+ export type InspectorSectionKey = (typeof INSPECTOR_SECTION_KEYS)[number];
540
470
 
541
- const kindOptions: { value: LinkKind; label: string }[] = [
542
- { label: "Internal Page", value: "internal" },
543
- { label: "External URL", value: "external" },
544
- { label: "Anchor", value: "anchor" },
545
- { label: "Email", value: "mailto" },
546
- { label: "Phone", value: "tel" },
547
- ];
471
+ /** The Component Settings section's key — recorded rather than fixed, like an attribute section. */
472
+ const PROPS_SECTION = "__props";
548
473
 
549
- const kindSelector = html`
550
- <sp-picker
551
- class="link-target-kind"
552
- size="s"
553
- .value=${live(kind)}
554
- @change=${(e: Event) => {
555
- const nextKind = (e.target as HTMLInputElement).value as LinkKind;
556
- // Switching kind reinterprets the current value under the new kind.
557
- commit(nextKind, value);
558
- }}
559
- >
560
- ${kindOptions.map((o) => html`<sp-menu-item value=${o.value}>${o.label}</sp-menu-item>`)}
561
- </sp-picker>
562
- `;
563
-
564
- let valueInput;
565
- if (kind === "internal") {
566
- if (pageRouteEntries === null) {
567
- void loadPageRouteEntries();
568
- }
569
- const routes = pageRouteEntries ?? [];
570
- const knownValue = value !== "" && !routes.includes(value);
571
- valueInput = html`
572
- <sp-picker
573
- class="link-target-value"
574
- size="s"
575
- .value=${live(value)}
576
- @change=${(e: Event) => commit("internal", (e.target as HTMLInputElement).value)}
577
- >
578
- ${knownValue ? html`<sp-menu-item value=${value}>${value}</sp-menu-item>` : nothing}
579
- ${routes.map((r) => html`<sp-menu-item value=${r}>${r}</sp-menu-item>`)}
580
- </sp-picker>
581
- `;
582
- } else {
583
- const placeholder =
584
- kind === "mailto"
585
- ? "name@example.com"
586
- : kind === "tel"
587
- ? "+15551234567"
588
- : kind === "anchor"
589
- ? "section-id"
590
- : "https://example.com";
591
- valueInput = html`
592
- <sp-textfield
593
- class="link-target-value"
594
- size="s"
595
- placeholder=${placeholder}
596
- .value=${live(value)}
597
- @input=${debouncedStyleCommit("link:href", 400, (e: Event) =>
598
- commit(kind, (e.target as HTMLInputElement).value),
599
- )}
600
- ></sp-textfield>
601
- `;
602
- }
474
+ /**
475
+ * The Usage section's key, in the same per-tab `inspectorSections` record every other section uses,
476
+ * so `inspector.setSection` addresses it exactly like the rest and nothing needs a second store.
477
+ */
478
+ const USAGES_SECTION = "__usages";
603
479
 
604
- return renderFieldRow({
605
- hasValue: raw !== "",
606
- label: "Link",
607
- onClear: () => transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, "href")),
608
- prop: "href",
609
- widget: html`<div class="link-target-field">${kindSelector}${valueInput}</div>`,
610
- });
480
+ /** Section keys addressable right now: the fixed rows plus any already recorded for this tab. */
481
+ export function inspectorSectionKeys(): string[] {
482
+ const recorded = Object.keys(activeTab.value?.session.ui.inspectorSections ?? {});
483
+ return [...new Set<string>([...INSPECTOR_SECTION_KEYS, ...recorded])];
611
484
  }
612
485
 
613
486
  /**
614
- * Real enum picker (sp-picker) for the anchor `target` attribute, replacing the generic
615
- * jx-value-selector so the four browsing-context keywords are offered as a dropdown.
487
+ * Whether one Inspector section is expanded, falling back to the caller's default.
616
488
  *
617
- * @param {JxMutableNode} node
618
- * @param {JxPath} path
619
- * @param {HtmlMetaEntry} entry
489
+ * The fallback is the caller's because it is not a constant: an attribute section opens itself when
490
+ * one of its attributes is set, and Usage never does. Only the panel drawing the row knows.
620
491
  */
621
- function renderTargetField(node: JxMutableNode, path: JxPath, entry: HtmlMetaEntry) {
622
- const options = Array.isArray(entry.enum) ? (entry.enum as string[]) : [];
623
- const current = typeof node.attributes?.target === "string" ? node.attributes.target : "";
624
- return renderFieldRow({
625
- hasValue: current !== "",
626
- label: attrLabel(entry, "target"),
627
- onClear: () => transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, "target")),
628
- prop: "target",
629
- widget: html`
630
- <sp-picker
631
- class="link-target-window"
632
- size="s"
633
- .value=${live(current)}
634
- @change=${(e: Event) =>
635
- transactDoc(activeTab.value!, (t) =>
636
- mutateUpdateAttribute(
637
- t,
638
- path,
639
- "target",
640
- (e.target as HTMLInputElement).value || undefined,
641
- ),
642
- )}
643
- >
644
- ${options.map((o) => html`<sp-menu-item value=${o}>${o}</sp-menu-item>`)}
645
- </sp-picker>
646
- `,
647
- });
492
+ export function isInspectorSectionOpen(section: string, fallback: boolean): boolean {
493
+ const recorded = activeTab.value?.session.ui.inspectorSections?.[section];
494
+ return recorded === undefined ? fallback : recorded;
648
495
  }
649
496
 
650
497
  /**
651
- * True when an attribute value is a binding (a `$ref` object or a template string containing
652
- * `${…}`), so the Link-target special-case must fall back to the raw widget to keep it editable.
498
+ * Open or close one Inspector section, per tab.
499
+ *
500
+ * The pair `{ section, open }` is the whole point: `inspector.toggleSection` named a DELTA against
501
+ * a section whose state depended on the selected node's own attributes (`autoOpen`), so the same
502
+ * three manifest steps opened a section on one document and closed it on the next. Writing a new
503
+ * object rather than mutating in place is what the reactive read in the projection depends on.
653
504
  */
654
- function isBoundAttrValue(value: unknown): boolean {
655
- return isRef(value) || (typeof value === "string" && value.includes("${"));
505
+ export function setInspectorSection(section: string, open: boolean): void {
506
+ const tab = activeTab.value;
507
+ if (!tab) {
508
+ return;
509
+ }
510
+ tab.session.ui.inspectorSections = { ...tab.session.ui.inspectorSections, [section]: open };
656
511
  }
657
512
 
658
- // ─── Layout selection panel ─────────────────────────────────────────────────
513
+ // ─── Row plans: how this module reads a gesture back ──────────────────────────
659
514
 
660
- /*
661
- * There is no `openLayoutAtNode`, and every line of it was wrong by the time it was deleted.
662
- *
663
- * Its docstring said `navigateToComponent` "swaps the tab's document (pushing the page onto the
664
- * document stack)" — two releases stale: nothing has swapped since §14.1 landed and there is no
665
- * document stack. Then it did four things, and the last three were the defect:
666
- *
667
- * 1. **Navigate.** Now the command's job, and the command opens the layout BESIDE the page rather
668
- * than over the page it is teaching about — §8.2's promise since P3, never shipped.
669
- * 2. **`setLayoutSelection(null)`.** Its stated reason — "the layout is now the open document, so
670
- * its nodes are ordinary editable content" — stops being true the moment the layout opens beside
671
- * the page rather than replacing it. Clearing it is precisely what killed the follow on its first
672
- * frame, and it is what made the Inspector blank at the instant the layout appeared.
673
- * 3. **Re-select against `activeTab`.** The focus deliberately stays in the page now, so the tab
674
- * this reached was the wrong one. The selection is carried by the derivation instead —
675
- * `pane-derive.ts`'s `DerivedTarget.select`, read from `shell.layoutSelection`'s `layoutPath`
676
- * and applied to the companion pane's tab once the open resolves. For one release that sentence
677
- * was written here and nothing implemented it: `layoutPath` had no consumer outside the canvas
678
- * hit test, `derivationFor("layout")` stored `{resolved: null}`, and the exact regression this
679
- * ledger entry describes — dropped into a layout file with nothing selected — was back while
680
- * the comment denied it.
681
- *
682
- * The companion resolves `shell.layoutSelection.layoutFile` in preference to the page's own
683
- * `$layout` for the same reason the old code did: they are different answers for a nested chain,
684
- * and the one the author clicked is the one they meant.
685
- * 4. **`renderOnly("rightPanel")`.** `panels/right-panel.ts:106` tracks `shell.layoutSelection`
686
- * itself and `:212` reads it, so the repaint was already subscribed — a second, imperative
687
- * subscription that only looked necessary because the function above had just cleared the state
688
- * the real one is keyed on.
689
- */
515
+ /** The ladder as one row holds it, so a rung change needs nothing re-derived. */
516
+ interface LadderPlan {
517
+ fieldKey: string;
518
+ mode: SlotMode;
519
+ offered: SlotMode[];
520
+ /** Every pointer the From data… rung offers, state entries and repeater signals alike. */
521
+ sources: SignalOption[];
522
+ /**
523
+ * The state entries alone, kept apart from {@link sources} because `slotModeSeed` reads them
524
+ * differently: a fresh Mixed text rung seeds `${state.<first>}` from a state entry and `${}` from
525
+ * anything else, so folding the two lists into one is how a bound prop dropped to an empty
526
+ * template instead of naming the signal it had just left.
527
+ */
528
+ stateDefs: string[];
529
+ extraSignals: SignalOption[];
530
+ value: JsonValue | undefined;
531
+ onChange: (v?: JsonValue) => void;
532
+ literalDefault: JsonValue | undefined;
533
+ seedFor: ((mode: SlotMode) => JsonValue | undefined) | undefined;
534
+ }
690
535
 
691
536
  /**
692
- * The Layout-element panel — what the inspector says when you click page chrome.
693
- *
694
- * It spent a release cycle unreachable: `shell.layoutSelection` had this reader and no writer, so
695
- * clicking the site name in the header — the first click a new user makes — did nothing at all. The
696
- * canvas hit test writes it now (§8.2), and `panels/right-panel.ts` tracks it, so the panel
697
- * renders.
537
+ * What a row's callbacks do. The document reports `commit("attr|href", "/about")` and nothing else;
538
+ * everything needed to turn that into a document write was decided when the row was built.
698
539
  */
540
+ interface RowPlan {
541
+ /** Turn a committed string into a document value. */
542
+ commit?: ((raw: string) => void) | undefined;
543
+ commitChecked?: ((checked: boolean) => void) | undefined;
544
+ commitKind?: ((kind: string) => void) | undefined;
545
+ /** The provenance chip's verb: clear it, jump to the donor, open the source. */
546
+ chipAction?: (() => void) | undefined;
547
+ /** An `action` row's verb, and a `file` row's path. */
548
+ action?: (() => void) | undefined;
549
+ openPath?: string | undefined;
550
+ /** The draft key for a text control; absent when the row commits with no draft. */
551
+ draft?: string | undefined;
552
+ /** How long after a keystroke {@link commit} runs; `0` means only on change. */
553
+ debounceMs: number;
554
+ /**
555
+ * A key/value pair's two draft keys, what each cell currently holds on disk, and the write that
556
+ * lands both at once. The committed pair is carried because either cell may be edited alone, and
557
+ * a missing draft has to fall back to the value that is actually there — reading `""` for the
558
+ * name is how a value-only edit renamed the attribute to nothing.
559
+ */
560
+ kv?: {
561
+ nameKey: string;
562
+ valueKey: string;
563
+ name: string;
564
+ value: string;
565
+ write: (name: string, value: string) => void;
566
+ };
567
+ ladder?: LadderPlan | undefined;
568
+ /**
569
+ * A leaf the document does not draw, given the empty box it drew for it.
570
+ *
571
+ * It takes the host rather than returning a template because the two leaves do not agree about
572
+ * what drawing means: the expression editor is lit and renders into the box, the media picker is
573
+ * a document and is MOUNTED into it. Both are idempotent, so a repaint calls this again rather
574
+ * than deciding which of the two it is holding.
575
+ */
576
+ control?: ((host: HTMLElement) => void) | undefined;
577
+ }
578
+
579
+ /** A row view with every field filled in, before the caller narrows it. */
580
+ function blankRow(key: string, prop: string, label: string): ContentRowView {
581
+ return {
582
+ checked: false,
583
+ chip: "",
584
+ chipState: "",
585
+ chipText: "",
586
+ chipTitle: "",
587
+ detail: "",
588
+ hasSource: false,
589
+ hasTokens: false,
590
+ key,
591
+ kind: "text",
592
+ kindOptions: [],
593
+ label,
594
+ linkKind: "",
595
+ linkValueKind: "text",
596
+ mono: false,
597
+ name: "",
598
+ options: [],
599
+ placeholder: "",
600
+ prop,
601
+ removeLabel: "",
602
+ resolved: "",
603
+ shape: "field",
604
+ source: "fixed",
605
+ sourceLabel: "",
606
+ sourceLocked: false,
607
+ sourceName: "",
608
+ text: "",
609
+ title: "",
610
+ tokens: [],
611
+ value: "",
612
+ };
613
+ }
614
+
615
+ // ─── The panel ───────────────────────────────────────────────────────────────
616
+
617
+ /** Where a control is navigated to when a row asks to open a component definition. */
618
+ let navigateToComponent: (path: string) => void = () => {};
619
+
620
+ /** The mounted document, once the Inspector has handed over its container. */
621
+ let surface: ContentPanelSurface | null = null;
622
+
623
+ /** The effect that watches everything the projection reads. */
624
+ let panelScope: EffectScope | null = null;
625
+
626
+ /** Row key → what its callbacks do. Rebuilt by every projection. */
627
+ const plans = new Map<string, RowPlan>();
628
+
629
+ /** The lit-owned control hosts the document has announced, by row key. */
630
+ const controlHosts = new Map<string, HTMLElement>();
631
+
699
632
  /**
700
- * The chip that opens the layout beside the page.
633
+ * Every draft key this tab has minted.
701
634
  *
702
- * **Drawn only where it can run**, which is why the condition lives at the call site rather than
703
- * inside the handler. `canvas/iframe-host.ts` focuses the pane a layout click landed in — including
704
- * a LENS, which draws layout chrome because it draws the same document — and from a derived pane
705
- * `pane.derive` can only throw `an open document in a pane that is not itself derived`, into a
706
- * floating `void registry.run(…)` that swallows it. A chip that does nothing when you press it is
707
- * worse than no chip: the sentence above it already says where the element comes from, and in that
708
- * shell the layout is either already beside you or one Unsplit away.
635
+ * `ui/field-input.ts`'s draft map is module-global and a draft deliberately OUTLIVES the commit it
636
+ * provoked — that is what keeps a field controlled by the text in it until the reader blurs. So the
637
+ * one moment a draft of this tab's is certainly dead is when the tab is unbound, and nothing but
638
+ * this set knows which of them were ours.
709
639
  */
710
- function openLayoutTpl(): TemplateResult {
711
- return html`<span
712
- class="kv-add"
713
- @click=${() => {
714
- /* ONE command, addressed by id — the chip is a control, not a second definition site for
715
- what "open the layout" means. It opens the layout BESIDE the page and leaves the keyboard
716
- in the page, so the next click on layout chrome moves the side pane's selection instead of
717
- teaching the author what a pane is. */
718
- void activeRegistry()?.run("pane.derive", { preset: "layout" });
719
- }}
720
- >Open Layout →</span
721
- >`;
722
- }
640
+ const draftKeys = new Set<string>();
723
641
 
724
- function renderLayoutSelectionPanel() {
725
- const selection = shell.layoutSelection as LayoutSelection;
726
- const tagName = selection.tagName || "element";
727
- const { className } = selection;
728
- const displayPath = selection.layoutFile || "layout";
729
-
730
- return html`
731
- <div class="style-sidebar">
732
- <sp-accordion allow-multiple size="s">
733
- <sp-accordion-item label="Layout Element" open>
734
- <div class="style-section-body">
735
- <div class="layout-origin">
736
- <span class="layout-origin-badge">Layout</span>
737
- <code class="layout-origin-tag">&lt;${tagName}&gt;</code>
738
- </div>
739
- ${
740
- className
741
- ? renderFieldRow({
742
- hasValue: false,
743
- label: "Class",
744
- prop: "className",
745
- widget: html`<span class="layout-origin-class">${className}</span>`,
746
- })
747
- : nothing
748
- }
749
- <p class="layout-origin-note">
750
- This element comes from ${displayPath}, which wraps every page that uses it. Open the
751
- layout to edit it.
752
- </p>
753
- ${deriveRefusal(workspace.activePaneId) === null ? openLayoutTpl() : nothing}
754
- </div>
755
- </sp-accordion-item>
756
- </sp-accordion>
757
- </div>
758
- `;
642
+ /** Mint a draft key, remembering it for the unbind. */
643
+ function draft(key: string): string {
644
+ draftKeys.add(key);
645
+ return key;
759
646
  }
760
647
 
761
- // ─── Usages (§9.6) ───────────────────────────────────────────────────────────
648
+ /** The rung picker, while one is up. One at a time, and it goes down with the panel. */
649
+ let sourceMenu: MenuHandle | null = null;
762
650
 
763
651
  /**
764
- * The Usage section's key, in the same per-tab `inspectorSections` record every other section uses,
765
- * so `inspector.setSection` addresses it exactly like the rest and nothing needs a second store.
652
+ * The repaint signal for state no reactive store carries — the component registry, the usage cache
653
+ * and the route walk, each of which lands asynchronously and used to repaint through
654
+ * `renderOnly("rightPanel")`.
766
655
  */
767
- const USAGES_SECTION = "__usages";
656
+ const pulse = reactive({ tick: 0 });
657
+
658
+ /** Whether a projection is already queued for the end of this tick. */
659
+ let projectionQueued = false;
768
660
 
769
661
  /**
770
- * The component instance's usage line — "Used on 7 pages →", expanding to the files.
662
+ * Ask the Content tab to re-read the world.
771
663
  *
772
- * Three things this deliberately does NOT do. It does not render when the host cannot answer
773
- * (`usagesSupported()` is `capability.findReferences`): a confident "0" for a component used
774
- * everywhere is worse than no line at all. It does not ask on every paint — `peekUsages` is
775
- * side-effect-free and only a cold target starts a request, which repaints once when it lands. And
776
- * it does not swallow a failure: a failed count says so and offers Retry, because "we could not
777
- * check" and "nothing uses this" are the two answers a user must never confuse.
664
+ * Three of the facts the projection reads are NOT reactive stores — the component registry, the
665
+ * usage cache and the enumerated page routes — so they announce themselves here. Every other
666
+ * repaint arrives through the watcher below, which is why this is a signal rather than a render
667
+ * call.
668
+ */
669
+ export function renderPropertiesPanel(): void {
670
+ /* The pulse is the whole of it: the watcher reads it, so a bump reaches the same coalescer every
671
+ other fact does. A second queue here would be a second scheduler for one surface. */
672
+ pulse.tick += 1;
673
+ }
674
+
675
+ /**
676
+ * Hand the Content tab its container, and subscribe to the state it draws.
778
677
  *
779
- * @param tagName — the instance's custom-element tag.
780
- * @param componentPath — its definition file, when the registry knows one. Passing both asks the
781
- * one question that covers file references AND element instances.
678
+ * `null` unbinds — the document goes down and the watcher stops — which is what the Inspector's own
679
+ * teardown needs. The same seam rather than a second export, for the reason `panels/ai-panel.ts`
680
+ * gives: a teardown nothing but a teardown reaches is one the reachability ledger has to carry.
782
681
  */
783
- function renderUsagesSection(tagName: string, componentPath: string | null) {
784
- const query = { tagName, ...(componentPath ? { path: componentPath } : {}) };
785
- const state = peekUsages(query);
786
- // ONE gate, and it is the peek itself: a host with no `findReferences` answers "unsupported"
787
- // Synchronously, and the section does not exist. A separate `usagesSupported()` guard above this
788
- // Would make the arm below unreachable, which is a branch nothing can ever test.
789
- if (state?.status === "unsupported") {
790
- return nothing;
682
+ export function bindContentHost(
683
+ el: HTMLElement | null,
684
+ ctx?: { navigateToComponent: (path: string) => void },
685
+ ): void {
686
+ panelScope?.stop();
687
+ panelScope = null;
688
+ surface?.dispose();
689
+ surface = null;
690
+ plans.clear();
691
+ for (const host of controlHosts.values()) {
692
+ unmountMediaPicker(host);
791
693
  }
792
- if (state === null) {
793
- // Cold: ask once, and repaint the inspector when the answer arrives.
794
- void loadUsages(query).then(() => renderOnly("rightPanel"));
694
+ controlHosts.clear();
695
+ for (const key of draftKeys) {
696
+ clearDraft(key);
795
697
  }
698
+ draftKeys.clear();
699
+ sourceMenu?.close();
700
+ sourceMenu = null;
701
+ if (!el) {
702
+ return;
703
+ }
704
+ navigateToComponent = ctx?.navigateToComponent ?? (() => {});
705
+ surface = mountContentSurface(el, emptyContentView(), ACTIONS, (key, host) => {
706
+ controlHosts.set(key, host);
707
+ paintControl(key, host);
708
+ });
709
+ panelScope = effectScope();
710
+ panelScope.run(() => {
711
+ effect(() => {
712
+ // Everything the projection reads that a store can announce. The tab's own fields are read
713
+ // One by one because a bare `void tab` tracks the holder and not the document inside it.
714
+ const tab = activeTab.value;
715
+ void shell.layoutSelection;
716
+ void workspace.activePaneId;
717
+ void pulse.tick;
718
+ if (tab) {
719
+ void tab.doc.document;
720
+ void tab.documentPath;
721
+ // The whole SET, joined — a bare property read would not re-trigger when the selection
722
+ // Changes WITHIN the array.
723
+ void tab.session.selection.map((path) => path.join("/")).join("|");
724
+ void tab.session.ui.inspectorSections;
725
+ }
726
+ queueProjection();
727
+ });
728
+ });
729
+ }
796
730
 
797
- let body;
798
- let heading = "Usage";
799
- if (state === null || state.status === "pending") {
800
- body = html`<div class="usage-note">Counting references…</div>`;
801
- } else if (state.status === "failed") {
802
- heading = "Usage · unknown";
803
- body = html`
804
- <div class="usage-note">
805
- References could not be counted: ${state.message}. This is not the same as “unused”.
806
- </div>
807
- <sp-action-button
808
- size="s"
809
- quiet
810
- @click=${() => {
811
- void retryUsages(query).then(() => renderOnly("rightPanel"));
812
- }}
813
- >Retry</sp-action-button
814
- >
815
- `;
816
- } else {
817
- const files = usageFiles(state.result);
818
- heading = usageHeadline(state.result);
819
- body =
820
- files.length === 0
821
- ? html`<div class="usage-note">
822
- Nothing else in this project places <code>&lt;${tagName}&gt;</code> yet.
823
- </div>`
824
- : html`${files.map(
825
- (file) => html`
826
- <button
827
- class="usage-row"
828
- type="button"
829
- title=${file.refs.map((r) => `${r.refType} ${r.ref} ×${r.count}`).join(", ")}
830
- @click=${() => openUsage(file.path)}
831
- >
832
- <span class="usage-row-path">${file.path}</span>
833
- <span class="usage-row-count">${file.count}</span>
834
- </button>
835
- `,
836
- )}`;
731
+ /**
732
+ * Project once at the end of this tick.
733
+ *
734
+ * Coalesced on a microtask because one gesture writes several reactive facts — a transaction moves
735
+ * the document, the selection and the history index — and the projection walks every applicable
736
+ * attribute of the selected tag. Running it once per write would build the whole accordion three
737
+ * times for one edit.
738
+ */
739
+ function queueProjection(): void {
740
+ if (projectionQueued || !surface) {
741
+ return;
837
742
  }
743
+ projectionQueued = true;
744
+ queueMicrotask(() => {
745
+ projectionQueued = false;
746
+ surface?.update(projectPanel());
747
+ paintControls();
748
+ });
749
+ }
838
750
 
839
- return html`
840
- <sp-accordion-item
841
- label=${heading}
842
- ?open=${isInspectorSectionOpen(USAGES_SECTION, false)}
843
- @sp-accordion-item-toggle=${() =>
844
- setInspectorSection(USAGES_SECTION, !isInspectorSectionOpen(USAGES_SECTION, false))}
845
- >
846
- <div class="style-section-body">${body}</div>
847
- </sp-accordion-item>
848
- `;
751
+ /**
752
+ * Draw one lit-owned leaf into the host the document announced for it.
753
+ *
754
+ * Connectedness is deliberately NOT consulted: a host is announced as it is CREATED, one reconcile
755
+ * step before it is in the page, so "is it connected" answers no for exactly the node that needs
756
+ * drawing.
757
+ */
758
+ function paintControl(key: string, host: HTMLElement): void {
759
+ plans.get(key)?.control?.(host);
760
+ }
761
+
762
+ /** Redraw every leaf this panel has been handed a host for, and forget the hosts that have gone. */
763
+ function paintControls(): void {
764
+ for (const [key, host] of controlHosts) {
765
+ if (plans.has(key)) {
766
+ paintControl(key, host);
767
+ } else {
768
+ controlHosts.delete(key);
769
+ /* A media picker in that box is a MOUNTED document, and the surface registry holds its host:
770
+ dropping our handle on it would leave the mount alive with nothing left to draw. */
771
+ unmountMediaPicker(host);
772
+ }
773
+ }
774
+ }
775
+
776
+ // ─── What a control asks for ─────────────────────────────────────────────────
777
+
778
+ const ACTIONS: ContentPanelActions = {
779
+ chipAction: (key) => {
780
+ plans.get(key)?.chipAction?.();
781
+ },
782
+ commit: (key, raw) => {
783
+ const plan = plans.get(key);
784
+ if (!plan?.commit) {
785
+ return;
786
+ }
787
+ if (plan.draft) {
788
+ setDraft(plan.draft, raw);
789
+ commitField(plan.draft, plan.commit);
790
+ return;
791
+ }
792
+ plan.commit(raw);
793
+ },
794
+ commitChecked: (key, checked) => {
795
+ plans.get(key)?.commitChecked?.(checked);
796
+ },
797
+ commitKind: (key, kind) => {
798
+ plans.get(key)?.commitKind?.(kind);
799
+ },
800
+ edit: (key, raw) => {
801
+ const plan = plans.get(key);
802
+ if (!plan?.commit || !plan.draft || plan.debounceMs <= 0) {
803
+ return;
804
+ }
805
+ setDraft(plan.draft, raw);
806
+ scheduleDraftCommit(plan.draft, plan.debounceMs, plan.commit);
807
+ },
808
+ editName: (key, raw) => {
809
+ editKvCell(key, "nameKey", raw);
810
+ },
811
+ editValue: (key, raw) => {
812
+ editKvCell(key, "valueKey", raw);
813
+ },
814
+ openFile: (key) => {
815
+ const path = plans.get(key)?.openPath;
816
+ if (path) {
817
+ openUsage(path);
818
+ }
819
+ },
820
+ openLayout: () => {
821
+ /* ONE command, addressed by id — the chip is a control, not a second definition site for what
822
+ "open the layout" means. It opens the layout BESIDE the page and leaves the keyboard in the
823
+ page, so the next click on layout chrome moves the side pane's selection instead of teaching
824
+ the author what a pane is. */
825
+ void activeRegistry()?.run("pane.derive", { preset: "layout" });
826
+ },
827
+ pickSource: (key, anchor) => {
828
+ openSourceMenu(key, anchor);
829
+ },
830
+ removeRow: (key) => {
831
+ plans.get(key)?.action?.();
832
+ },
833
+ runAction: (key) => {
834
+ plans.get(key)?.action?.();
835
+ },
836
+ runEmptyAction: (id) => {
837
+ if (id === "open-page") {
838
+ // The one verb every "needs an open document" empty state offers, run from where it is
839
+ // Declared rather than re-implemented: `panels/empty-state.ts` owns both its words and its
840
+ // Lazy reach into Quick Access.
841
+ openPageAction().run();
842
+ } else if (id === "open-logic") {
843
+ showLogicTab();
844
+ }
845
+ },
846
+ setSection: (key, open) => {
847
+ // One writer: the accordion's own toggle and `inspector.setSection` land in the same function,
848
+ // So the command and the control cannot disagree about what "open" means.
849
+ setInspectorSection(key, open);
850
+ },
851
+ };
852
+
853
+ /** A keystroke in one cell of a key/value pair: draft it, and land both cells together. */
854
+ function editKvCell(key: string, cell: "nameKey" | "valueKey", raw: string): void {
855
+ const plan = plans.get(key);
856
+ if (!plan?.kv) {
857
+ return;
858
+ }
859
+ const cellDraft = plan.kv[cell];
860
+ setDraft(cellDraft, raw);
861
+ scheduleDraftCommit(cellDraft, plan.debounceMs, () => {
862
+ commitKvPair(key);
863
+ });
864
+ }
865
+
866
+ /** Land a key/value pair: whatever is in both cells right now, in one transaction. */
867
+ function commitKvPair(key: string): void {
868
+ const plan = plans.get(key);
869
+ if (!plan?.kv) {
870
+ return;
871
+ }
872
+ const { nameKey, valueKey, write } = plan.kv;
873
+ // Both cells schedule the same commit, so the second timer arrives with nothing left to say.
874
+ if (!hasDraft(nameKey) && !hasDraft(valueKey)) {
875
+ return;
876
+ }
877
+ const name = getFieldValue(nameKey, plan.kv.name);
878
+ const value = getFieldValue(valueKey, plan.kv.value);
879
+ clearDraft(nameKey);
880
+ clearDraft(valueKey);
881
+ write(name, value);
882
+ }
883
+
884
+ /** The rung picker: every source this position permits, one action away (§6.3). */
885
+ function openSourceMenu(key: string, anchor: HTMLElement): void {
886
+ const ladder = plans.get(key)?.ladder;
887
+ if (!ladder) {
888
+ return;
889
+ }
890
+ const { fieldKey, mode, offered } = ladder;
891
+ sourceMenu?.close();
892
+ sourceMenu = openMenu({
893
+ label: "Value source",
894
+ onClosed: () => {
895
+ sourceMenu = null;
896
+ },
897
+ opener: anchor,
898
+ region: "value-source",
899
+ rows: offered.map((rung) => ({
900
+ checked: (rung === mode ? "true" : "false") as "true" | "false",
901
+ destructive: false,
902
+ disabled: false,
903
+ dividerAbove: false,
904
+ id: rung,
905
+ title: VALUE_SOURCE_LABELS[rung],
906
+ })),
907
+ run: (id) => {
908
+ const next = id as SlotMode;
909
+ if (next === mode) {
910
+ return;
911
+ }
912
+ /* Leaving From data… seeds an empty Fixed value stash with the signal's declared default, so
913
+ unbind-restores-default survives a detour (ref → mixed text → fixed value). */
914
+ if (
915
+ mode === "ref" &&
916
+ ladder.literalDefault !== undefined &&
917
+ !hasStashedSlotValue(fieldKey, "literal")
918
+ ) {
919
+ stashSlotValue(fieldKey, "literal", ladder.literalDefault);
920
+ }
921
+ ladder.onChange(
922
+ switchSlotMode(
923
+ fieldKey,
924
+ mode,
925
+ next,
926
+ cloneValue(ladder.value),
927
+ ladder.seedFor?.(next) ??
928
+ slotModeSeed(next, {
929
+ extraSignals: ladder.extraSignals,
930
+ literalDefault: ladder.literalDefault,
931
+ stateDefs: ladder.stateDefs,
932
+ }),
933
+ ),
934
+ );
935
+ },
936
+ });
849
937
  }
850
938
 
851
939
  /** Open a referencing file in a tab — the "→" half of "Used on N pages →". */
@@ -860,172 +948,279 @@ function showLogicTab(): void {
860
948
  void import("./right-panel").then((m) => m.setInspectorTab("events"));
861
949
  }
862
950
 
863
- // ─── Main entry point ───────────────────────────────────────────────────────
951
+ // ─── Row builders ────────────────────────────────────────────────────────────
952
+
953
+ /** Record a row's plan and hand back the row, so a builder reads as one expression. */
954
+ function withPlan(row: ContentRowView, plan: RowPlan): ContentRowView {
955
+ plans.set(row.key, plan);
956
+ return row;
957
+ }
958
+
959
+ /** Fill in a row's provenance chip from the vocabulary `panels/provenance.ts` owns. */
960
+ function applyChip(
961
+ row: ContentRowView,
962
+ plan: RowPlan,
963
+ prop: string,
964
+ p: FieldProvenance | undefined,
965
+ ): void {
966
+ if (!p || p.state === "default") {
967
+ return;
968
+ }
969
+ row.chipState = p.state;
970
+ row.chipText = provenanceText(p);
971
+ row.chipTitle = provenanceTitle(prop, p);
972
+ row.chip = p.state === "set" ? "dot" : p.onClick ? "press" : "text";
973
+ plan.chipAction = p.onClick;
974
+ }
975
+
976
+ /** Everything a bindable position needs to offer the value ladder. */
977
+ interface LadderOpts {
978
+ fieldKey: string;
979
+ caps: SlotCapsSource;
980
+ value: unknown;
981
+ onChange: (v?: JsonValue) => void;
982
+ stateDefs: string[];
983
+ extraSignals?: SignalOption[] | null;
984
+ literalDefault?: JsonValue | undefined;
985
+ seedFor?: ((mode: SlotMode) => JsonValue | undefined) | undefined;
986
+ /** The operators this position's grammar admits, when it admits fewer than all of them. */
987
+ operators?: readonly string[] | undefined;
988
+ }
864
989
 
865
990
  /**
866
- * The Content tab — lit-html template with accordion sections.
991
+ * Offer the value ladder on a row, and draw the rung it is actually at.
867
992
  *
868
- * @param {{ navigateToComponent: (path: string) => void }} ctx
993
+ * The rungs are DERIVED from the schema at the named position, never listed by the caller, so a
994
+ * panel can no longer offer a rung the compiler would reject nor withhold one it would accept. The
995
+ * literal rung is whatever the caller already put on the row; every other rung replaces it.
869
996
  */
870
- export function renderPropertiesPanelTemplate(ctx: {
871
- navigateToComponent: (path: string) => void;
872
- }) {
997
+ function applyLadder(row: ContentRowView, plan: RowPlan, opts: LadderOpts): void {
998
+ const caps = slotCaps(opts.caps);
999
+ const mode = effectiveSlotMode(opts.fieldKey, opts.value);
1000
+ const sources: SignalOption[] = [
1001
+ ...opts.stateDefs.map((name) => ({ label: name, value: `#/state/${name}` })),
1002
+ ...(opts.extraSignals ?? []),
1003
+ ];
1004
+ // A ref rung with nothing to point at is a dead end — drop it rather than offer an empty picker.
1005
+ const offered = caps.filter((m) => m !== "ref" || sources.length > 0);
1006
+ const label = VALUE_SOURCE_LABELS[mode];
1007
+ /* Locked only when the rung this value already sits on is the only one there is. A value on a
1008
+ rung the position does NOT permit — a fixed string left in a `$switch`, say — is exactly the
1009
+ case that needs the chip most, and counting rungs alone used to grey it out. */
1010
+ const locked = !offered.some((m) => m !== mode);
1011
+ row.hasSource = true;
1012
+ row.sourceLabel = label;
1013
+ row.sourceLocked = locked;
1014
+ row.sourceName = locked
1015
+ ? `Value source: ${label} (no other source available here)`
1016
+ : `Value source: ${label} — click to change`;
1017
+ row.source = mode === "literal" ? "fixed" : "bound";
1018
+ plan.ladder = {
1019
+ extraSignals: [...(opts.extraSignals ?? [])],
1020
+ fieldKey: opts.fieldKey,
1021
+ literalDefault: opts.literalDefault,
1022
+ mode,
1023
+ offered,
1024
+ onChange: opts.onChange,
1025
+ seedFor: opts.seedFor,
1026
+ sources,
1027
+ stateDefs: opts.stateDefs,
1028
+ value: cloneValue(opts.value as JsonValue | undefined),
1029
+ };
1030
+ if (mode === "ref") {
1031
+ row.kind = "select";
1032
+ row.value = isRef(opts.value) ? opts.value.$ref : "";
1033
+ row.options = [
1034
+ { label: "— select signal —", value: "" },
1035
+ ...sources.map((s) => ({ label: s.label, value: s.value })),
1036
+ ];
1037
+ plan.commit = (raw) => opts.onChange(raw ? { $ref: raw } : undefined);
1038
+ plan.debounceMs = 0;
1039
+ plan.draft = undefined;
1040
+ plan.control = undefined;
1041
+ return;
1042
+ }
1043
+ if (mode === "template") {
1044
+ const templateDraft = draft(`${opts.fieldKey}:template`);
1045
+ row.kind = "text";
1046
+ row.mono = true;
1047
+ row.placeholder = "${state.…}";
1048
+ row.value = getFieldValue(templateDraft, typeof opts.value === "string" ? opts.value : "");
1049
+ plan.commit = (raw) => opts.onChange(raw);
1050
+ plan.debounceMs = LIVE_PREVIEW;
1051
+ plan.draft = templateDraft;
1052
+ plan.control = undefined;
1053
+ return;
1054
+ }
1055
+ if (mode === "expression") {
1056
+ const node = (opts.value as { $expression?: unknown } | undefined)?.$expression;
1057
+ row.kind = "control";
1058
+ plan.commit = undefined;
1059
+ plan.draft = undefined;
1060
+ plan.control = (host) =>
1061
+ mountExpressionEditor(
1062
+ host,
1063
+ node,
1064
+ (next) => opts.onChange({ $expression: next } as JsonValue),
1065
+ {
1066
+ allowEventRef: false,
1067
+ ...(opts.operators ? { operators: opts.operators } : {}),
1068
+ preview: null,
1069
+ stateDefs: opts.stateDefs,
1070
+ stateEntries: null,
1071
+ },
1072
+ );
1073
+ }
1074
+ }
1075
+
1076
+ /** A text row bound to the draft layer: the one control every typed value in this tab goes through. */
1077
+ function textRow(opts: {
1078
+ key: string;
1079
+ prop: string;
1080
+ label: string;
1081
+ draft: string;
1082
+ value: string;
1083
+ placeholder?: string;
1084
+ commit: (raw: string) => void;
1085
+ }): { row: ContentRowView; plan: RowPlan } {
1086
+ const row = blankRow(opts.key, opts.prop, opts.label);
1087
+ row.kind = "text";
1088
+ row.value = getFieldValue(opts.draft, opts.value);
1089
+ row.placeholder = opts.placeholder ?? "";
1090
+ const plan: RowPlan = { commit: opts.commit, debounceMs: LIVE_PREVIEW, draft: draft(opts.draft) };
1091
+ return { plan, row };
1092
+ }
1093
+
1094
+ // ─── The projection ──────────────────────────────────────────────────────────
1095
+
1096
+ /** An empty state, in the words §-6's copy rules ask for. */
1097
+ function emptyView(
1098
+ message: string,
1099
+ detail: string,
1100
+ actions: { id: string; label: string }[],
1101
+ ): ContentPanelView {
1102
+ return {
1103
+ ...emptyContentView(),
1104
+ emptyActions: actions,
1105
+ emptyDetail: detail,
1106
+ emptyMessage: message,
1107
+ hasEmptyActions: actions.length > 0,
1108
+ hasEmptyDetail: detail !== "",
1109
+ view: "empty",
1110
+ };
1111
+ }
1112
+
1113
+ /**
1114
+ * The whole tab, as the document reads it.
1115
+ *
1116
+ * Every plan this panel can act on is rebuilt here, which is what makes a callback safe: a row key
1117
+ * that is no longer in the projection has no plan, so a click on a control the reconciler has since
1118
+ * taken away reaches nothing rather than the wrong node.
1119
+ */
1120
+ function projectPanel(): ContentPanelView {
1121
+ plans.clear();
873
1122
  const tab = activeTab.value;
874
1123
  if (!tab) {
875
- return renderEmptyState({
876
- actions: [openPageAction()],
877
- message: "Open a page to inspect and style what you click.",
878
- });
1124
+ return emptyView("Open a page to inspect and style what you click.", "", [
1125
+ { id: "open-page", label: openPageAction().label },
1126
+ ]);
879
1127
  }
880
1128
 
881
- // Layout element selected — show read-only info with link to open layout
1129
+ // Layout chrome first: it is mutually exclusive with a document selection, and it is the one
1130
+ // Target whose name has to say where it came FROM.
882
1131
  if (shell.layoutSelection) {
883
- return renderLayoutSelectionPanel();
1132
+ return layoutView(shell.layoutSelection as LayoutSelection);
884
1133
  }
885
1134
 
886
1135
  const selected = primarySelection(tab.session.selection);
887
1136
  if (!selected) {
888
- return renderEmptyState({ message: clickAnythingTo("edit its content") });
1137
+ return emptyView(clickAnythingTo("edit its content"), "", []);
889
1138
  }
890
1139
  const path: JxPath = selected;
891
1140
  const node = getNodeAtPath(tab.doc.document, path);
892
1141
  if (!node) {
893
- return renderEmptyState({ message: staleSelectionMessage() });
1142
+ return emptyView(staleSelectionMessage(), "", []);
894
1143
  }
895
1144
 
896
1145
  // A repeating list has no content of its own — it has a source and a template, and both are
897
1146
  // Wiring. Content says where the answer lives rather than drawing an empty accordion (§6.5).
898
1147
  if (node.$prototype === "Array") {
899
- return renderEmptyState({
900
- actions: [{ label: "Open Logic", run: showLogicTab }],
901
- detail: "Its items, filter, sort and template are wiring, so they live in Logic.",
902
- message: "A repeating list has no content of its own.",
903
- });
1148
+ return emptyView(
1149
+ "A repeating list has no content of its own.",
1150
+ "Its items, filter, sort and template are wiring, so they live in Logic.",
1151
+ [{ id: "open-logic", label: "Open Logic" }],
1152
+ );
904
1153
  }
905
1154
 
906
- // The whole selection the Content tab edits. `[path]` when one element is selected, which is
907
- // Every existing call site's behaviour unchanged.
908
- const targets: JxPath[] = tab.session.selection.length > 0 ? tab.session.selection : [path];
1155
+ return {
1156
+ ...emptyContentView(),
1157
+ sections: sectionsFor(node, path),
1158
+ view: "content",
1159
+ };
1160
+ }
1161
+
1162
+ /** The Layout-element card — what the inspector says when you click page chrome. */
1163
+ function layoutView(selection: LayoutSelection): ContentPanelView {
1164
+ const displayPath = selection.layoutFile || "layout";
1165
+ return {
1166
+ ...emptyContentView(),
1167
+ /* Drawn only where it can run. `canvas/iframe-host.ts` focuses the pane a layout click landed
1168
+ in — including a LENS, which draws layout chrome because it draws the same document — and
1169
+ from a derived pane `pane.derive` can only throw into a floating `void registry.run(…)` that
1170
+ swallows it. A chip that does nothing when you press it is worse than no chip. */
1171
+ layoutCanOpen: deriveRefusal(workspace.activePaneId) === null,
1172
+ layoutClass: selection.className,
1173
+ layoutHasClass: Boolean(selection.className),
1174
+ layoutNote:
1175
+ `This element comes from ${displayPath}, which wraps every page that uses it. ` +
1176
+ "Open the layout to edit it.",
1177
+ layoutTag: selection.tagName || "element",
1178
+ view: "layout",
1179
+ };
1180
+ }
1181
+
1182
+ /** Every section the selected node draws, in reading order. */
1183
+ function sectionsFor(node: JxMutableNode, path: JxPath): ContentSectionView[] {
1184
+ const tab = activeTab.value!;
909
1185
  const doc = tab.doc.document;
910
- const isCustomInstance = displayTagName(node.tagName).includes("-");
911
- const chosenTag = isTagExpression(node.tagName) ? node.tagName.$expression : null;
1186
+ const targets: JxPath[] = tab.session.selection.length > 0 ? tab.session.selection : [path];
912
1187
  const tagName = displayTagName(node.tagName) || "div";
1188
+ const isCustomInstance = displayTagName(node.tagName).includes("-");
913
1189
  const attrs = node.attributes || {};
914
-
915
1190
  const mapSignals = mapSignalsFor(path);
916
- // Signals offered to attribute/textContent bindings (handlers and Functions excluded).
917
- const bindableSignals = bindableSignalNames(tab.doc.document);
918
-
919
- function renderAttrRow(attr: string, entry: HtmlMetaEntry, value: unknown) {
920
- const type = inferInputType(entry);
921
- const hasVal = value !== undefined && value !== "";
922
- // One write per selected element, inside ONE transaction (§6.5). `targets` is `[path]` for a
923
- // Single selection, so this is the same single mutation it has always been.
924
- const commitAttr = (v?: JsonValue) =>
925
- transactDoc(activeTab.value!, (t) => {
926
- for (const target of targets) {
927
- mutateUpdateAttribute(t, target, attr, v as JxAttributeValue | undefined);
928
- }
929
- });
930
-
931
- // Enhanced Link handling: only for anchors (a/area) with a plain (non-binding) value. Bindings
932
- // ($ref objects or ${…} template strings) fall through to the raw widget to stay editable.
933
- const isAnchor = tagName === "a" || tagName === "area";
934
- if (isAnchor && !isBoundAttrValue(value)) {
935
- if (attr === "href") {
936
- return renderLinkTargetField(node, path);
937
- }
938
- if (attr === "target") {
939
- return renderTargetField(node, path, entry);
940
- }
941
- }
942
-
943
- const attrSlot = (staticWidget: unknown) =>
944
- renderDynamicSlot({
945
- caps: "attribute",
946
- extraSignals: mapSignals,
947
- fieldKey: `attr|${path.join("/")}|${attr}`,
948
- onChange: commitAttr,
949
- staticWidget,
950
- stateDefs: bindableSignals,
951
- value,
952
- });
953
-
954
- const literalWidget =
955
- entry.type === "boolean"
956
- ? html`
957
- <sp-checkbox
958
- size="s"
959
- .checked=${live(Boolean(value))}
960
- @change=${(e: Event) => commitAttr((e.target as HTMLInputElement).checked ? "" : undefined)}
961
- >
962
- </sp-checkbox>
963
- `
964
- : widgetForType(type, entry, attr, isRef(value) ? "" : String(value || ""), (v: string) =>
965
- commitAttr(v || undefined),
966
- );
967
- const slot = attrSlot(literalWidget);
968
- return renderFieldRow({
969
- hasValue: hasVal,
970
- label: attrLabel(entry, attr),
971
- labelExtra: slot.modeButton,
972
- prop: attr,
973
- provenance: attributeProvenance(
974
- attr,
975
- value,
976
- hasVal,
977
- () =>
978
- transactDoc(activeTab.value, (t) => {
979
- for (const target of targets) {
980
- mutateUpdateAttribute(t, target, attr);
981
- }
982
- }),
983
- mixedAcrossSelection(doc, targets, (n) => (n?.attributes ?? {})[attr] ?? null),
984
- ),
985
- widget: slot.widget,
986
- });
987
- }
1191
+ const bindableSignals = bindableSignalNames(doc);
988
1192
 
989
- // ── Collect applicable attributes from html-meta ──
1193
+ // ── Which attributes this tag has, by section ──
990
1194
  const applicableAttrs = {} as Record<string, HtmlMetaEntry>;
991
1195
  for (const [attr, entry] of Object.entries(htmlMeta.$defs) as [string, HtmlMetaEntry][]) {
992
1196
  if (!entry.$elements || entry.$elements.includes(displayTagName(tagName))) {
993
- // The $attr field aliases a $defs key to a different attribute name.
994
- // This lets the same attribute (e.g. "name") carry per-element metadata.
1197
+ // The $attr field aliases a $defs key to a different attribute name, so the same attribute
1198
+ // (e.g. "name") can carry per-element metadata.
995
1199
  applicableAttrs[entry.$attr ?? attr] = entry;
996
1200
  }
997
1201
  }
998
-
999
1202
  const attrSections: Record<string, { name: string; entry: HtmlMetaEntry }[]> = {};
1000
1203
  for (const sec of htmlMeta.$sections) {
1001
1204
  attrSections[sec.key] = [];
1002
1205
  }
1003
1206
  for (const [attr, entry] of Object.entries(applicableAttrs)) {
1004
- const secKey = entry.$section;
1005
- if (attrSections[secKey]) {
1006
- attrSections[secKey].push({ entry, name: attr });
1007
- }
1207
+ attrSections[entry.$section]?.push({ entry, name: attr });
1008
1208
  }
1009
1209
  for (const sec of htmlMeta.$sections) {
1010
- attrSections[sec.key]!.sort(
1011
- (a: { name: string; entry: HtmlMetaEntry }, b: { name: string; entry: HtmlMetaEntry }) =>
1012
- a.entry.$order - b.entry.$order,
1013
- );
1210
+ attrSections[sec.key]!.sort((a, b) => a.entry.$order - b.entry.$order);
1014
1211
  }
1015
1212
 
1016
1213
  const knownAttrNames = new Set(Object.keys(applicableAttrs));
1017
1214
  if (isCustomInstance) {
1018
1215
  const comp = componentRegistry.find((c) => c.tagName === node.tagName);
1019
- if (comp?.props) {
1020
- for (const p of comp.props) {
1021
- knownAttrNames.add(p.name);
1022
- }
1216
+ for (const p of comp?.props ?? []) {
1217
+ knownAttrNames.add(p.name);
1023
1218
  }
1024
1219
  }
1025
1220
  const customAttrs = Object.entries(attrs).filter(([k]) => !knownAttrNames.has(k));
1026
1221
 
1027
- const autoOpen = new Set();
1028
- for (const [attr] of Object.entries(attrs)) {
1222
+ const autoOpen = new Set<string>();
1223
+ for (const attr of Object.keys(attrs)) {
1029
1224
  const entry = applicableAttrs[attr];
1030
1225
  if (entry) {
1031
1226
  autoOpen.add(entry.$section);
@@ -1035,48 +1230,107 @@ export function renderPropertiesPanelTemplate(ctx: {
1035
1230
  autoOpen.add("__custom");
1036
1231
  }
1037
1232
 
1038
- function isSectionOpen(key: string) {
1039
- return isInspectorSectionOpen(key, autoOpen.has(key));
1233
+ const sections: ContentSectionView[] = [
1234
+ sectionView("__element", "Element", isInspectorSectionOpen("__element", false), [], 0),
1235
+ ];
1236
+ sections[0]!.rows = elementRows(node, path, tagName, mapSignals, bindableSignals);
1237
+
1238
+ if (isCustomInstance) {
1239
+ const definitionOfThis = isComponentDefinitionOpen(node);
1240
+ sections.push(
1241
+ sectionView(
1242
+ PROPS_SECTION,
1243
+ definitionOfThis ? "Component Defaults" : "Component Settings",
1244
+ isInspectorSectionOpen(PROPS_SECTION, true),
1245
+ componentPropRows(node, path, mapSignals, definitionOfThis),
1246
+ 0,
1247
+ ),
1248
+ );
1249
+ // "Used on N pages →". Only for a component instance: an ordinary <div> is not a thing that can
1250
+ // Be reused, so the question does not arise.
1251
+ const usages = usagesSection(
1252
+ displayTagName(tagName),
1253
+ componentRegistry.find((c) => c.tagName === displayTagName(tagName))?.path ?? null,
1254
+ );
1255
+ if (usages) {
1256
+ sections.push(usages);
1257
+ }
1040
1258
  }
1041
1259
 
1042
- function toggleSection(key: string) {
1043
- // One writer: the accordion's own click and `inspector.setSection` land in the same function,
1044
- // So the command and the control cannot disagree about what "open" means.
1045
- setInspectorSection(key, !isSectionOpen(key));
1260
+ for (const sec of htmlMeta.$sections) {
1261
+ const sectionAttrs = attrSections[sec.key]!;
1262
+ if (sectionAttrs.length === 0) {
1263
+ continue;
1264
+ }
1265
+ const setCount = sectionAttrs.filter((a) => attrs[a.name] !== undefined).length;
1266
+ const attrCtx: AttrCtx = {
1267
+ bindableSignals,
1268
+ doc,
1269
+ mapSignals,
1270
+ node,
1271
+ path,
1272
+ tagName,
1273
+ targets,
1274
+ };
1275
+ const rows = sectionAttrs.map((a) => attributeRow(a.name, a.entry, attrs[a.name], attrCtx));
1276
+ const open = isInspectorSectionOpen(sec.key, autoOpen.has(sec.key));
1277
+ sections.push(sectionView(sec.key, sec.label, open, rows, setCount));
1046
1278
  }
1047
1279
 
1048
- // ── Build section templates ─────────────────────────────────────────
1280
+ if (customAttrs.length > 0 || Object.keys(attrs).length > 0) {
1281
+ const custom = customRows(path, customAttrs);
1282
+ const open = isInspectorSectionOpen("__custom", autoOpen.has("__custom"));
1283
+ sections.push(sectionView("__custom", "Custom", open, custom, customAttrs.length));
1284
+ }
1285
+ return sections;
1286
+ }
1049
1287
 
1050
- const textSlot = renderDynamicSlot({
1051
- caps: "textProperty",
1052
- extraSignals: mapSignals,
1053
- fieldKey: `prop|${path.join("/")}|textContent`,
1054
- onChange: (v?: JsonValue) =>
1055
- transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, path, "textContent", v)),
1056
- staticWidget: spTextArea(
1057
- "prop:textContent",
1058
- typeof node.textContent === "string" ? node.textContent : "",
1059
- (v: string) =>
1060
- transactDoc(activeTab.value, (t) =>
1061
- mutateUpdateProperty(t, path, "textContent", v || undefined),
1062
- ),
1063
- ),
1064
- stateDefs: bindableSignals,
1065
- value: node.textContent,
1066
- });
1288
+ /**
1289
+ * One accordion section, with §6.2's collapsed-header tally.
1290
+ *
1291
+ * The dot is an INDICATOR and not a control: it used to inherit `.set-dot`'s pointer cursor and its
1292
+ * danger hover, which together say "click me to clear this", and nothing happened because it never
1293
+ * had a handler. Clicking anywhere in the heading already toggles the section.
1294
+ */
1295
+ function sectionView(
1296
+ key: string,
1297
+ label: string,
1298
+ open: boolean,
1299
+ rows: ContentRowView[],
1300
+ setCount: number,
1301
+ ): ContentSectionView {
1302
+ return {
1303
+ dotTitle: `${setCount} value${setCount === 1 ? "" : "s"} set in this section`,
1304
+ hasDot: setCount > 0,
1305
+ key,
1306
+ label,
1307
+ open,
1308
+ rows,
1309
+ };
1310
+ }
1067
1311
 
1068
- /**
1069
- * A draft key for one field ON THIS NODE.
1070
- *
1071
- * The three Element rows used constant keys — `"prop:tagName"`, `"prop:$id"`, `"prop:className"`
1072
- * — and `ui/field-input.ts`'s draft map is module-global, so every element shared one draft slot
1073
- * per field. Type a class name, click a sibling before blurring, and the sibling's Class row
1074
- * showed the text you had typed for the first one; blur it there and the commit landed on the
1075
- * WRONG element. Plan §11.4 asks for "drafts keyed by node path (today all elements share one
1076
- * draft slot per field)".
1077
- */
1078
- const fieldKey = (name: string) => `prop:${path.join("/")}:${name}`;
1312
+ // ─── The Element section ─────────────────────────────────────────────────────
1079
1313
 
1314
+ /**
1315
+ * A draft key for one field ON THIS NODE.
1316
+ *
1317
+ * The three Element rows used constant keys — `"prop:tagName"`, `"prop:$id"`, `"prop:className"` —
1318
+ * and `ui/field-input.ts`'s draft map is module-global, so every element shared one draft slot per
1319
+ * field. Type a class name, click a sibling before blurring, and the sibling's Class row showed the
1320
+ * text you had typed for the first one; blur it there and the commit landed on the WRONG element.
1321
+ */
1322
+ function draftKey(path: JxPath, name: string): string {
1323
+ return `prop:${path.join("/")}:${name}`;
1324
+ }
1325
+
1326
+ function elementRows(
1327
+ node: JxMutableNode,
1328
+ path: JxPath,
1329
+ tagName: string,
1330
+ mapSignals: SignalOption[] | null,
1331
+ bindableSignals: string[],
1332
+ ): ContentRowView[] {
1333
+ const chosenTag = isTagExpression(node.tagName) ? node.tagName.$expression : null;
1080
1334
  /**
1081
1335
  * The set-here chip for a document property, with the tooltip in the label's own words.
1082
1336
  *
@@ -1089,326 +1343,635 @@ export function renderPropertiesPanelTemplate(ctx: {
1089
1343
  state: "set",
1090
1344
  title: `Clear ${human}`,
1091
1345
  });
1346
+ const writeProperty = (name: string) => (raw: string) => {
1347
+ transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, path, name, raw || undefined));
1348
+ };
1092
1349
 
1093
- const elemT = html`
1094
- <sp-accordion-item
1095
- label="Element"
1096
- ?open=${isSectionOpen("__element") !== false}
1097
- @sp-accordion-item-toggle=${() => toggleSection("__element")}
1098
- >
1099
- <div class="style-section-body">
1100
- ${(() => {
1101
- /* THE SHARED SLOT, not a hand-rolled control.
1102
- `tagName` is a bindable position like any other — a literal name or a `TagExpression`
1103
- — so it gets the same Value Source chip and the same expression editor as `href`, a
1104
- style declaration or an event handler. The rungs are DERIVED from
1105
- `SLOT_POSITION_SCHEMAS.elementTag`, which is why only Fixed value and Formula are
1106
- offered: `TagName` carries a pattern, so `isFreeStringSchema` refuses a template rung,
1107
- and a `${…}` in tag position is precisely what that pattern exists to reject.
1108
-
1109
- The seed is ours because the generic one is `{ operator: "??" }`, which a
1110
- `TagExpression` does not admit — clicking the chip would otherwise write a document
1111
- that fails its own validator. */
1112
- const tagSlot = renderDynamicSlot({
1113
- caps: "elementTag",
1114
- fieldKey: `${path.join(".")}:tagName`,
1115
- onChange: (next) => {
1116
- transactDoc(activeTab.value, (t) =>
1117
- mutateUpdateProperty(t, path, "tagName", next ?? undefined),
1118
- );
1119
- },
1120
- seedFor: (mode) =>
1121
- mode === "expression"
1122
- ? {
1123
- $expression: {
1124
- initial: tagName,
1125
- operator: "?:",
1126
- target: null,
1127
- value: tagName,
1128
- },
1129
- }
1130
- : undefined,
1131
- staticWidget: spTextField(fieldKey("tagName"), tagName, (v: string) => {
1132
- transactDoc(activeTab.value, (t) =>
1133
- mutateUpdateProperty(t, path, "tagName", v || undefined),
1134
- );
1135
- }),
1136
- stateDefs: bindableSignalNames(activeTab.value!.doc.document),
1137
- value: node.tagName as JsonValue | undefined,
1138
- });
1139
- return renderFieldRow({
1140
- hasValue: chosenTag !== null,
1141
- label: "Tag",
1142
- labelExtra: tagSlot.modeButton,
1143
- prop: "tagName",
1144
- widget: tagSlot.widget,
1145
- });
1146
- })()}
1147
- ${renderFieldRow({
1148
- hasValue: Boolean(node.$id),
1149
- label: "ID",
1150
- prop: "$id",
1151
- ...(node.$id ? { provenance: propertyChip("$id", "ID") } : {}),
1152
- widget: spTextField(fieldKey("$id"), String(node.$id || ""), (v: string) =>
1153
- transactDoc(activeTab.value, (t) =>
1154
- mutateUpdateProperty(t, path, "$id", v || undefined),
1155
- ),
1156
- ),
1157
- })}
1158
- ${renderFieldRow({
1159
- hasValue: Boolean(node.className),
1160
- label: "Class",
1161
- prop: "className",
1162
- ...(node.className ? { provenance: propertyChip("className", "class") } : {}),
1163
- widget: spTextField(fieldKey("className"), String(node.className || ""), (v: string) =>
1164
- transactDoc(activeTab.value, (t) =>
1165
- mutateUpdateProperty(t, path, "className", v || undefined),
1166
- ),
1167
- ),
1168
- })}
1169
- ${
1170
- !Array.isArray(node.children) || node.children.length === 0
1171
- ? renderFieldRow({
1172
- hasValue: node.textContent !== undefined,
1173
- label: "Text Content",
1174
- labelExtra: textSlot.modeButton,
1175
- prop: "textContent",
1176
- widget: textSlot.widget,
1177
- ...(node.textContent === undefined
1178
- ? {}
1179
- : { provenance: propertyChip("textContent", "text") }),
1180
- })
1181
- : nothing
1182
- }
1183
- ${renderFieldRow({
1184
- hasValue: Boolean(node.hidden),
1185
- label: "Hidden",
1186
- prop: "hidden",
1187
- ...(node.hidden ? { provenance: propertyChip("hidden", "hidden") } : {}),
1188
- widget: html`
1189
- <sp-checkbox
1190
- size="s"
1191
- .checked=${live(Boolean(node.hidden))}
1192
- @change=${(e: Event) =>
1193
- transactDoc(activeTab.value, (t) =>
1194
- mutateUpdateProperty(
1195
- t,
1196
- path,
1197
- "hidden",
1198
- (e.target as HTMLInputElement).checked || undefined,
1199
- ),
1200
- )}
1201
- >
1202
- </sp-checkbox>
1203
- `,
1204
- })}
1205
- </div>
1206
- </sp-accordion-item>
1207
- `;
1208
-
1209
- const editingDefinition = isCustomInstance && isComponentDefinitionOpen(node);
1210
- const compPropsT = isCustomInstance
1211
- ? html`
1212
- <sp-accordion-item
1213
- label=${editingDefinition ? "Component Defaults" : "Component Settings"}
1214
- open
1215
- >
1216
- <div class="style-section-body">
1217
- ${renderComponentPropsFieldsTemplate(node, path, mapSignals, ctx.navigateToComponent)}
1218
- </div>
1219
- </sp-accordion-item>
1220
- `
1221
- : nothing;
1222
-
1223
- // "Used on N pages →". Only for a component instance: an ordinary <div> is not a thing that can
1224
- // Be reused, so the question does not arise.
1225
- const usagesT = isCustomInstance
1226
- ? renderUsagesSection(
1227
- displayTagName(tagName),
1228
- componentRegistry.find((c) => c.tagName === displayTagName(tagName))?.path ?? null,
1229
- )
1230
- : nothing;
1231
-
1232
- const attrSectionTemplates = htmlMeta.$sections
1233
- .filter((sec) => attrSections[sec.key]!.length > 0)
1234
- .map((sec) => {
1235
- const sectionAttrs = attrSections[sec.key]!;
1236
- const setCount = sectionAttrs.filter(
1237
- (a: { name: string; entry: HtmlMetaEntry }) => attrs[a.name] !== undefined,
1238
- ).length;
1239
- return html`
1240
- <sp-accordion-item
1241
- label=${sec.label}
1242
- ?open=${isSectionOpen(sec.key)}
1243
- @sp-accordion-item-toggle=${() => toggleSection(sec.key)}
1244
- >
1245
- ${sectionDot(setCount)}
1246
- <div class="style-section-body">
1247
- ${sectionAttrs.map((a: { name: string; entry: HtmlMetaEntry }) =>
1248
- renderAttrRow(a.name, a.entry, attrs[a.name]),
1249
- )}
1250
- </div>
1251
- </sp-accordion-item>
1252
- `;
1350
+ const rows: ContentRowView[] = [];
1351
+
1352
+ /* THE SHARED SLOT, not a hand-rolled control. `tagName` is a bindable position like any other — a
1353
+ literal name or a `TagExpression` — so it gets the same Value Source chip and the same
1354
+ expression editor as `href`, a style declaration or an event handler. The rungs are DERIVED
1355
+ from `SLOT_POSITION_SCHEMAS.elementTag`, which is why only Fixed value and Formula are offered:
1356
+ `TagName` carries a pattern, so a template rung is refused, and a `${…}` in tag position is
1357
+ precisely what that pattern exists to reject.
1358
+
1359
+ The seed is ours because the generic one is `{ operator: "??" }`, which a `TagExpression` does
1360
+ not admit — clicking the chip would otherwise write a document that fails its own validator.
1361
+ And the GRAMMAR is ours for exactly the same reason: seeding `?:` and then handing the node to
1362
+ an unrestricted editor left the very next click free to reoperate it to `capitalize`, or to
1363
+ pick `toUpperCase` out of the formula catalog. `TAG_EXPRESSION_OPERATORS` is read off the def,
1364
+ so the two branches the editor offers are the two branches the validator accepts. */
1365
+ const tag = textRow({
1366
+ commit: writeProperty("tagName"),
1367
+ draft: draftKey(path, "tagName"),
1368
+ key: "prop|tagName",
1369
+ label: "Tag",
1370
+ prop: "tagName",
1371
+ value: tagName,
1372
+ });
1373
+ applyLadder(tag.row, tag.plan, {
1374
+ caps: "elementTag",
1375
+ fieldKey: `${path.join(".")}:tagName`,
1376
+ operators: TAG_EXPRESSION_OPERATORS,
1377
+ onChange: (next) => {
1378
+ transactDoc(activeTab.value, (t) =>
1379
+ mutateUpdateProperty(t, path, "tagName", next ?? undefined),
1380
+ );
1381
+ },
1382
+ seedFor: (mode) =>
1383
+ mode === "expression"
1384
+ ? { $expression: { initial: tagName, operator: "?:", target: null, value: tagName } }
1385
+ : undefined,
1386
+ stateDefs: bindableSignals,
1387
+ value: node.tagName as JsonValue | undefined,
1388
+ });
1389
+ void chosenTag;
1390
+ rows.push(withPlan(tag.row, tag.plan));
1391
+
1392
+ const id = textRow({
1393
+ commit: writeProperty("$id"),
1394
+ draft: draftKey(path, "$id"),
1395
+ key: "prop|$id",
1396
+ label: "ID",
1397
+ prop: "$id",
1398
+ value: String(node.$id || ""),
1399
+ });
1400
+ if (node.$id) {
1401
+ applyChip(id.row, id.plan, "$id", propertyChip("$id", "ID"));
1402
+ }
1403
+ rows.push(withPlan(id.row, id.plan));
1404
+
1405
+ const cls = textRow({
1406
+ commit: writeProperty("className"),
1407
+ draft: draftKey(path, "className"),
1408
+ key: "prop|className",
1409
+ label: "Class",
1410
+ prop: "className",
1411
+ value: String(node.className || ""),
1412
+ });
1413
+ if (node.className) {
1414
+ applyChip(cls.row, cls.plan, "className", propertyChip("className", "class"));
1415
+ }
1416
+ rows.push(withPlan(cls.row, cls.plan));
1417
+
1418
+ if (!Array.isArray(node.children) || node.children.length === 0) {
1419
+ const textDraft = draft(draftKey(path, "textContent"));
1420
+ const text = blankRow("prop|textContent", "textContent", "Text Content");
1421
+ text.kind = "textarea";
1422
+ text.value = getFieldValue(
1423
+ textDraft,
1424
+ typeof node.textContent === "string" ? node.textContent : "",
1425
+ );
1426
+ const plan: RowPlan = {
1427
+ commit: writeProperty("textContent"),
1428
+ debounceMs: LIVE_PREVIEW,
1429
+ draft: textDraft,
1430
+ };
1431
+ applyLadder(text, plan, {
1432
+ caps: "textProperty",
1433
+ extraSignals: mapSignals,
1434
+ fieldKey: `prop|${path.join("/")}|textContent`,
1435
+ onChange: (v) =>
1436
+ transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, path, "textContent", v)),
1437
+ stateDefs: bindableSignals,
1438
+ value: node.textContent,
1253
1439
  });
1440
+ if (node.textContent !== undefined) {
1441
+ applyChip(text, plan, "textContent", propertyChip("textContent", "text"));
1442
+ }
1443
+ rows.push(withPlan(text, plan));
1444
+ }
1254
1445
 
1255
- const customSectionT =
1256
- customAttrs.length > 0 || Object.keys(attrs).length > 0
1257
- ? html`
1258
- <sp-accordion-item
1259
- label="Custom"
1260
- ?open=${isSectionOpen("__custom")}
1261
- @sp-accordion-item-toggle=${() => toggleSection("__custom")}
1262
- >
1263
- ${sectionDot(customAttrs.length)}
1264
- <div class="style-section-body">
1265
- ${renderCustomAttrsFieldsTemplate(path, attrs, knownAttrNames)}
1266
- </div>
1267
- </sp-accordion-item>
1268
- `
1269
- : nothing;
1270
-
1271
- return html`
1272
- <div class="style-sidebar">
1273
- <sp-accordion allow-multiple size="s">
1274
- ${elemT} ${compPropsT} ${usagesT} ${attrSectionTemplates} ${customSectionT}
1275
- </sp-accordion>
1276
- </div>
1277
- `;
1446
+ const hidden = blankRow("prop|hidden", "hidden", "Hidden");
1447
+ hidden.kind = "checkbox";
1448
+ hidden.checked = Boolean(node.hidden);
1449
+ const hiddenPlan: RowPlan = {
1450
+ commitChecked: (checked) =>
1451
+ transactDoc(activeTab.value, (t) =>
1452
+ mutateUpdateProperty(t, path, "hidden", checked || undefined),
1453
+ ),
1454
+ debounceMs: 0,
1455
+ };
1456
+ if (node.hidden) {
1457
+ applyChip(hidden, hiddenPlan, "hidden", propertyChip("hidden", "hidden"));
1458
+ }
1459
+ rows.push(withPlan(hidden, hiddenPlan));
1460
+
1461
+ return rows;
1278
1462
  }
1279
1463
 
1464
+ // ─── Component settings ──────────────────────────────────────────────────────
1465
+
1280
1466
  /**
1281
- * The collapsed-header provenance dot (§6.2), and the end of a false affordance.
1467
+ * Component props, each row carrying §6.2's provenance chip — the SECOND cascade.
1282
1468
  *
1283
- * It inherited `.set-dot`'s `cursor: pointer` and its "turns red on hover" rule, which together say
1284
- * "click me to clear this" — and nothing happened, because it never had a handler. It is not a
1285
- * control and it is not deleted either: §6.2 puts a provenance dot on collapsed accordion headers
1286
- * precisely so a section's state is legible while closed, and clicking anywhere in the header
1287
- * already toggles the section, so a second, different meaning for the same click would be worse
1288
- * than the silence. So it keeps the information and gives up the affordance: an inert, labelled
1289
- * indicator that says how many values in the section are set.
1469
+ * A component instance's value for a prop either overrides the component's declared default or does
1470
+ * not exist, and until the chip landed the two were the same blank field. The chip states which,
1471
+ * and in the inherited case it names the donor AND jumps to it: the donor of a component default is
1472
+ * the component definition, which this panel can already open.
1290
1473
  */
1291
- function sectionDot(setCount: number) {
1292
- return setCount > 0
1293
- ? html`<span
1294
- slot="heading"
1295
- class="set-dot set-dot--section"
1296
- aria-hidden="true"
1297
- title=${`${setCount} value${setCount === 1 ? "" : "s"} set in this section`}
1298
- ></span>`
1299
- : nothing;
1474
+ function componentPropRows(
1475
+ node: JxMutableNode,
1476
+ path: JxPath,
1477
+ mapSignals: SignalOption[] | null,
1478
+ definitionOfThis: boolean,
1479
+ ): ContentRowView[] {
1480
+ const tab = activeTab.value!;
1481
+ const comp = componentRegistry.find((c) => c.tagName === displayTagName(node.tagName));
1482
+ if (!comp || !comp.props) {
1483
+ return [
1484
+ noteRow(
1485
+ "props|unknown",
1486
+ "This component is not in the project's library, so it has no settings to show.",
1487
+ ),
1488
+ ];
1489
+ }
1490
+ const isNpm = comp.source === "npm";
1491
+ /* In the definition the "value" of a setting is its DECLARED DEFAULT — the state entry's
1492
+ `default` — not a per-instance override, so the row reads and writes there instead. */
1493
+ const stateEntries = (tab.doc.document.state ?? {}) as Record<string, { default?: JsonValue }>;
1494
+ const currentVals: Record<string, unknown> = definitionOfThis
1495
+ ? Object.fromEntries(comp.props.map((prop) => [prop.name, stateEntries[prop.name]?.default]))
1496
+ : isNpm
1497
+ ? node.attributes || {}
1498
+ : node.$props || {};
1499
+ const updateFn = definitionOfThis
1500
+ ? (name: string, v?: JsonValue) =>
1501
+ transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { default: v }))
1502
+ : isNpm
1503
+ ? (name: string, v?: JsonValue) =>
1504
+ transactDoc(activeTab.value, (t) =>
1505
+ mutateUpdateAttribute(
1506
+ t,
1507
+ path,
1508
+ name,
1509
+ v === "" ? undefined : (v as JxAttributeValue | undefined),
1510
+ ),
1511
+ )
1512
+ : (name: string, v?: JsonValue) =>
1513
+ transactDoc(activeTab.value, (t) => mutateUpdateProp(t, path, name, v));
1514
+
1515
+ const defs = tab.doc.document.state || {};
1516
+ const signalNames = bindableSignalNames(tab.doc.document);
1517
+ const definitionPath = comp.path;
1518
+ /* One closure for the whole list rather than one per prop: it says the same thing every time,
1519
+ and building it inside the loop is what made every row carry its own copy of it. */
1520
+ const openDefinition =
1521
+ definitionPath && !definitionOfThis ? () => navigateToComponent(definitionPath) : undefined;
1522
+ const rows: ContentRowView[] = [];
1523
+
1524
+ for (const prop of comp.props) {
1525
+ const rawValue = currentVals[prop.name];
1526
+ const boundRef = isRef(rawValue) ? rawValue.$ref : null;
1527
+ const hasVal = rawValue !== undefined && rawValue !== null;
1528
+ const parsed = parseCemType(prop.type);
1529
+ const onChange = (v?: JsonValue) => updateFn(prop.name, v);
1530
+ const staticVal = slotMode(rawValue) === "literal" ? String(rawValue ?? "") : "";
1531
+ const key = `cprop|${prop.name}`;
1532
+ const propDraft = draft(`cprop:${path.join("/")}:${prop.name}`);
1533
+ const row = blankRow(key, prop.name, camelToLabel(prop.name));
1534
+ const plan: RowPlan = { debounceMs: LIVE_PREVIEW };
1535
+
1536
+ if (isMediaFormat(prop.format)) {
1537
+ row.kind = "control";
1538
+ plan.control = (host) =>
1539
+ mountMediaPicker(host, prop.name, staticVal, (v: string) => onChange(v));
1540
+ } else if (prop.format === "color") {
1541
+ row.kind = "color";
1542
+ row.tokens = colorTokens();
1543
+ row.hasTokens = row.tokens.length > 0;
1544
+ row.value = getFieldValue(propDraft, staticVal);
1545
+ row.resolved = resolvedColor(row.value);
1546
+ plan.commit = (raw) => onChange(raw);
1547
+ plan.draft = propDraft;
1548
+ } else if (prop.format === "date") {
1549
+ row.kind = "text";
1550
+ row.placeholder = "YYYY-MM-DD";
1551
+ row.value = getFieldValue(propDraft, staticVal);
1552
+ plan.commit = (raw) => onChange(raw);
1553
+ plan.draft = propDraft;
1554
+ } else if (parsed.kind === "boolean") {
1555
+ row.kind = "checkbox";
1556
+ row.checked = Boolean(staticVal);
1557
+ plan.commitChecked = (checked) => onChange(checked || undefined);
1558
+ } else if (parsed.kind === "number") {
1559
+ row.kind = "number";
1560
+ row.value = getFieldValue(propDraft, staticVal);
1561
+ plan.commit = (raw) => onChange(raw);
1562
+ plan.draft = propDraft;
1563
+ } else if (parsed.kind === "combobox") {
1564
+ row.kind = "select";
1565
+ row.value = staticVal;
1566
+ row.options = [
1567
+ CLEAR_OPTION,
1568
+ ...(parsed.options as string[]).map((o) => ({ label: camelToLabel(o), value: o })),
1569
+ ];
1570
+ plan.commit = (raw) => onChange(raw || undefined);
1571
+ plan.debounceMs = 0;
1572
+ } else {
1573
+ row.kind = "text";
1574
+ row.value = getFieldValue(propDraft, staticVal);
1575
+ plan.commit = (raw) => onChange(raw);
1576
+ plan.draft = propDraft;
1577
+ }
1578
+
1579
+ applyLadder(row, plan, {
1580
+ caps: "componentProp",
1581
+ extraSignals: mapSignals,
1582
+ fieldKey: `cprop|${path.join("/")}|${prop.name}`,
1583
+ // De-escalating to literal restores the bound signal's declared default (the old unbind).
1584
+ literalDefault: boundRef
1585
+ ? defaultAsString(defs[boundRef.startsWith("#/state/") ? boundRef.slice(8) : boundRef]) ||
1586
+ undefined
1587
+ : undefined,
1588
+ onChange,
1589
+ stateDefs: signalNames,
1590
+ value: rawValue,
1591
+ });
1592
+ applyChip(
1593
+ row,
1594
+ plan,
1595
+ prop.name,
1596
+ componentPropProvenance(prop, rawValue, hasVal, {
1597
+ // No donor and no jump in the definition: the value IS the component's default, and
1598
+ // "→ the component definition" would open the document already in front of you.
1599
+ isDefinition: definitionOfThis,
1600
+ onClear: () => updateFn(prop.name),
1601
+ ...(openDefinition ? { openDefinition } : {}),
1602
+ }),
1603
+ );
1604
+ rows.push(withPlan(row, plan));
1605
+ }
1606
+
1607
+ if (comp.props.length === 0) {
1608
+ rows.push(
1609
+ noteRow(
1610
+ "props|empty",
1611
+ definitionOfThis
1612
+ ? "This component declares no settings yet. Add a state entry with a default to give " +
1613
+ "whoever places it something to fill in."
1614
+ : "This component has no settings to fill in yet.",
1615
+ ),
1616
+ );
1617
+ }
1618
+ // A link to the definition, from inside the definition, is a link to here.
1619
+ if (openDefinition) {
1620
+ rows.push(
1621
+ actionRow("props|edit-definition", "editDefinition", "→ Edit definition", openDefinition),
1622
+ );
1623
+ }
1624
+ return rows;
1300
1625
  }
1301
1626
 
1302
- /** Which of §6.2's states an HTML-attribute row is in. Attributes have no third cascade layer. */
1303
- function attributeProvenance(
1627
+ // ─── HTML attributes ─────────────────────────────────────────────────────────
1628
+
1629
+ interface AttrCtx {
1630
+ node: JxMutableNode;
1631
+ path: JxPath;
1632
+ doc: JxMutableNode;
1633
+ targets: JxPath[];
1634
+ tagName: string;
1635
+ mapSignals: SignalOption[] | null;
1636
+ bindableSignals: string[];
1637
+ }
1638
+
1639
+ function attributeRow(
1304
1640
  attr: string,
1641
+ entry: HtmlMetaEntry,
1305
1642
  value: unknown,
1306
- hasVal: boolean,
1307
- onClear: () => void,
1308
- mixedCount = 0,
1309
- ): FieldProvenance {
1310
- // Mixed first, for the same reason the Style tab decides it first: it is a fact about the
1311
- // SELECTION, and the primary's own value says nothing about whether the others agree. A count of
1312
- // 0 (a selection of one) can never reach this branch, so single-selection rows are untouched.
1313
- if (mixedCount > 0) {
1314
- return { donor: String(mixedCount), onClick: onClear, state: "mixed" };
1643
+ ctx: AttrCtx,
1644
+ ): ContentRowView {
1645
+ const { doc, node, path, tagName, targets } = ctx;
1646
+ const hasVal = value !== undefined && value !== "";
1647
+ const key = `attr|${attr}`;
1648
+ const label = attrLabel(entry, attr);
1649
+ // One write per selected element, inside ONE transaction (§6.5). `targets` is `[path]` for a
1650
+ // Single selection, so this is the same single mutation it has always been.
1651
+ const commitAttr = (v?: JsonValue) =>
1652
+ transactDoc(activeTab.value!, (t) => {
1653
+ for (const target of targets) {
1654
+ mutateUpdateAttribute(t, target, attr, v as JxAttributeValue | undefined);
1655
+ }
1656
+ });
1657
+ const clearAttr = () =>
1658
+ transactDoc(activeTab.value, (t) => {
1659
+ for (const target of targets) {
1660
+ mutateUpdateAttribute(t, target, attr);
1661
+ }
1662
+ });
1663
+ const chip = () =>
1664
+ attributeProvenance(
1665
+ attr,
1666
+ value,
1667
+ hasVal,
1668
+ clearAttr,
1669
+ mixedAcrossSelection(doc, targets, (n) => (n?.attributes ?? {})[attr] ?? null),
1670
+ );
1671
+
1672
+ // Enhanced Link handling: only for anchors (a/area) with a plain (non-binding) value. Bindings
1673
+ // ($ref objects or ${…} template strings) fall through to the raw widget to stay editable.
1674
+ const isAnchor = tagName === "a" || tagName === "area";
1675
+ if (isAnchor && !isBoundAttrValue(value)) {
1676
+ if (attr === "href") {
1677
+ return linkRow(key, label, node, path);
1678
+ }
1679
+ if (attr === "target") {
1680
+ const row = blankRow(key, attr, label);
1681
+ row.kind = "select";
1682
+ row.value = typeof node.attributes?.target === "string" ? node.attributes.target : "";
1683
+ row.options = [
1684
+ CLEAR_OPTION,
1685
+ ...(Array.isArray(entry.enum) ? (entry.enum as string[]) : []).map((o) => ({
1686
+ label: o,
1687
+ value: o,
1688
+ })),
1689
+ ];
1690
+ const plan: RowPlan = { commit: (raw) => commitAttr(raw || undefined), debounceMs: 0 };
1691
+ applyChip(row, plan, attr, chip());
1692
+ return withPlan(row, plan);
1693
+ }
1315
1694
  }
1316
- if (hasVal && (isRef(value) || (typeof value === "string" && value.includes("${")))) {
1317
- return boundProvenance(value, `Bound — ${attr}`);
1695
+
1696
+ /* Same gate as the anchor fields above: a BOUND value falls through to the raw widget so it stays
1697
+ editable as an expression rather than being flattened into a picker. */
1698
+ if (entry.$input === "popover-target" && !isBoundAttrValue(value)) {
1699
+ const row = blankRow(key, attr, label);
1700
+ row.kind = "select";
1701
+ row.value = typeof value === "string" ? value : "";
1702
+ row.options = [
1703
+ CLEAR_OPTION,
1704
+ ...popoverIdsIn((activeTab.value?.doc.document ?? {}) as JxElement, {
1705
+ popoverTags: POPOVER_TAGS,
1706
+ }).map((id) => ({ label: id, value: id })),
1707
+ { label: "Type an id…", value: POPOVER_TARGET_OTHER },
1708
+ ];
1709
+ const plan: RowPlan = {
1710
+ commit: (raw) => {
1711
+ if (raw === POPOVER_TARGET_OTHER) {
1712
+ /* It stays typeable, because the list can never be complete: a `popovertarget` resolves
1713
+ in the RENDERED DOM, and a page composes components whose internals this document
1714
+ cannot see. */
1715
+ void showPromptDialog("Popover id", {
1716
+ confirmLabel: "Use",
1717
+ message: "The id of a popover in another file.",
1718
+ placeholder: "site-mobile-menu",
1719
+ }).then((typed) => commitAttr(typed?.trim() || undefined));
1720
+ return;
1721
+ }
1722
+ commitAttr(raw || undefined);
1723
+ },
1724
+ debounceMs: 0,
1725
+ };
1726
+ applyChip(row, plan, attr, chip());
1727
+ return withPlan(row, plan);
1318
1728
  }
1319
- return hasVal ? { onClick: onClear, state: "set", title: `Clear ${attr}` } : { state: "default" };
1320
- }
1321
1729
 
1322
- /**
1323
- * How many selected elements disagree about `key` on `read` — 0 when they agree or there is one.
1324
- *
1325
- * The Content tab's two cascades (HTML attributes and component props) both need the same answer
1326
- * about the same set, so the question is asked once here and answered with `unifyValues`, the same
1327
- * comparison the Style tab uses.
1328
- *
1329
- * @param {JxMutableNode} doc
1330
- * @param {readonly JxPath[]} targets
1331
- * @param {(node: JxMutableNode | undefined) => unknown} read
1332
- * @returns {number}
1333
- */
1334
- export function mixedAcrossSelection(
1335
- doc: JxMutableNode,
1336
- targets: readonly JxPath[],
1337
- read: (node: JxMutableNode | undefined) => unknown,
1338
- ): number {
1339
- if (targets.length < 2) {
1340
- return 0;
1730
+ const row = blankRow(key, attr, label);
1731
+ const attrDraft = draft(`attr:${path.join("/")}:${attr}`);
1732
+ const plan: RowPlan = { debounceMs: INPUT_DEBOUNCE };
1733
+ const type = inferInputType(entry);
1734
+ if (entry.type === "boolean") {
1735
+ row.kind = "checkbox";
1736
+ row.checked = Boolean(value);
1737
+ plan.commitChecked = (checked) => commitAttr(checked ? "" : undefined);
1738
+ } else if (type === "media") {
1739
+ row.kind = "control";
1740
+ const staticVal = isRef(value) ? "" : String(value || "");
1741
+ plan.control = (host) =>
1742
+ mountMediaPicker(host, attr, staticVal, (v: string) => commitAttr(v || undefined));
1743
+ } else if (type === "select") {
1744
+ row.kind = "select";
1745
+ row.value = isRef(value) ? "" : String(value || "");
1746
+ row.options = [
1747
+ CLEAR_OPTION,
1748
+ ...(Array.isArray(entry.enum) ? (entry.enum as string[]) : []).map((o) => ({
1749
+ label: o,
1750
+ value: o,
1751
+ })),
1752
+ ];
1753
+ plan.commit = (raw) => commitAttr(raw || undefined);
1754
+ plan.debounceMs = 0;
1755
+ } else {
1756
+ row.kind = "text";
1757
+ row.value = getFieldValue(attrDraft, isRef(value) ? "" : String(value || ""));
1758
+ plan.commit = (raw) => commitAttr(raw || undefined);
1759
+ plan.draft = attrDraft;
1341
1760
  }
1342
- const values = targets.map((path) => read(getNodeAtPath(doc, path) as JxMutableNode | undefined));
1343
- return unifyValues(values).mixed ? targets.length : 0;
1344
- }
1345
1761
 
1346
- // ─── Section state ────────────────────────────────────────────────────────────
1762
+ applyLadder(row, plan, {
1763
+ caps: "attribute",
1764
+ extraSignals: ctx.mapSignals,
1765
+ fieldKey: `attr|${path.join("/")}|${attr}`,
1766
+ onChange: commitAttr,
1767
+ stateDefs: ctx.bindableSignals,
1768
+ value,
1769
+ });
1770
+ applyChip(row, plan, attr, chip());
1771
+ return withPlan(row, plan);
1772
+ }
1347
1773
 
1348
1774
  /**
1349
- * The Inspector's fixed section keys — the accordion rows the inspector always draws.
1775
+ * The Link target: a kind and a value over one `href`.
1350
1776
  *
1351
- * Attribute-schema sections add their own keys at render time (a class's `$section`), so this is a
1352
- * floor, not a closed set: {@link inspectorSectionKeys} unions it with whatever the OPEN DOCUMENT
1353
- * declares, and that union is what `inspector.setSection` validates against. A fixed enum would
1354
- * refuse a section the user is looking at; no validation at all would accept the label the old
1355
- * `inspector.toggleSection` step passed ("Element", not `__element`) and silently do nothing.
1356
- *
1357
- * `__media` left with the breakpoint definitions, which are Project Settings › Contexts now (P4).
1358
- * `__observed`, `__cssprops` and `__cssparts` are drawn by the Logic tab; the key space is the
1359
- * INSPECTOR's, not one tab's, so `inspector.setSection` keeps addressing all of them.
1777
+ * `utils/link-target.ts` classifies the current value and composes the next one, so switching kind
1778
+ * REINTERPRETS what is there rather than throwing it away — `a@b.com` picked as Email becomes
1779
+ * `mailto:a@b.com`. Internal targets offer the routes enumerated from the `pages/` tree.
1360
1780
  */
1361
- export const INSPECTOR_SECTION_KEYS = [
1362
- "__element",
1363
- "__observed",
1364
- "__usages",
1365
- "__custom",
1366
- "__cssprops",
1367
- "__cssparts",
1368
- ] as const;
1781
+ function linkRow(key: string, label: string, node: JxMutableNode, path: JxPath): ContentRowView {
1782
+ const raw = typeof node.attributes?.href === "string" ? node.attributes.href : "";
1783
+ const { kind, value } = classifyHref(raw);
1784
+ const commit = (nextKind: LinkKind, nextValue: string) => {
1785
+ const composed = composeHref(nextKind, nextValue);
1786
+ transactDoc(activeTab.value!, (t) =>
1787
+ mutateUpdateAttribute(t, path, "href", composed || undefined),
1788
+ );
1789
+ };
1369
1790
 
1370
- export type InspectorSectionKey = (typeof INSPECTOR_SECTION_KEYS)[number];
1791
+ const row = blankRow(key, "href", label);
1792
+ row.kind = "link";
1793
+ row.linkKind = kind;
1794
+ row.kindOptions = [
1795
+ { label: "Internal Page", value: "internal" },
1796
+ { label: "External URL", value: "external" },
1797
+ { label: "Anchor", value: "anchor" },
1798
+ { label: "Email", value: "mailto" },
1799
+ { label: "Phone", value: "tel" },
1800
+ ];
1801
+ const plan: RowPlan = {
1802
+ commit: (next) => commit(kind, next),
1803
+ commitKind: (nextKind) => commit(nextKind as LinkKind, value),
1804
+ debounceMs: INPUT_DEBOUNCE,
1805
+ };
1371
1806
 
1372
- /** Section keys addressable right now: the fixed rows plus any already recorded for this tab. */
1373
- export function inspectorSectionKeys(): string[] {
1374
- const recorded = Object.keys(activeTab.value?.session.ui.inspectorSections ?? {});
1375
- return [...new Set<string>([...INSPECTOR_SECTION_KEYS, ...recorded])];
1807
+ if (kind === "internal") {
1808
+ if (pageRouteEntries === null && !pageRoutesLoading) {
1809
+ void loadPageRouteEntries();
1810
+ }
1811
+ row.linkValueKind = "route";
1812
+ row.value = value;
1813
+ // `jx-select` stands in a row for a value no option holds, so a route this document cannot
1814
+ // Enumerate — one in another file, or a listing that failed — stays selected and offered.
1815
+ row.options = (pageRouteEntries ?? []).map((r) => ({ label: r, value: r }));
1816
+ plan.debounceMs = 0;
1817
+ } else {
1818
+ const linkDraft = draft(`link:${path.join("/")}:href`);
1819
+ row.linkValueKind = "text";
1820
+ row.value = getFieldValue(linkDraft, value);
1821
+ row.placeholder =
1822
+ kind === "mailto"
1823
+ ? "name@example.com"
1824
+ : kind === "tel"
1825
+ ? "+15551234567"
1826
+ : kind === "anchor"
1827
+ ? "section-id"
1828
+ : "https://example.com";
1829
+ plan.draft = linkDraft;
1830
+ }
1831
+
1832
+ applyChip(
1833
+ row,
1834
+ plan,
1835
+ "href",
1836
+ raw === ""
1837
+ ? { state: "default" }
1838
+ : {
1839
+ onClick: () =>
1840
+ transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, "href")),
1841
+ state: "set",
1842
+ title: "Clear href",
1843
+ },
1844
+ );
1845
+ return withPlan(row, plan);
1376
1846
  }
1377
1847
 
1378
- /**
1379
- * Whether one Inspector section is expanded, falling back to the caller's default.
1380
- *
1381
- * The fallback is the caller's because it is not a constant: an attribute section opens itself when
1382
- * one of its attributes is set, and Usage never does. Only the panel drawing the row knows.
1383
- */
1384
- export function isInspectorSectionOpen(section: string, fallback: boolean): boolean {
1385
- const recorded = activeTab.value?.session.ui.inspectorSections?.[section];
1386
- return recorded === undefined ? fallback : recorded;
1848
+ // ─── Custom attributes ───────────────────────────────────────────────────────
1849
+
1850
+ function customRows(path: JxPath, customAttrs: [string, unknown][]): ContentRowView[] {
1851
+ const rows: ContentRowView[] = customAttrs.map(([attr, val]) => {
1852
+ const key = `kv|${attr}`;
1853
+ const nameKey = draft(`kvname:${path.join("/")}:${attr}`);
1854
+ const valueKey = draft(`kvval:${path.join("/")}:${attr}`);
1855
+ const row = blankRow(key, attr, `${attr} value`);
1856
+ row.shape = "kv";
1857
+ row.name = getFieldValue(nameKey, attr);
1858
+ row.value = getFieldValue(valueKey, String(val));
1859
+ row.removeLabel = `Remove ${attr}`;
1860
+ const plan: RowPlan = {
1861
+ action: () => transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, attr)),
1862
+ debounceMs: INPUT_DEBOUNCE,
1863
+ kv: {
1864
+ name: attr,
1865
+ nameKey,
1866
+ value: String(val),
1867
+ valueKey,
1868
+ write: (nextAttr, nextVal) => {
1869
+ if (nextAttr !== attr) {
1870
+ transactDoc(activeTab.value, (t) => {
1871
+ mutateUpdateAttribute(t, path, attr);
1872
+ mutateUpdateAttribute(t, path, nextAttr, nextVal);
1873
+ });
1874
+ } else {
1875
+ transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, attr, nextVal));
1876
+ }
1877
+ },
1878
+ },
1879
+ };
1880
+ return withPlan(row, plan);
1881
+ });
1882
+ rows.push(
1883
+ actionRow("kv|add", "addAttribute", "+ Add attribute", () =>
1884
+ transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, "data-", "")),
1885
+ ),
1886
+ );
1887
+ return rows;
1387
1888
  }
1388
1889
 
1890
+ // ─── Usage (§9.6) ────────────────────────────────────────────────────────────
1891
+
1389
1892
  /**
1390
- * Open or close one Inspector section, per tab.
1893
+ * The component instance's usage line — "Used on 7 pages →", expanding to the files.
1391
1894
  *
1392
- * The pair `{ section, open }` is the whole point: `inspector.toggleSection` named a DELTA against
1393
- * a section whose state depended on the selected node's own attributes (`autoOpen`), so the same
1394
- * three manifest steps opened a section on one document and closed it on the next. Writing a new
1395
- * object rather than mutating in place is what the reactive read in `isSectionOpen` depends on.
1895
+ * Three things this deliberately does NOT do. It does not render when the host cannot answer
1896
+ * (`usagesSupported()` is `capability.findReferences`): a confident "0" for a component used
1897
+ * everywhere is worse than no line at all. It does not ask on every paint — `peekUsages` is
1898
+ * side-effect-free and only a cold target starts a request, which re-projects once when it lands.
1899
+ * And it does not swallow a failure: a failed count says so and offers Retry, because "we could not
1900
+ * check" and "nothing uses this" are the two answers a user must never confuse.
1396
1901
  */
1397
- export function setInspectorSection(section: string, open: boolean): void {
1398
- const tab = activeTab.value;
1399
- if (!tab) {
1400
- return;
1902
+ function usagesSection(tagName: string, componentPath: string | null): ContentSectionView | null {
1903
+ const query = { tagName, ...(componentPath ? { path: componentPath } : {}) };
1904
+ const state = peekUsages(query);
1905
+ // ONE gate, and it is the peek itself: a host with no `findReferences` answers "unsupported"
1906
+ // Synchronously, and the section does not exist.
1907
+ if (state?.status === "unsupported") {
1908
+ return null;
1401
1909
  }
1402
- tab.session.ui.inspectorSections = { ...tab.session.ui.inspectorSections, [section]: open };
1910
+ if (state === null) {
1911
+ // Cold: ask once, and re-project when the answer arrives.
1912
+ void loadUsages(query).then(() => renderPropertiesPanel());
1913
+ }
1914
+
1915
+ const rows: ContentRowView[] = [];
1916
+ let heading = "Usage";
1917
+ if (state === null || state.status === "pending") {
1918
+ rows.push(noteRow("usage|pending", "Counting references…"));
1919
+ } else if (state.status === "failed") {
1920
+ heading = "Usage · unknown";
1921
+ rows.push(
1922
+ noteRow(
1923
+ "usage|failed",
1924
+ `References could not be counted: ${state.message}. This is not the same as “unused”.`,
1925
+ ),
1926
+ actionRow("usage|retry", "retryUsages", "Retry", () => {
1927
+ void retryUsages(query).then(() => renderPropertiesPanel());
1928
+ }),
1929
+ );
1930
+ } else {
1931
+ const files = usageFiles(state.result);
1932
+ heading = usageHeadline(state.result);
1933
+ if (files.length === 0) {
1934
+ rows.push(noteRow("usage|none", `Nothing else in this project places <${tagName}> yet.`));
1935
+ } else {
1936
+ for (const file of files) {
1937
+ const row = blankRow(`usage|${file.path}`, file.path, file.path);
1938
+ row.shape = "file";
1939
+ row.text = file.path;
1940
+ row.detail = String(file.count);
1941
+ row.title = file.refs.map((r) => `${r.refType} ${r.ref} ×${r.count}`).join(", ");
1942
+ rows.push(withPlan(row, { debounceMs: 0, openPath: file.path }));
1943
+ }
1944
+ }
1945
+ }
1946
+ return sectionView(
1947
+ USAGES_SECTION,
1948
+ heading,
1949
+ isInspectorSectionOpen(USAGES_SECTION, false),
1950
+ rows,
1951
+ 0,
1952
+ );
1953
+ }
1954
+
1955
+ // ─── The two rows that are not fields ────────────────────────────────────────
1956
+
1957
+ function noteRow(key: string, text: string): ContentRowView {
1958
+ const row = blankRow(key, key, "");
1959
+ row.shape = "note";
1960
+ row.text = text;
1961
+ plans.set(key, { debounceMs: 0 });
1962
+ return row;
1963
+ }
1964
+
1965
+ function actionRow(key: string, prop: string, text: string, run: () => void): ContentRowView {
1966
+ const row = blankRow(key, prop, text);
1967
+ row.shape = "action";
1968
+ row.text = text;
1969
+ return withPlan(row, { action: run, debounceMs: 0 });
1403
1970
  }
1404
1971
 
1405
1972
  // ─── Commands ─────────────────────────────────────────────────────────────────
1406
1973
 
1407
- /**
1408
- * The Inspector's section verb. **This is the setter that retires `inspector.toggleSection`.**
1409
- *
1410
- * @returns {AnyCommand[]}
1411
- */
1974
+ /** The Inspector's section verb. **This is the setter that retires `inspector.toggleSection`.** */
1412
1975
  export function inspectorCommands(): AnyCommand[] {
1413
1976
  return [
1414
1977
  {
@@ -1467,7 +2030,7 @@ export function inspectorCommands(): AnyCommand[] {
1467
2030
  const componentPath = componentRegistry.find((c) => c.tagName === tagName)?.path ?? null;
1468
2031
  setInspectorSection(USAGES_SECTION, true);
1469
2032
  void loadUsages({ tagName, ...(componentPath ? { path: componentPath } : {}) }).then(() =>
1470
- renderOnly("rightPanel"),
2033
+ renderPropertiesPanel(),
1471
2034
  );
1472
2035
  },
1473
2036
  title: "Find Usages",
@@ -1494,11 +2057,7 @@ function componentSelectionTag(): string | null {
1494
2057
  return tagName.includes("-") ? tagName : null;
1495
2058
  }
1496
2059
 
1497
- /**
1498
- * Register the Inspector's section verb.
1499
- *
1500
- * @param {CommandRegistry} registry
1501
- */
2060
+ /** Register the Inspector's section verb. */
1502
2061
  export function registerInspectorCommands(registry: CommandRegistry): void {
1503
2062
  registry.registerAll(inspectorCommands());
1504
2063
  }