@jxsuite/studio 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (419) hide show
  1. package/canvas.html +21 -0
  2. package/dist/chunks/Overlay.dev-qfjzkms8.js +23 -0
  3. package/dist/chunks/{Overlay.dev-cszx7yn5.js.map → Overlay.dev-qfjzkms8.js.map} +1 -1
  4. package/dist/chunks/{adapter-z8fkmbmv.js → adapter-qfnesn5v.js} +2 -2
  5. package/dist/chunks/{cf-settings-mmyv436d.js → cf-settings-73sh5z4p.js} +3 -3
  6. package/dist/chunks/{components-0hzentwq.js → components-2bpqp4mq.js} +5 -7
  7. package/dist/chunks/{components-0hzentwq.js.map → components-2bpqp4mq.js.map} +1 -1
  8. package/dist/chunks/content-source-34gnkbnn.js +38 -0
  9. package/dist/chunks/content-source-34gnkbnn.js.map +9 -0
  10. package/dist/chunks/convert-to-repeater-t8kxre5s.js +258 -0
  11. package/dist/chunks/convert-to-repeater-t8kxre5s.js.map +10 -0
  12. package/dist/chunks/extension-sections-6mqydz35.js +301 -0
  13. package/dist/chunks/extension-sections-6mqydz35.js.map +11 -0
  14. package/dist/chunks/files-8kn321h1.js +70 -0
  15. package/dist/chunks/files-8kn321h1.js.map +9 -0
  16. package/dist/chunks/{format-host-2c188n41.js → format-host-mkwsmt8d.js} +3 -3
  17. package/dist/chunks/git-panel-tjbr191d.js +51 -0
  18. package/dist/chunks/git-panel-tjbr191d.js.map +9 -0
  19. package/dist/chunks/{index-baeg2x3y.js → index-7pxchcce.js} +4 -3
  20. package/dist/chunks/{index-baeg2x3y.js.map → index-7pxchcce.js.map} +3 -3
  21. package/dist/chunks/{jx-validate-qx8degj7.js → jx-validate-v6qgknrs.js} +3 -3
  22. package/dist/chunks/layers-panel-87pb25km.js +58 -0
  23. package/dist/chunks/layers-panel-87pb25km.js.map +9 -0
  24. package/dist/chunks/{monaco-setup-zjdp9zdw.js → monaco-setup-e0yh25j4.js} +4 -75
  25. package/dist/chunks/{monaco-setup-zjdp9zdw.js.map → monaco-setup-e0yh25j4.js.map} +3 -4
  26. package/dist/chunks/publish-panel-m0fcgdrt.js +21 -0
  27. package/dist/chunks/publish-panel-m0fcgdrt.js.map +9 -0
  28. package/dist/chunks/quick-search-rhs6cbsb.js +53 -0
  29. package/dist/chunks/quick-search-rhs6cbsb.js.map +9 -0
  30. package/dist/chunks/right-panel-zpdxd98t.js +37 -0
  31. package/dist/chunks/right-panel-zpdxd98t.js.map +9 -0
  32. package/dist/chunks/section-registry-zmckaxf4.js +30 -0
  33. package/dist/chunks/section-registry-zmckaxf4.js.map +9 -0
  34. package/dist/chunks/sp-overlay.dev-9ran0sfq.js +18 -0
  35. package/dist/chunks/sp-overlay.dev-9ran0sfq.js.map +9 -0
  36. package/dist/chunks/sp-popover.dev-rebmdncr.js +9 -0
  37. package/dist/chunks/sp-popover.dev-rebmdncr.js.map +9 -0
  38. package/dist/chunks/sp-progress-circle.dev-5vkppfkt.js +15 -0
  39. package/dist/chunks/sp-progress-circle.dev-5vkppfkt.js.map +9 -0
  40. package/dist/chunks/sp-tooltip.dev-ykqrg8h5.js +15 -0
  41. package/dist/chunks/sp-tooltip.dev-ykqrg8h5.js.map +9 -0
  42. package/dist/chunks/{sp-tray.dev-pb2fsd65.js → sp-tray.dev-ey59vfh7.js} +10 -8
  43. package/dist/chunks/{sp-tray.dev-pb2fsd65.js.map → sp-tray.dev-ey59vfh7.js.map} +2 -2
  44. package/dist/chunks/{studio-9qr6fq7p.js → studio-0rqqhrdy.js} +10 -378
  45. package/dist/chunks/{studio-9qr6fq7p.js.map → studio-0rqqhrdy.js.map} +4 -14
  46. package/dist/chunks/{studio-5x6tqqec.js → studio-2sj1jj3v.js} +10 -8
  47. package/dist/chunks/{studio-5x6tqqec.js.map → studio-2sj1jj3v.js.map} +2 -2
  48. package/dist/chunks/{studio-h3fw0wn8.js → studio-3fk1ac9z.js} +6 -4
  49. package/dist/chunks/{studio-h3fw0wn8.js.map → studio-3fk1ac9z.js.map} +2 -2
  50. package/dist/chunks/studio-41exgzhq.js +6690 -0
  51. package/dist/chunks/studio-41exgzhq.js.map +38 -0
  52. package/dist/chunks/{studio-7v2bpweq.js → studio-4mqb9a0j.js} +6 -4
  53. package/dist/chunks/{studio-7v2bpweq.js.map → studio-4mqb9a0j.js.map} +2 -2
  54. package/dist/chunks/studio-50k77cca.js +1787 -0
  55. package/dist/chunks/studio-50k77cca.js.map +18 -0
  56. package/dist/chunks/{studio-56v3zwk6.js → studio-6a0h7sne.js} +10 -8
  57. package/dist/chunks/{studio-56v3zwk6.js.map → studio-6a0h7sne.js.map} +2 -2
  58. package/dist/chunks/studio-6f0am8f2.js +53 -0
  59. package/dist/chunks/studio-6f0am8f2.js.map +10 -0
  60. package/dist/chunks/studio-6nmjc5qk.js +7002 -0
  61. package/dist/chunks/studio-6nmjc5qk.js.map +32 -0
  62. package/dist/chunks/studio-7afd21nw.js +67 -0
  63. package/dist/chunks/studio-7afd21nw.js.map +10 -0
  64. package/dist/chunks/{studio-xze4zrs1.js → studio-7bmdvzne.js} +2 -2
  65. package/dist/chunks/studio-7bmdvzne.js.map +10 -0
  66. package/dist/chunks/studio-8jpzqk5s.js +128 -0
  67. package/dist/chunks/studio-8jpzqk5s.js.map +10 -0
  68. package/dist/chunks/studio-94m77tgk.js +1482 -0
  69. package/dist/chunks/studio-94m77tgk.js.map +13 -0
  70. package/dist/chunks/studio-9je6w5sq.js +1000 -0
  71. package/dist/chunks/studio-9je6w5sq.js.map +16 -0
  72. package/dist/chunks/{studio-w7c6kvk7.js → studio-a7pzveq1.js} +2 -2
  73. package/dist/chunks/{studio-97qcgvdn.js → studio-adn0z1k3.js} +14 -8
  74. package/dist/chunks/{studio-97qcgvdn.js.map → studio-adn0z1k3.js.map} +3 -3
  75. package/dist/chunks/{studio-kr88ryr7.js → studio-bsxxx3x2.js} +17 -15
  76. package/dist/chunks/{studio-kr88ryr7.js.map → studio-bsxxx3x2.js.map} +7 -6
  77. package/dist/chunks/studio-bw5dz6km.js +608 -0
  78. package/dist/chunks/studio-bw5dz6km.js.map +21 -0
  79. package/dist/chunks/studio-c6s5getc.js +4232 -0
  80. package/dist/chunks/studio-c6s5getc.js.map +39 -0
  81. package/dist/chunks/studio-cjm7fdjf.js +451 -0
  82. package/dist/chunks/studio-cjm7fdjf.js.map +14 -0
  83. package/dist/chunks/{studio-cx91mnh8.js → studio-ckwz4d3x.js} +53 -53
  84. package/dist/chunks/{studio-cx91mnh8.js.map → studio-ckwz4d3x.js.map} +5 -5
  85. package/dist/chunks/studio-dg7zyt5x.js +28 -0
  86. package/dist/chunks/studio-dg7zyt5x.js.map +10 -0
  87. package/dist/chunks/studio-dzcyvvvz.js +166 -0
  88. package/dist/chunks/studio-dzcyvvvz.js.map +10 -0
  89. package/dist/chunks/{studio-7kqe41sa.js → studio-evkr5d3b.js} +15 -7
  90. package/dist/chunks/{studio-7kqe41sa.js.map → studio-evkr5d3b.js.map} +5 -4
  91. package/dist/chunks/studio-f12h6177.js +411 -0
  92. package/dist/chunks/studio-f12h6177.js.map +13 -0
  93. package/dist/chunks/studio-hbp1s8m9.js +1817 -0
  94. package/dist/chunks/studio-hbp1s8m9.js.map +14 -0
  95. package/dist/chunks/studio-k2sxjph5.js +15295 -0
  96. package/dist/chunks/studio-k2sxjph5.js.map +235 -0
  97. package/dist/chunks/studio-myje51t3.js +385 -0
  98. package/dist/chunks/studio-myje51t3.js.map +12 -0
  99. package/dist/chunks/studio-na6rwzhw.js +353 -0
  100. package/dist/chunks/studio-na6rwzhw.js.map +11 -0
  101. package/dist/chunks/{studio-4k0m4fmp.js → studio-p471gg5n.js} +126 -3
  102. package/dist/chunks/studio-p471gg5n.js.map +14 -0
  103. package/dist/chunks/{studio-9bjwf0d4.js → studio-rgwcpast.js} +5 -46
  104. package/dist/chunks/{studio-9bjwf0d4.js.map → studio-rgwcpast.js.map} +4 -5
  105. package/dist/chunks/{studio-hhj92gcr.js → studio-s519zc99.js} +3 -3
  106. package/dist/chunks/studio-sas7mt3k.js +75 -0
  107. package/dist/chunks/studio-sas7mt3k.js.map +10 -0
  108. package/dist/chunks/studio-t5wqwzdh.js +74 -0
  109. package/dist/chunks/studio-t5wqwzdh.js.map +10 -0
  110. package/dist/chunks/{studio-9qpspwfn.js → studio-temv5bw4.js} +5 -5
  111. package/dist/chunks/{studio-9qpspwfn.js.map → studio-temv5bw4.js.map} +1 -1
  112. package/dist/chunks/{studio-qkvt37c0.js → studio-tr20m8g8.js} +669 -18
  113. package/dist/chunks/studio-tr20m8g8.js.map +9 -0
  114. package/dist/chunks/{studio-ygzav7z5.js → studio-v96qfnww.js} +5514 -4291
  115. package/dist/chunks/studio-v96qfnww.js.map +54 -0
  116. package/dist/chunks/studio-vcetc91k.js +659 -0
  117. package/dist/chunks/studio-vcetc91k.js.map +10 -0
  118. package/dist/chunks/{studio-n9zmpxjg.js → studio-vmymjefp.js} +2972 -4937
  119. package/dist/chunks/studio-vmymjefp.js.map +98 -0
  120. package/dist/chunks/{studio-bghqfmea.js → studio-wwseqyw0.js} +2 -2
  121. package/dist/chunks/studio-x9hmy5kr.js +390 -0
  122. package/dist/chunks/studio-x9hmy5kr.js.map +10 -0
  123. package/dist/chunks/studio-xa0ce0n7.js +335 -0
  124. package/dist/chunks/studio-xa0ce0n7.js.map +10 -0
  125. package/dist/chunks/studio-xm3dbxvn.js +378 -0
  126. package/dist/chunks/studio-xm3dbxvn.js.map +19 -0
  127. package/dist/chunks/{studio-nqw433kv.js → studio-xmvbhg31.js} +2 -2
  128. package/dist/chunks/studio-zv37yrfa.js +1054 -0
  129. package/dist/chunks/studio-zv37yrfa.js.map +13 -0
  130. package/dist/chunks/{ws-client-q7hkz3j9.js → ws-client-654ts66j.js} +6 -4
  131. package/dist/chunks/ws-client-654ts66j.js.map +11 -0
  132. package/dist/iframe-entry.js +668 -230
  133. package/dist/studio.js +23711 -35185
  134. package/index.html +35 -4685
  135. package/package.json +17 -15
  136. package/src/about/about-modal.ts +42 -11
  137. package/src/browse/library-commands.ts +240 -0
  138. package/src/browse/library-layouts.ts +369 -0
  139. package/src/browse/library-model.ts +396 -0
  140. package/src/browse/library-pane.ts +1049 -0
  141. package/src/browse/library-preview.ts +252 -0
  142. package/src/browse/library-source.ts +150 -0
  143. package/src/canvas/canvas-helpers.ts +33 -9
  144. package/src/canvas/canvas-live-render.ts +45 -29
  145. package/src/canvas/canvas-patcher.ts +151 -31
  146. package/src/canvas/canvas-perf.ts +46 -7
  147. package/src/canvas/canvas-render.ts +1092 -220
  148. package/src/canvas/canvas-surface.ts +240 -0
  149. package/src/canvas/canvas-utils.ts +1166 -124
  150. package/src/canvas/iframe-drop.ts +2 -1
  151. package/src/canvas/iframe-entry.ts +241 -4
  152. package/src/canvas/iframe-host.ts +1226 -143
  153. package/src/canvas/iframe-inline-edit.ts +31 -0
  154. package/src/canvas/iframe-interaction.ts +76 -16
  155. package/src/canvas/iframe-keys.ts +107 -45
  156. package/src/canvas/iframe-overlay.ts +38 -1
  157. package/src/canvas/iframe-protocol.ts +137 -2
  158. package/src/canvas/iframe-render.ts +70 -2
  159. package/src/canvas/iframe-slash.ts +6 -0
  160. package/src/canvas/path-mapping.ts +56 -6
  161. package/src/canvas/surface-registry.ts +292 -0
  162. package/src/collab/collab-commands.ts +244 -0
  163. package/src/collab/collab-session.ts +198 -32
  164. package/src/collab/collab-state.ts +28 -2
  165. package/src/collab/presence-chips.ts +100 -9
  166. package/src/commands/active-registry.ts +42 -0
  167. package/src/commands/app-commands.ts +176 -0
  168. package/src/commands/budget.ts +158 -0
  169. package/src/commands/command-args.ts +253 -0
  170. package/src/commands/context.ts +387 -0
  171. package/src/commands/defaults.ts +580 -0
  172. package/src/commands/keymap.ts +571 -0
  173. package/src/commands/levels.ts +345 -0
  174. package/src/commands/live-context.ts +193 -0
  175. package/src/commands/reference.ts +227 -0
  176. package/src/commands/registry.ts +293 -0
  177. package/src/component-props.ts +3 -2
  178. package/src/content/draft-state.ts +106 -0
  179. package/src/content/entry-commands.ts +282 -0
  180. package/src/content/entry-editor.ts +327 -0
  181. package/src/content/entry-fields.ts +116 -0
  182. package/src/content/entry-model.ts +175 -0
  183. package/src/editor/canvas-context-menu.ts +17 -11
  184. package/src/editor/canvas-slash-bridge.ts +11 -4
  185. package/src/editor/context-menu.ts +804 -204
  186. package/src/editor/convert-to-component.ts +31 -10
  187. package/src/editor/convert-to-repeater.ts +3 -2
  188. package/src/editor/inline-edit-apply.ts +5 -5
  189. package/src/editor/inline-edit.ts +86 -50
  190. package/src/editor/insert-zone-action.ts +1 -1
  191. package/src/editor/shortcuts.ts +1043 -329
  192. package/src/editor/slash-menu.ts +1 -0
  193. package/src/files/elements.ts +166 -0
  194. package/src/files/file-ops.ts +178 -31
  195. package/src/files/files.ts +898 -268
  196. package/src/files/fs-events.ts +21 -0
  197. package/src/files/media-meta.ts +203 -0
  198. package/src/files/media-paths.ts +141 -0
  199. package/src/files/media-upload.ts +7 -3
  200. package/src/files/media-usage.ts +189 -0
  201. package/src/github/github-auth.ts +235 -33
  202. package/src/github/github-publish.ts +80 -21
  203. package/src/grid/grid-controller.ts +152 -12
  204. package/src/grid/grid-layout.ts +225 -20
  205. package/src/grid/grid-open.ts +120 -0
  206. package/src/grid/grid-panel.ts +554 -39
  207. package/src/grid/grid-source.ts +27 -1
  208. package/src/grid/redirects-grid.ts +599 -0
  209. package/src/grid/redirects.ts +398 -0
  210. package/src/grid/sources/content-source.ts +108 -2
  211. package/src/i18n/i18n-commands.ts +322 -0
  212. package/src/new-project/add-repo-modal.ts +8 -10
  213. package/src/new-project/import-tab.ts +34 -9
  214. package/src/new-project/location-fields.ts +9 -1
  215. package/src/new-project/new-project-modal.ts +270 -262
  216. package/src/packages/jxsuite-update.ts +97 -48
  217. package/src/packages/pull-package-sync.ts +18 -9
  218. package/src/page-params.ts +2 -1
  219. package/src/panels/activity-bar.ts +152 -91
  220. package/src/panels/activity-panel.ts +481 -0
  221. package/src/panels/ai-chat/chat-markdown.ts +8 -3
  222. package/src/panels/ai-chat/chat-view.ts +245 -18
  223. package/src/panels/ai-chat/composer.ts +31 -5
  224. package/src/panels/ai-chat/sessions-view.ts +17 -10
  225. package/src/panels/ai-panel.ts +279 -120
  226. package/src/panels/block-action-bar.ts +1115 -224
  227. package/src/panels/bottom-dock.ts +337 -0
  228. package/src/panels/canvas-dnd-bridge.ts +37 -8
  229. package/src/panels/chat-panel.ts +29 -12
  230. package/src/panels/data-explorer.ts +254 -112
  231. package/src/panels/data-grid.ts +10 -4
  232. package/src/panels/dnd.ts +28 -51
  233. package/src/panels/editors.ts +434 -107
  234. package/src/panels/elements-panel.ts +66 -2
  235. package/src/panels/empty-state.ts +112 -0
  236. package/src/panels/events-panel.ts +596 -82
  237. package/src/panels/formula-workspace.ts +408 -218
  238. package/src/panels/frontmatter-fields.ts +51 -10
  239. package/src/panels/frontmatter-panel.ts +357 -79
  240. package/src/panels/git-panel.ts +375 -164
  241. package/src/panels/head-panel.ts +791 -85
  242. package/src/panels/i18n-panel.ts +592 -0
  243. package/src/panels/imports-panel.ts +95 -85
  244. package/src/panels/jump-bar.ts +584 -0
  245. package/src/panels/layers-panel.ts +1106 -328
  246. package/src/panels/left-panel.ts +109 -204
  247. package/src/panels/navigator-panels.ts +131 -0
  248. package/src/panels/overlays.ts +7 -2
  249. package/src/panels/pane-context.ts +1477 -0
  250. package/src/panels/pane-grid.ts +406 -0
  251. package/src/panels/panel-registry.ts +340 -0
  252. package/src/panels/panel-scheduler.ts +75 -0
  253. package/src/panels/problems-panel.ts +234 -0
  254. package/src/panels/properties-panel.ts +946 -1139
  255. package/src/panels/provenance.ts +258 -0
  256. package/src/panels/quick-search.ts +791 -122
  257. package/src/panels/right-panel.ts +228 -102
  258. package/src/panels/seo-modal.ts +395 -0
  259. package/src/panels/settings-pane.ts +203 -0
  260. package/src/panels/signals-panel.ts +354 -137
  261. package/src/panels/statement-editor.ts +41 -25
  262. package/src/panels/statusbar.ts +342 -106
  263. package/src/panels/style-panel.ts +974 -463
  264. package/src/panels/style-utils.ts +3 -3
  265. package/src/panels/stylebook-doc.ts +4 -2
  266. package/src/panels/stylebook-layers-panel.ts +3 -2
  267. package/src/panels/stylebook-panel.ts +51 -26
  268. package/src/panels/tab-strip.ts +1058 -78
  269. package/src/panels/target-line.ts +274 -0
  270. package/src/panels/toolbar.ts +631 -484
  271. package/src/panels/welcome-screen.ts +301 -198
  272. package/src/platform-errors.ts +26 -3
  273. package/src/platform.ts +96 -2
  274. package/src/platforms/cloud.ts +71 -8
  275. package/src/platforms/devserver.ts +92 -29
  276. package/src/publish/deploy-checklist.ts +383 -0
  277. package/src/publish/pages-service.ts +10 -1
  278. package/src/publish/publish-commands.ts +218 -0
  279. package/src/publish/publish-panel.ts +110 -16
  280. package/src/recent-projects.ts +3 -2
  281. package/src/services/a11y-report.ts +505 -0
  282. package/src/services/ai-models.ts +75 -2
  283. package/src/services/ai-project-tools.ts +29 -10
  284. package/src/services/ai-system-prompt.ts +53 -14
  285. package/src/services/ai-tools.ts +39 -3
  286. package/src/services/ai-writes.ts +154 -0
  287. package/src/services/announce.ts +97 -0
  288. package/src/services/automation.ts +424 -175
  289. package/src/services/cem-export.ts +9 -4
  290. package/src/services/clock.ts +61 -0
  291. package/src/services/document-assistant.ts +36 -8
  292. package/src/services/idle.ts +179 -0
  293. package/src/services/import-client.ts +26 -0
  294. package/src/services/monaco-buffer.ts +627 -0
  295. package/src/services/monaco-lazy.ts +36 -0
  296. package/src/services/notify.ts +271 -0
  297. package/src/services/profile.ts +155 -0
  298. package/src/services/references.ts +0 -0
  299. package/src/services/token-lint.ts +3 -2
  300. package/src/services/tool-executor.ts +53 -7
  301. package/src/services/trusted-types.ts +123 -0
  302. package/src/settings/contexts-section.ts +427 -0
  303. package/src/settings/contributed-section.ts +149 -11
  304. package/src/settings/css-vars-editor.ts +306 -349
  305. package/src/settings/defs-editor.ts +93 -34
  306. package/src/settings/dependencies-editor.ts +7 -7
  307. package/src/settings/extension-sections.ts +37 -5
  308. package/src/settings/general-settings.ts +204 -121
  309. package/src/settings/locales-section.ts +347 -0
  310. package/src/settings/preferences-accounts.ts +140 -0
  311. package/src/settings/preferences-dialog.ts +620 -0
  312. package/src/settings/preferences-keymap.ts +270 -0
  313. package/src/settings/project-sections.ts +260 -0
  314. package/src/settings/section-registry.ts +169 -0
  315. package/src/settings/settings-document.ts +385 -0
  316. package/src/shell.ts +1549 -0
  317. package/src/site-context.ts +45 -20
  318. package/src/state.ts +14 -275
  319. package/src/store.ts +88 -43
  320. package/src/studio.ts +790 -346
  321. package/src/style/live-preview.ts +36 -0
  322. package/src/style/project-styles.ts +349 -0
  323. package/src/style/token-ref.ts +123 -0
  324. package/src/tabs/patch-ops.ts +16 -4
  325. package/src/tabs/project-config.ts +416 -0
  326. package/src/tabs/selection.ts +323 -0
  327. package/src/tabs/tab.ts +182 -47
  328. package/src/tabs/transact.ts +209 -28
  329. package/src/types.ts +68 -13
  330. package/src/ui/ai-credentials-form.ts +39 -35
  331. package/src/ui/ai-managed-connect.ts +113 -0
  332. package/src/ui/dynamic-slot.ts +224 -81
  333. package/src/ui/expression-editor.ts +71 -63
  334. package/src/ui/field-input.ts +4 -6
  335. package/src/ui/field-row.ts +215 -38
  336. package/src/ui/form-controls.ts +194 -7
  337. package/src/ui/layers.ts +492 -69
  338. package/src/ui/media-picker.ts +115 -25
  339. package/src/ui/panel-resize.ts +109 -91
  340. package/src/ui/progress-modal.ts +131 -50
  341. package/src/ui/regions.ts +462 -0
  342. package/src/ui/schema-form.ts +229 -45
  343. package/src/ui/spectrum.ts +24 -8
  344. package/src/ui/timing.ts +49 -0
  345. package/src/ui/value-selector.ts +20 -0
  346. package/src/ui/value-source.ts +348 -0
  347. package/src/ui/virtual-window.ts +400 -0
  348. package/src/utils/edit-display.ts +3 -2
  349. package/src/utils/inherited-style.ts +67 -37
  350. package/src/view.ts +64 -78
  351. package/src/workspace/pane-derive.ts +1258 -0
  352. package/src/workspace/session.ts +317 -0
  353. package/src/workspace/workspace.ts +1345 -23
  354. package/styles/canvas.css +653 -0
  355. package/styles/forced-colors.css +106 -0
  356. package/styles/inspector.css +1454 -0
  357. package/styles/overlays.css +2704 -0
  358. package/styles/panels.css +1701 -0
  359. package/styles/shell.css +1423 -0
  360. package/styles/tokens.css +151 -0
  361. package/dist/chunks/Overlay.dev-cszx7yn5.js +0 -22
  362. package/dist/chunks/extension-sections-qct0gt8f.js +0 -531
  363. package/dist/chunks/extension-sections-qct0gt8f.js.map +0 -12
  364. package/dist/chunks/files-02zn5y24.js +0 -50
  365. package/dist/chunks/files-02zn5y24.js.map +0 -9
  366. package/dist/chunks/git-panel-mbb0y0hz.js +0 -28
  367. package/dist/chunks/git-panel-mbb0y0hz.js.map +0 -9
  368. package/dist/chunks/layers-panel-9qekmqk1.js +0 -26
  369. package/dist/chunks/layers-panel-9qekmqk1.js.map +0 -9
  370. package/dist/chunks/settings-modal-gj0hrah4.js +0 -29
  371. package/dist/chunks/settings-modal-gj0hrah4.js.map +0 -9
  372. package/dist/chunks/sp-overlay.dev-earn5as8.js +0 -17
  373. package/dist/chunks/sp-overlay.dev-earn5as8.js.map +0 -9
  374. package/dist/chunks/sp-popover.dev-frxtfejn.js +0 -8
  375. package/dist/chunks/sp-popover.dev-frxtfejn.js.map +0 -9
  376. package/dist/chunks/sp-progress-circle.dev-7tvw34h8.js +0 -14
  377. package/dist/chunks/sp-progress-circle.dev-7tvw34h8.js.map +0 -9
  378. package/dist/chunks/sp-tooltip.dev-a9fywesm.js +0 -21
  379. package/dist/chunks/sp-tooltip.dev-a9fywesm.js.map +0 -10
  380. package/dist/chunks/studio-4k0m4fmp.js.map +0 -13
  381. package/dist/chunks/studio-568nx4t5.js +0 -469
  382. package/dist/chunks/studio-568nx4t5.js.map +0 -14
  383. package/dist/chunks/studio-9smfzqhc.js +0 -19
  384. package/dist/chunks/studio-9smfzqhc.js.map +0 -10
  385. package/dist/chunks/studio-asvf0j9e.js +0 -429
  386. package/dist/chunks/studio-asvf0j9e.js.map +0 -13
  387. package/dist/chunks/studio-d77qm3pk.js +0 -3427
  388. package/dist/chunks/studio-d77qm3pk.js.map +0 -26
  389. package/dist/chunks/studio-j6gqyjf4.js +0 -1854
  390. package/dist/chunks/studio-j6gqyjf4.js.map +0 -18
  391. package/dist/chunks/studio-n9zmpxjg.js.map +0 -115
  392. package/dist/chunks/studio-qkvt37c0.js.map +0 -9
  393. package/dist/chunks/studio-rq0vxb19.js +0 -3744
  394. package/dist/chunks/studio-rq0vxb19.js.map +0 -22
  395. package/dist/chunks/studio-wd6ewdvd.js +0 -853
  396. package/dist/chunks/studio-wd6ewdvd.js.map +0 -12
  397. package/dist/chunks/studio-xq0t7n5t.js +0 -140
  398. package/dist/chunks/studio-xq0t7n5t.js.map +0 -10
  399. package/dist/chunks/studio-xze4zrs1.js.map +0 -10
  400. package/dist/chunks/studio-ygzav7z5.js.map +0 -46
  401. package/dist/chunks/studio-zmy7241e.js +0 -399
  402. package/dist/chunks/studio-zmy7241e.js.map +0 -12
  403. package/dist/chunks/ws-client-q7hkz3j9.js.map +0 -11
  404. package/src/browse/browse-modal.ts +0 -69
  405. package/src/browse/browse.ts +0 -904
  406. package/src/canvas/canvas-diff.ts +0 -216
  407. package/src/canvas/nested-site-style.ts +0 -58
  408. package/src/new-project/design-fields.ts +0 -264
  409. package/src/new-project/templates.ts +0 -61
  410. package/src/panels/tab-bar.ts +0 -548
  411. package/src/settings/settings-modal.ts +0 -203
  412. /package/dist/chunks/{adapter-z8fkmbmv.js.map → adapter-qfnesn5v.js.map} +0 -0
  413. /package/dist/chunks/{cf-settings-mmyv436d.js.map → cf-settings-73sh5z4p.js.map} +0 -0
  414. /package/dist/chunks/{format-host-2c188n41.js.map → format-host-mkwsmt8d.js.map} +0 -0
  415. /package/dist/chunks/{jx-validate-qx8degj7.js.map → jx-validate-v6qgknrs.js.map} +0 -0
  416. /package/dist/chunks/{studio-w7c6kvk7.js.map → studio-a7pzveq1.js.map} +0 -0
  417. /package/dist/chunks/{studio-hhj92gcr.js.map → studio-s519zc99.js.map} +0 -0
  418. /package/dist/chunks/{studio-bghqfmea.js.map → studio-wwseqyw0.js.map} +0 -0
  419. /package/dist/chunks/{studio-nqw433kv.js.map → studio-xmvbhg31.js.map} +0 -0
@@ -11,14 +11,21 @@ import { renderFieldRow } from "../ui/field-row";
11
11
  import { spTextArea, spTextField } from "../ui/field-input";
12
12
  import { renderMediaPicker } from "../ui/media-picker";
13
13
  import { projectState, renderOnly } from "../store";
14
- import type { DirEntry } from "../types";
14
+ import type { DirEntry, JsonValue } from "../types";
15
15
  import { activeTab } from "../workspace/workspace";
16
+ import { activeRegistry } from "../commands/active-registry";
17
+ import { clearProblems, notify } from "../services/notify";
18
+ import { renderEmptyState } from "./empty-state";
19
+ import { registerPanel } from "./panel-registry";
20
+ import { mutateUpdateFrontmatter, transact } from "../tabs/transact";
16
21
  import { collectFmFields, renderFmField } from "./frontmatter-fields";
17
22
  import { isGoogleFontEntry, isGoogleFontPreconnect } from "../utils/google-fonts";
18
- import { invalidateLayoutCache } from "../site-context";
23
+ import { getEffectiveLayoutPath, invalidateLayoutCache, resolveLayoutDoc } from "../site-context";
19
24
  import { getPlatform } from "../platform";
25
+ import { pageRoute } from "./tab-strip";
20
26
 
21
27
  import type { JxHeadEntry, JxMutableNode } from "@jxsuite/schema/types";
28
+ import type { Tab } from "../tabs/tab";
22
29
  import type { TemplateResult } from "lit-html";
23
30
 
24
31
  interface MetaField {
@@ -51,20 +58,27 @@ async function loadLayoutEntries() {
51
58
  layoutEntries = [];
52
59
  }
53
60
  renderOnly("leftPanel");
61
+ renderOnly("frontmatterPanel", "seoModal");
54
62
  }
55
63
 
64
+ /**
65
+ * Forget every cached fact about layouts this module holds — the picker's listing AND the effective
66
+ * layout's `$head`, because "the layouts changed" is one event and answering it in two halves is
67
+ * how a preview ends up attributing a deleted layout's description to the open page.
68
+ */
56
69
  export function invalidateLayoutPickerCache() {
57
70
  layoutEntries = null;
71
+ invalidateLayoutHeadCache();
58
72
  }
59
73
 
60
74
  // ─── Field definitions ───────────────────────────────────────────────────
61
75
 
62
- const PAGE_FIELDS: MetaField[] = [
76
+ export const PAGE_FIELDS: MetaField[] = [
63
77
  { attr: "name", key: "description", label: "Description" },
64
78
  { attr: "name", key: "viewport", label: "Viewport" },
65
79
  ];
66
80
 
67
- const OG_FIELDS: MetaField[] = [
81
+ export const OG_FIELDS: MetaField[] = [
68
82
  { attr: "property", key: "og:title", label: "Title" },
69
83
  {
70
84
  attr: "property",
@@ -79,8 +93,17 @@ const OG_FIELDS: MetaField[] = [
79
93
  /** Set of `name`/`property` values managed by the structured forms. */
80
94
  const MANAGED_META_KEYS = new Set([...PAGE_FIELDS, ...OG_FIELDS].map((f) => f.key));
81
95
 
82
- /** Frontmatter keys managed by the PAGE dedicated controls (others live inside $head). */
83
- const RESERVED_FM_KEYS = new Set(["title"]);
96
+ /**
97
+ * Frontmatter keys the Document Header card owns with a dedicated control, so the generic field
98
+ * list must not print them a second time.
99
+ *
100
+ * **The one policy.** `frontmatter-panel.ts` used to pass an EMPTY reserved set while this module
101
+ * passed `{title}`, so the same key rendered as a bare Obsidian-style property above the canvas and
102
+ * as the Page panel's Title field at the same time, with two different commit paths. Merging the
103
+ * two field sets was only safe once one of the two policies won, and it is this one: a document has
104
+ * ONE title, and the surface that gives it a named row is the one that owns the key.
105
+ */
106
+ export const RESERVED_FM_KEYS = new Set(["title"]);
84
107
 
85
108
  // ─── Helpers ─────────────────────────────────────────────────────────────
86
109
 
@@ -106,7 +129,7 @@ function findMetaEntry(head: JxHeadEntry[], attr: "name" | "property", key: stri
106
129
  * @param {string} rel
107
130
  * @returns {JxHeadEntry | undefined}
108
131
  */
109
- function findLinkEntry(head: JxHeadEntry[], rel: string) {
132
+ export function findLinkEntry(head: JxHeadEntry[], rel: string) {
110
133
  if (!head) {
111
134
  return;
112
135
  }
@@ -119,7 +142,7 @@ function findLinkEntry(head: JxHeadEntry[], rel: string) {
119
142
  * @param {JxHeadEntry} entry
120
143
  * @returns {boolean}
121
144
  */
122
- function isManagedEntry(entry: JxHeadEntry) {
145
+ export function isManagedEntry(entry: JxHeadEntry) {
123
146
  if (!entry?.tagName) {
124
147
  return false;
125
148
  }
@@ -172,7 +195,7 @@ function upsertMeta(doc: JxMutableNode, attr: "name" | "property", key: string,
172
195
  * @param {string} rel
173
196
  * @param {string} href
174
197
  */
175
- function upsertLink(doc: JxMutableNode, rel: string, href: string) {
198
+ export function upsertLink(doc: JxMutableNode, rel: string, href: string) {
176
199
  if (!doc.$head) {
177
200
  doc.$head = [];
178
201
  }
@@ -197,7 +220,7 @@ function upsertLink(doc: JxMutableNode, rel: string, href: string) {
197
220
  * @param {JxHeadEntry} entry
198
221
  * @returns {string}
199
222
  */
200
- function entryLabel(entry: JxHeadEntry) {
223
+ export function entryLabel(entry: JxHeadEntry) {
201
224
  if (!entry?.tagName) {
202
225
  return "unknown";
203
226
  }
@@ -226,11 +249,528 @@ function entryLabel(entry: JxHeadEntry) {
226
249
  * @param {JxHeadEntry} entry
227
250
  * @returns {string}
228
251
  */
229
- function entryValue(entry: JxHeadEntry) {
252
+ export function entryValue(entry: JxHeadEntry) {
230
253
  const a = entry?.attributes ?? {};
231
254
  return String(a.content ?? a.href ?? a.src ?? entry?.textContent ?? "");
232
255
  }
233
256
 
257
+ // ─── The merged `$head`, as a preview model ──────────────────────────────
258
+
259
+ /*
260
+ * Everything below answers ONE question — what actually reaches the browser? — and it is a
261
+ * different question from what this panel's fields edit. A page that inherits its description from
262
+ * the site is not a page missing a description, and the old SEO gap was that no surface could tell
263
+ * the two apart. The contract mirrored here is `packages/compiler/src/site/head-merger.ts`:
264
+ * site → layout → page, later wins, keyed by `name` / `property` / `rel`. Studio does not depend
265
+ * on `@jxsuite/compiler`, so the merge is restated for the handful of keys the previews read
266
+ * rather than copied wholesale; `tests/head-panel.test.ts` pins each rule against that file.
267
+ */
268
+
269
+ /**
270
+ * Which layer of the cascade supplied a previewed value.
271
+ *
272
+ * `build` is the compiler's own contribution — the `"Jx Site"` title fallback, the canonical link
273
+ * and `og:url` it derives from the site URL and the route. It is a donor like any other, and saying
274
+ * so is what stops the preview from claiming the author wrote something they did not.
275
+ */
276
+ export type HeadSource = "page" | "layout" | "site" | "build" | "none";
277
+
278
+ /** One value that reaches the browser, and where it came from. */
279
+ export interface ResolvedHeadField {
280
+ /** The emitted value, or `""` when the merged head carries none. */
281
+ value: string;
282
+ source: HeadSource;
283
+ /**
284
+ * The donor's name in the words a reader would use — `"Base"`, `"Site head"`, `"Site name"`,
285
+ * `"the build"`. `null` for `page` (nothing to name; the author is looking at it) and `none`.
286
+ */
287
+ donor: string | null;
288
+ }
289
+
290
+ /** The three `$head` arrays the build merges, plus the name of the layout supplying the middle one. */
291
+ export interface HeadLayers {
292
+ site: JxHeadEntry[];
293
+ layout: JxHeadEntry[];
294
+ page: JxHeadEntry[];
295
+ layoutName: string | null;
296
+ }
297
+
298
+ /** The title the build emits when neither the page nor the site names one (`head-merger.ts`). */
299
+ export const BUILD_FALLBACK_TITLE = "Jx Site";
300
+
301
+ const NOT_SUPPLIED: ResolvedHeadField = { donor: null, source: "none", value: "" };
302
+
303
+ /**
304
+ * The `content` of the LAST matching meta entry in one layer, or `null` when the layer is silent.
305
+ *
306
+ * Last rather than first, because the merger folds a layer into a keyed map in array order: a layer
307
+ * that lists `description` twice emits the second one.
308
+ */
309
+ function metaContentIn(head: JxHeadEntry[], attr: "name" | "property", key: string): string | null {
310
+ let found: string | null = null;
311
+ for (const entry of head) {
312
+ if (entry?.tagName === "meta" && entry?.attributes?.[attr] === key) {
313
+ found = String(entry.attributes?.content ?? "");
314
+ }
315
+ }
316
+ return found;
317
+ }
318
+
319
+ /**
320
+ * Resolve one meta key through site → layout → page.
321
+ *
322
+ * A page entry whose `content` is empty still counts as the page speaking: the merged map is keyed
323
+ * by `meta:<key>`, so an empty page entry SHADOWS the site's. The preview reports the empty result,
324
+ * which is what the browser gets.
325
+ *
326
+ * @param {HeadLayers} layers
327
+ * @param {"name" | "property"} attr
328
+ * @param {string} key
329
+ * @returns {ResolvedHeadField}
330
+ */
331
+ export function resolveMetaField(
332
+ layers: HeadLayers,
333
+ attr: "name" | "property",
334
+ key: string,
335
+ ): ResolvedHeadField {
336
+ const page = metaContentIn(layers.page, attr, key);
337
+ if (page !== null) {
338
+ return { donor: null, source: "page", value: page };
339
+ }
340
+ const layout = metaContentIn(layers.layout, attr, key);
341
+ if (layout !== null) {
342
+ return { donor: layers.layoutName ?? "the layout", source: "layout", value: layout };
343
+ }
344
+ const site = metaContentIn(layers.site, attr, key);
345
+ if (site !== null) {
346
+ return { donor: "Site head", source: "site", value: site };
347
+ }
348
+ return NOT_SUPPLIED;
349
+ }
350
+
351
+ /**
352
+ * Resolve `<title>`: the page's `title` property, then the site's `name`, then `"Jx Site"`.
353
+ *
354
+ * A layout never supplies one. `site/site-build.ts` reads `pageDoc.title ?? layoutDoc._pageTitle`,
355
+ * and `_pageTitle` is a carrier for the PAGE's title through layout distribution, not a layout
356
+ * value of its own. A `<title>` entry inside any `$head` does not supply one either — the merger
357
+ * overwrites the `title` key from its context after the layers are folded in, which is what
358
+ * {@link seoWarnings}' `head-title-ignored` exists to say out loud.
359
+ *
360
+ * @param {string} pageTitle
361
+ * @param {string} [siteName]
362
+ * @returns {ResolvedHeadField}
363
+ */
364
+ export function resolveTitleField(pageTitle: string, siteName?: string): ResolvedHeadField {
365
+ const own = pageTitle.trim();
366
+ if (own) {
367
+ return { donor: null, source: "page", value: own };
368
+ }
369
+ const site = siteName?.trim();
370
+ if (site) {
371
+ return { donor: "Site name", source: "site", value: site };
372
+ }
373
+ return { donor: "the build", source: "build", value: BUILD_FALLBACK_TITLE };
374
+ }
375
+
376
+ /** The URL a result row prints, and the canonical the build derives — or the honest absence of one. */
377
+ export interface SeoUrl {
378
+ /** `<link rel="canonical">` / `og:url`, or `null` when Project Settings names no site URL. */
379
+ href: string | null;
380
+ /** `example.com › blog › hello` — the breadcrumb a search result prints. */
381
+ crumb: string;
382
+ /** The bare host a social card prints, or `""` when unknown. */
383
+ host: string;
384
+ }
385
+
386
+ /**
387
+ * The canonical URL and its two printed forms.
388
+ *
389
+ * `head-merger.ts` emits a canonical link and `og:url` only when BOTH a site URL and a route exist,
390
+ * so this returns `href: null` in every case where the build would emit neither.
391
+ *
392
+ * @param {string | null} route
393
+ * @param {string} [siteUrl]
394
+ * @returns {SeoUrl}
395
+ */
396
+ export function resolveSeoUrl(route: string | null, siteUrl?: string): SeoUrl {
397
+ const path = route ?? "";
398
+ const segments = path.split("/").filter((s) => s !== "");
399
+ if (!siteUrl || route === null) {
400
+ return { crumb: path || "/", host: "", href: null };
401
+ }
402
+ let absolute: URL;
403
+ try {
404
+ absolute = new URL(path, siteUrl);
405
+ } catch {
406
+ // A malformed `url` in project.json is a settings problem, not a preview crash — fall back to
407
+ // The route, exactly as a build with no site URL would.
408
+ return { crumb: path || "/", host: "", href: null };
409
+ }
410
+ const { host } = absolute;
411
+ return {
412
+ crumb: [host, ...segments].join(" › "),
413
+ host,
414
+ href: absolute.href,
415
+ };
416
+ }
417
+
418
+ /** One row of the resolved-field list: the value, its provenance, and its character budget. */
419
+ export interface SeoField extends ResolvedHeadField {
420
+ /** The head key — `title`, `description`, `og:image`, … */
421
+ key: string;
422
+ label: string;
423
+ /** The width at which the surface that shows it truncates, or `null` when nobody counts it. */
424
+ limit: number | null;
425
+ }
426
+
427
+ /**
428
+ * Character budgets, in the only role a number is allowed here: a counter's reference line.
429
+ *
430
+ * There is deliberately no score. A single figure out of a hundred aggregates unrelated facts into
431
+ * a verdict, and the verdict is what gets optimised (plan §14). A count beside a limit says the
432
+ * same thing without pretending to rank it.
433
+ */
434
+ export const SEO_LIMITS: Readonly<Record<string, number>> = {
435
+ description: 160,
436
+ "og:description": 200,
437
+ "og:title": 60,
438
+ title: 60,
439
+ };
440
+
441
+ /** Counts what a reader sees as one character. See {@link visibleLength}. */
442
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter("en-US", { granularity: "grapheme" });
443
+
444
+ /**
445
+ * How long a value looks, in the units the person reading the counter has in mind.
446
+ *
447
+ * `String.length` counts UTF-16 code units, which is not a count of anything a person can see: an
448
+ * emoji is 2, a flag is 4, and "👩‍🚀" is 5 — so a title with three emoji reported eleven
449
+ * characters of budget spent on three glyphs, and the warning beside it was wrong by the same
450
+ * margin. Counting **grapheme clusters** (UAX #29) counts what renders.
451
+ *
452
+ * **What this deliberately does not do is model pixel width.** "iiiii" and "WWWWW" are both five,
453
+ * and a search result truncates on width — but width depends on the font the search engine chooses,
454
+ * at a size it chooses, and any table of per-character widths here would be a fabricated number
455
+ * presented as a measurement. A character count next to a documented limit is an honest
456
+ * approximation; a pixel count would not be.
457
+ *
458
+ * @param {string} value
459
+ * @returns {number}
460
+ */
461
+ export function visibleLength(value: string): number {
462
+ let count = 0;
463
+ for (const _ of GRAPHEME_SEGMENTER.segment(value)) {
464
+ count += 1;
465
+ }
466
+ return count;
467
+ }
468
+
469
+ /** A named thing that is wrong, or absent. Never summed — the list IS the report. */
470
+ export interface SeoWarning {
471
+ /** Stable id, so a test and a shot name the same warning. */
472
+ id: string;
473
+ /** The head key the warning is about, so the field row can carry it. */
474
+ field: string;
475
+ message: string;
476
+ }
477
+
478
+ /** The Problems `source` the SEO warnings carry, so a re-run replaces rather than stacks. */
479
+ export const SEO_PROBLEM_SOURCE = "Search appearance";
480
+
481
+ /**
482
+ * File the SEO warnings as Problems as well as rendering them in the modal.
483
+ *
484
+ * The modal is a window someone has to open. A page shipped with no description is a fact worth
485
+ * knowing whether or not you thought to look, and Problems is where this app already keeps facts
486
+ * that outlive the frame you were not watching — which is the same argument the accessibility
487
+ * report makes, and the redirects checks before it.
488
+ *
489
+ * Cleared first, so a fixed page stops being listed; keyed by warning id, so the two surfaces name
490
+ * the same thing.
491
+ *
492
+ * @param {SeoPreview} preview
493
+ * @param {string} [path] The document's path, so a row can open the file it is about.
494
+ * @returns {number} How many Problems were filed.
495
+ */
496
+ export function reportSeoProblems(preview: SeoPreview, path?: string): number {
497
+ clearProblems((record) => record.source === SEO_PROBLEM_SOURCE);
498
+ for (const warning of preview.warnings) {
499
+ notify.warn(warning.message, {
500
+ action: "document.openSeo",
501
+ key: `seo.${warning.id}`,
502
+ source: SEO_PROBLEM_SOURCE,
503
+ tier: "problem",
504
+ ...(path === undefined ? {} : { path }),
505
+ });
506
+ }
507
+ return preview.warnings.length;
508
+ }
509
+
510
+ /** Everything the two preview cards, the field list and the warning list render. */
511
+ export interface SeoPreview {
512
+ url: SeoUrl;
513
+ /** In render order: `title`, `description`, then the four OpenGraph keys. */
514
+ fields: SeoField[];
515
+ warnings: SeoWarning[];
516
+ }
517
+
518
+ /** Look one field up by key — the previews read four of the six by name. */
519
+ export function seoField(preview: SeoPreview, key: string): SeoField {
520
+ return (
521
+ preview.fields.find((f) => f.key === key) ?? {
522
+ ...NOT_SUPPLIED,
523
+ key,
524
+ label: key,
525
+ limit: SEO_LIMITS[key] ?? null,
526
+ }
527
+ );
528
+ }
529
+
530
+ /** Whether any layer declares a `<title>` element, which the merger discards. */
531
+ function hasTitleEntry(layers: HeadLayers): boolean {
532
+ return [...layers.site, ...layers.layout, ...layers.page].some((e) => e?.tagName === "title");
533
+ }
534
+
535
+ /**
536
+ * The named warnings, in the order they are rendered.
537
+ *
538
+ * Every one of them states a consequence rather than a grade, and every one is checkable against
539
+ * `head-merger.ts`. "Missing" is decided on the MERGED value, so a page that inherits a description
540
+ * from the site is never told it has none — the defect §9.2 names by name.
541
+ *
542
+ * @param {SeoPreview} preview — `warnings` is ignored; this computes it.
543
+ * @param {HeadLayers} layers
544
+ * @returns {SeoWarning[]}
545
+ */
546
+ export function seoWarnings(
547
+ preview: Omit<SeoPreview, "warnings">,
548
+ layers: HeadLayers,
549
+ ): SeoWarning[] {
550
+ const warnings: SeoWarning[] = [];
551
+ const get = (key: string) => preview.fields.find((f) => f.key === key);
552
+
553
+ const title = get("title");
554
+ if (title?.source === "build") {
555
+ warnings.push({
556
+ field: "title",
557
+ id: "title-missing",
558
+ message: `Neither this page nor the project names a title, so the build ships “${BUILD_FALLBACK_TITLE}”.`,
559
+ });
560
+ }
561
+ if (get("description")?.value.trim() === "") {
562
+ warnings.push({
563
+ field: "description",
564
+ id: "description-missing",
565
+ message:
566
+ "No description reaches this page from the site, its layout or the page itself — a " +
567
+ "result row shows whatever text the engine picks instead.",
568
+ });
569
+ }
570
+ if (get("og:title")?.value.trim() === "") {
571
+ warnings.push({
572
+ field: "og:title",
573
+ id: "og-title-missing",
574
+ message: "No og:title — a shared link carries no headline of its own.",
575
+ });
576
+ }
577
+ if (get("og:description")?.value.trim() === "") {
578
+ warnings.push({
579
+ field: "og:description",
580
+ id: "og-description-missing",
581
+ message: "No og:description — a shared link carries no summary of its own.",
582
+ });
583
+ }
584
+ if (get("og:image")?.value.trim() === "") {
585
+ warnings.push({
586
+ field: "og:image",
587
+ id: "og-image-missing",
588
+ message: "No og:image — a shared link renders as a text-only card.",
589
+ });
590
+ }
591
+ for (const field of preview.fields) {
592
+ const length = visibleLength(field.value);
593
+ if (field.limit !== null && length > field.limit) {
594
+ const kind = field.key.endsWith("description") ? "summaries" : "headlines";
595
+ warnings.push({
596
+ field: field.key,
597
+ id: `${field.key}-long`,
598
+ message: `${field.label} is ${length} characters; ${kind} are cut near ${field.limit}.`,
599
+ });
600
+ }
601
+ }
602
+ if (preview.url.href === null) {
603
+ warnings.push({
604
+ field: "url",
605
+ id: "site-url-missing",
606
+ message:
607
+ "Project Settings names no site URL, so the build emits no canonical link and no og:url.",
608
+ });
609
+ }
610
+ if (hasTitleEntry(layers)) {
611
+ warnings.push({
612
+ field: "title",
613
+ id: "head-title-ignored",
614
+ message:
615
+ "A <title> element in $head is discarded — the build writes the title from the " +
616
+ "document's own title property.",
617
+ });
618
+ }
619
+ return warnings;
620
+ }
621
+
622
+ /**
623
+ * Build the whole preview from the three layers, the page title, the route and the site config.
624
+ *
625
+ * Pure, and the reason the previews are testable without a stage: everything asynchronous
626
+ * (resolving the layout document) happens in {@link layoutHeadEntries} before this is called.
627
+ *
628
+ * @param {HeadLayers} layers
629
+ * @param {{ pageTitle: string; route: string | null; siteName?: string; siteUrl?: string }} ctx
630
+ * @returns {SeoPreview}
631
+ */
632
+ export function buildSeoPreview(
633
+ layers: HeadLayers,
634
+ ctx: { pageTitle: string; route: string | null; siteName?: string; siteUrl?: string },
635
+ ): SeoPreview {
636
+ const field = (key: string, label: string, resolved: ResolvedHeadField): SeoField => ({
637
+ ...resolved,
638
+ key,
639
+ label,
640
+ limit: SEO_LIMITS[key] ?? null,
641
+ });
642
+
643
+ const fields: SeoField[] = [
644
+ field("title", "Title", resolveTitleField(ctx.pageTitle, ctx.siteName)),
645
+ field("description", "Description", resolveMetaField(layers, "name", "description")),
646
+ field("og:title", "Social title", resolveMetaField(layers, "property", "og:title")),
647
+ field(
648
+ "og:description",
649
+ "Social description",
650
+ resolveMetaField(layers, "property", "og:description"),
651
+ ),
652
+ field("og:image", "Social image", resolveMetaField(layers, "property", "og:image")),
653
+ field("og:type", "Social type", resolveMetaField(layers, "property", "og:type")),
654
+ ];
655
+ const url = resolveSeoUrl(ctx.route, ctx.siteUrl);
656
+ const withoutWarnings = { fields, url };
657
+ return { ...withoutWarnings, warnings: seoWarnings(withoutWarnings, layers) };
658
+ }
659
+
660
+ // ─── The layout's `$head` ────────────────────────────────────────────────
661
+
662
+ /*
663
+ * The middle layer is the only one the studio does not already hold in memory: it lives in a file.
664
+ * It is fetched once per layout path and cached here, and the fetch rides `platform.readFile`, so
665
+ * `probe.idle()`'s platform source already accounts for it — no new in-flight declaration.
666
+ */
667
+
668
+ let _layoutHeadPath: string | null = null;
669
+ let _layoutHead: JxHeadEntry[] = [];
670
+ let _layoutHeadPending: string | null = null;
671
+
672
+ /** Drop the cached layout head, so the next preview re-reads the file. */
673
+ export function invalidateLayoutHeadCache(): void {
674
+ _layoutHeadPath = null;
675
+ _layoutHead = [];
676
+ _layoutHeadPending = null;
677
+ }
678
+
679
+ async function loadLayoutHead(path: string): Promise<void> {
680
+ if (_layoutHeadPending === path) {
681
+ return;
682
+ }
683
+ _layoutHeadPending = path;
684
+ const doc = await resolveLayoutDoc(path);
685
+ if (_layoutHeadPending !== path) {
686
+ // A different layout was asked for while this one was in flight; that request owns the cache.
687
+ return;
688
+ }
689
+ _layoutHeadPending = null;
690
+ _layoutHeadPath = path;
691
+ _layoutHead = doc?.$head ?? [];
692
+ // THREE surfaces show the merged head now, and the layout layer arrives asynchronously: the
693
+ // Document Header card, and Search appearance, which is where the previews went. A modal left
694
+ // Off this list renders the page's own head with the layout layer missing — which reads as
695
+ // "you have no description" on a page that inherits one.
696
+ renderOnly("frontmatterPanel", "seoModal");
697
+ }
698
+
699
+ /**
700
+ * A layout path as a reader would name it — `./layouts/blog-post.json` → `Blog Post`.
701
+ *
702
+ * @param {string} path
703
+ * @returns {string}
704
+ */
705
+ export function layoutDisplayName(path: string): string {
706
+ return (
707
+ path
708
+ .replace(/^\.\//, "")
709
+ .replace(/^layouts\//, "")
710
+ .replace(/\.json$/, "")
711
+ .replaceAll(/[-_/]+/g, " ")
712
+ .replaceAll(/\b\w/g, (c) => c.toUpperCase()) || path
713
+ );
714
+ }
715
+
716
+ /**
717
+ * The effective layout's `$head`, and the name to print for it.
718
+ *
719
+ * Returns empty on the first call for a layout and schedules the read; the card repaints when it
720
+ * lands. Showing nothing briefly is the honest state — the alternative is attributing the layout's
721
+ * entries to the page.
722
+ *
723
+ * @param {Tab | null} tab — the tab whose document the entries are being resolved FOR.
724
+ * @param {string | false} [docLayout]
725
+ * @returns {{ entries: JxHeadEntry[]; name: string | null }}
726
+ */
727
+ export function layoutHeadEntries(
728
+ tab: Tab | null,
729
+ docLayout?: string | false,
730
+ ): {
731
+ entries: JxHeadEntry[];
732
+ name: string | null;
733
+ } {
734
+ const path = isPageDocument(tab) ? getEffectiveLayoutPath(docLayout) : null;
735
+ if (path === null) {
736
+ return { entries: [], name: null };
737
+ }
738
+ const name = layoutDisplayName(path);
739
+ if (path === _layoutHeadPath) {
740
+ return { entries: _layoutHead, name };
741
+ }
742
+ void loadLayoutHead(path);
743
+ return { entries: [], name };
744
+ }
745
+
746
+ /**
747
+ * The preview for the document the card is showing.
748
+ *
749
+ * @param {Tab | null} tab — the tab the card was drawn for; its route and its layout, not the
750
+ * focused pane's.
751
+ * @param {JxMutableNode} doc — the head-bearing view of the document (`buildHeadDoc` for content).
752
+ * @returns {SeoPreview}
753
+ */
754
+ export function seoPreviewFor(tab: Tab | null, doc: JxMutableNode): SeoPreview {
755
+ const config = projectState?.projectConfig;
756
+ const layout = layoutHeadEntries(tab, doc.$layout);
757
+ const path = tab?.documentPath;
758
+ return buildSeoPreview(
759
+ {
760
+ layout: layout.entries,
761
+ layoutName: layout.name,
762
+ page: doc.$head ?? [],
763
+ site: config?.$head ?? [],
764
+ },
765
+ {
766
+ pageTitle: typeof doc.title === "string" ? doc.title : "",
767
+ route: path ? pageRoute(path) : null,
768
+ ...(config?.name === undefined ? {} : { siteName: config.name }),
769
+ ...(config?.url === undefined ? {} : { siteUrl: config.url }),
770
+ },
771
+ );
772
+ }
773
+
234
774
  // ─── Field renderers ─────────────────────────────────────────────────────
235
775
 
236
776
  /**
@@ -241,7 +781,7 @@ function entryValue(entry: JxHeadEntry) {
241
781
  * @param {(fn: (doc: JxMutableNode) => void) => void} applyMutation
242
782
  * @returns {import("lit-html").TemplateResult}
243
783
  */
244
- function renderMetaFieldRow(
784
+ export function renderMetaFieldRow(
245
785
  field: MetaField,
246
786
  head: JxHeadEntry[],
247
787
  applyMutation: (fn: (doc: JxMutableNode) => void) => void,
@@ -316,77 +856,10 @@ export function renderHeadTemplate({
316
856
  const isContent = tab?.doc.mode === "content";
317
857
  const frontmatterSection = isContent ? renderFrontmatterSection() : nothing;
318
858
 
319
- // Layout field
320
- const isPage =
321
- tab?.documentPath &&
322
- projectState?.isSiteProject &&
323
- (tab.documentPath.startsWith("pages/") || tab.documentPath.startsWith("./pages/"));
324
-
325
- let layoutSection: TemplateResult | symbol = nothing;
326
- if (isPage) {
327
- if (layoutEntries === null) {
328
- void loadLayoutEntries();
329
- } else {
330
- const currentLayout = doc.$layout;
331
- const defaultLayout = projectState?.projectConfig?.defaults?.layout;
332
- const displayValue = currentLayout === false ? "__none__" : currentLayout || "__default__";
333
- const defaultLabel = defaultLayout
334
- ? defaultLayout
335
- .replace(/^\.\/layouts\//, "")
336
- .replace(/\.json$/, "")
337
- .replaceAll(/[-_]+/g, " ")
338
- .replaceAll(/\b\w/g, (c: string) => c.toUpperCase())
339
- : "";
340
-
341
- layoutSection = html`
342
- <div class="imports-section">
343
- <div class="imports-section-header">
344
- <span class="imports-section-title">Layout</span>
345
- </div>
346
- <div class="head-section-body">
347
- ${renderFieldRow({
348
- hasValue: currentLayout !== undefined,
349
- label: "Layout",
350
- onClear: () =>
351
- applyMutation((d: JxMutableNode) => {
352
- delete d.$layout;
353
- }),
354
- prop: "layout",
355
- widget: html`
356
- <sp-picker
357
- size="s"
358
- value=${displayValue}
359
- @change=${(e: Event) => {
360
- const val = (e.target as HTMLInputElement).value;
361
- applyMutation((d: JxMutableNode) => {
362
- if (val === "__default__") {
363
- delete d.$layout;
364
- } else if (val === "__none__") {
365
- d.$layout = false;
366
- } else {
367
- d.$layout = val;
368
- }
369
- });
370
- invalidateLayoutCache();
371
- }}
372
- >
373
- <sp-menu-item value="__default__"
374
- >Default${defaultLabel ? ` (${defaultLabel})` : ""}</sp-menu-item
375
- >
376
- <sp-menu-item value="__none__">None</sp-menu-item>
377
- <sp-menu-divider></sp-menu-divider>
378
- ${layoutEntries.map(
379
- (l: { name: string; path: string }) =>
380
- html`<sp-menu-item value=${l.path}>${l.name}</sp-menu-item>`,
381
- )}
382
- </sp-picker>
383
- `,
384
- })}
385
- </div>
386
- </div>
387
- `;
388
- }
389
- }
859
+ // `tab` is the FOCUSED tab, and legitimately so: this template is the Navigator's Page panel,
860
+ // Which is drawn once for the shell and follows the focus by design. Spelling it here is what
861
+ // Lets `renderLayoutSection` and everything under it take a tab instead of asking.
862
+ const layoutSection = renderLayoutSection(tab, doc, applyMutation);
390
863
 
391
864
  return html`
392
865
  <div class="imports-panel">
@@ -396,6 +869,20 @@ export function renderHeadTemplate({
396
869
  <div class="imports-section">
397
870
  <div class="imports-section-header">
398
871
  <span class="imports-section-title">Page</span>
872
+ <!-- The SECOND door to Search appearance; the Document Header card has the other. Two
873
+ moments, one capability — both run document.openSeo, so neither surface owns it and
874
+ the palette has it by name. -->
875
+ <sp-action-button
876
+ quiet
877
+ size="xs"
878
+ class="head-seo-btn"
879
+ title="Preview how this page appears in search and when shared"
880
+ @click=${() => {
881
+ void activeRegistry()?.run("document.openSeo");
882
+ }}
883
+ >
884
+ Search appearance…
885
+ </sp-action-button>
399
886
  </div>
400
887
  <div class="head-section-body">
401
888
  ${renderFieldRow({
@@ -485,7 +972,12 @@ export function renderHeadTemplate({
485
972
  })}
486
973
  </div>
487
974
  `
488
- : html`<div class="imports-empty">No custom tags</div>`
975
+ : renderEmptyState({
976
+ compact: true,
977
+ message:
978
+ "Custom tags add your own meta, link and script elements to this page — " +
979
+ "analytics, verification, a webfont. Add one below.",
980
+ })
489
981
  }
490
982
 
491
983
  <!-- Add custom tag form -->
@@ -549,6 +1041,128 @@ export function renderHeadTemplate({
549
1041
  `;
550
1042
  }
551
1043
 
1044
+ // ─── Layout picker ───────────────────────────────────────────────────────
1045
+
1046
+ /**
1047
+ * Whether `tab`'s document is a page of a site project — the only documents a layout applies to.
1048
+ *
1049
+ * **It takes the tab, and that is the whole of finding 3.** It was zero-argument and read
1050
+ * `activeTab.value?.documentPath`, while both of its important callers had already been handed a
1051
+ * tab: `hasDocumentHeader(tab)` inspects THAT tab's frontmatter, title and `$head` and then fell
1052
+ * through to here for the "a page always has one" rule, and `documentHeaderTemplate(tab, paneId)`
1053
+ * gates the Layout picker on it. Both directions were visible with two panes — a page in the
1054
+ * unfocused pane lost its Title and Route because the focused tab was a component, and a bare
1055
+ * component GAINED a header card because the focused tab was a page — and the Layout picker
1056
+ * appeared or vanished in the pane you were editing according to the document in the other one.
1057
+ *
1058
+ * A caller whose subject genuinely IS the focused document (the Navigator's Page panel) passes
1059
+ * `activeTab.value`, where a reviewer can see it. The same bargain `setCanvasMode` made.
1060
+ *
1061
+ * @param {Tab | null} tab
1062
+ * @returns {boolean}
1063
+ */
1064
+ export function isPageDocument(tab: Tab | null): boolean {
1065
+ const path = tab?.documentPath;
1066
+ return Boolean(
1067
+ path &&
1068
+ projectState?.isSiteProject &&
1069
+ (path.startsWith("pages/") || path.startsWith("./pages/")),
1070
+ );
1071
+ }
1072
+
1073
+ /**
1074
+ * The layout picker as ONE field row, so the Page panel and the Document Header card render the
1075
+ * same control rather than two that drift.
1076
+ *
1077
+ * Returns `nothing` while the layouts directory is still being listed; the listing schedules a
1078
+ * re-render of both surfaces when it lands.
1079
+ *
1080
+ * @param {JxMutableNode} doc
1081
+ * @param {(fn: (doc: JxMutableNode) => void) => void} applyMutation
1082
+ * @returns {TemplateResult | typeof nothing}
1083
+ */
1084
+ export function renderLayoutPickerRow(
1085
+ doc: JxMutableNode,
1086
+ applyMutation: (fn: (doc: JxMutableNode) => void) => void,
1087
+ ): TemplateResult | typeof nothing {
1088
+ if (layoutEntries === null) {
1089
+ void loadLayoutEntries();
1090
+ return nothing;
1091
+ }
1092
+ const currentLayout = doc.$layout;
1093
+ const defaultLayout = projectState?.projectConfig?.defaults?.layout;
1094
+ const displayValue = currentLayout === false ? "__none__" : currentLayout || "__default__";
1095
+ const defaultLabel = defaultLayout
1096
+ ? defaultLayout
1097
+ .replace(/^\.\/layouts\//, "")
1098
+ .replace(/\.json$/, "")
1099
+ .replaceAll(/[-_]+/g, " ")
1100
+ .replaceAll(/\b\w/g, (c: string) => c.toUpperCase())
1101
+ : "";
1102
+ const entries = layoutEntries;
1103
+ return renderFieldRow({
1104
+ hasValue: currentLayout !== undefined,
1105
+ label: "Layout",
1106
+ onClear: () =>
1107
+ applyMutation((d: JxMutableNode) => {
1108
+ delete d.$layout;
1109
+ }),
1110
+ prop: "layout",
1111
+ widget: html`
1112
+ <sp-picker
1113
+ size="s"
1114
+ value=${displayValue}
1115
+ @change=${(e: Event) => {
1116
+ const val = (e.target as HTMLInputElement).value;
1117
+ applyMutation((d: JxMutableNode) => {
1118
+ if (val === "__default__") {
1119
+ delete d.$layout;
1120
+ } else if (val === "__none__") {
1121
+ d.$layout = false;
1122
+ } else {
1123
+ d.$layout = val;
1124
+ }
1125
+ });
1126
+ invalidateLayoutCache();
1127
+ }}
1128
+ >
1129
+ <sp-menu-item value="__default__"
1130
+ >Default${defaultLabel ? ` (${defaultLabel})` : ""}</sp-menu-item
1131
+ >
1132
+ <sp-menu-item value="__none__">None</sp-menu-item>
1133
+ <sp-menu-divider></sp-menu-divider>
1134
+ ${entries.map(
1135
+ (l: { name: string; path: string }) =>
1136
+ html`<sp-menu-item value=${l.path}>${l.name}</sp-menu-item>`,
1137
+ )}
1138
+ </sp-picker>
1139
+ `,
1140
+ });
1141
+ }
1142
+
1143
+ /** The Page panel's boxed wrapper around {@link renderLayoutPickerRow}. */
1144
+ function renderLayoutSection(
1145
+ tab: Tab | null,
1146
+ doc: JxMutableNode,
1147
+ applyMutation: (fn: (doc: JxMutableNode) => void) => void,
1148
+ ): TemplateResult | typeof nothing {
1149
+ if (!isPageDocument(tab)) {
1150
+ return nothing;
1151
+ }
1152
+ const row = renderLayoutPickerRow(doc, applyMutation);
1153
+ if (row === nothing) {
1154
+ return nothing;
1155
+ }
1156
+ return html`
1157
+ <div class="imports-section">
1158
+ <div class="imports-section-header">
1159
+ <span class="imports-section-title">Layout</span>
1160
+ </div>
1161
+ <div class="head-section-body">${row}</div>
1162
+ </div>
1163
+ `;
1164
+ }
1165
+
552
1166
  // ─── Frontmatter section ────────────────────────────────────────────────
553
1167
 
554
1168
  function renderFrontmatterSection() {
@@ -575,8 +1189,100 @@ function renderFrontmatterSection() {
575
1189
  >
576
1190
  </div>
577
1191
  <div class="head-section-body">
578
- ${fields.map((f) => renderFmField(f.field, f.entry, f.value, requiredFields))}
1192
+ ${fields.map((f) => renderFmField(tab, f.field, f.entry, f.value, requiredFields))}
579
1193
  </div>
580
1194
  </div>
581
1195
  `;
582
1196
  }
1197
+
1198
+ /** Overlay content-mode frontmatter title/`$head` onto the document the panel edits. */
1199
+ export function buildHeadDoc(doc: JxMutableNode, fm: Record<string, unknown>): JxMutableNode {
1200
+ const title = fm.title as string | undefined;
1201
+ const $head = fm.$head as JxHeadEntry[] | undefined;
1202
+ return {
1203
+ ...doc,
1204
+ ...(title === undefined ? {} : { title }),
1205
+ ...($head === undefined ? {} : { $head }),
1206
+ };
1207
+ }
1208
+
1209
+ /**
1210
+ * The mutation path for a content-mode document, where title and `$head` live in frontmatter.
1211
+ *
1212
+ * The panel edits a `JxMutableNode`; a markdown page's head fields are frontmatter keys. This
1213
+ * adapts one to the other in the module that owns both, instead of in the Navigator orchestrator
1214
+ * that owns neither.
1215
+ *
1216
+ * **`tab` is a parameter for the same reason {@link renderFmField}'s is.** It resolved
1217
+ * `activeTab.value` itself, and the Document Header card calls it for the CONTENT branch of every
1218
+ * mutation it makes — Title, Clear title, the Layout picker — as does Search appearance, which is
1219
+ * where the head fields went and which takes its tab the same way. The card is drawn per pane, so
1220
+ * on a markdown page the card in one pane retitled the document in the other. The comment beside
1221
+ * the card's JSON branch has claimed since P8 that this was fixed; the fix reached the JSON branch
1222
+ * only, and this is the half that was left.
1223
+ *
1224
+ * @param {Tab | null} tab The document to commit into.
1225
+ * @param {() => void} rerender
1226
+ * @param {(doc: JxMutableNode) => void} fn
1227
+ */
1228
+ export function applyContentMutation(
1229
+ tab: Tab | null,
1230
+ rerender: () => void,
1231
+ fn: (doc: JxMutableNode) => void,
1232
+ ): void {
1233
+ if (!tab) {
1234
+ return;
1235
+ }
1236
+ const fmNow = (tab.doc.content?.frontmatter ?? {}) as Record<string, unknown>;
1237
+ const fmHead = fmNow.$head as JxHeadEntry[] | undefined;
1238
+ const tmp: JxMutableNode = {
1239
+ ...(typeof fmNow.title === "string" ? { title: fmNow.title } : {}),
1240
+ ...(fmHead ? { $head: [...fmHead] } : {}),
1241
+ };
1242
+ fn(tmp);
1243
+ if (tmp.title !== fmNow.title) {
1244
+ mutateUpdateFrontmatter(tab, "title", tmp.title as JsonValue);
1245
+ }
1246
+ const newHead = tmp.$head && tmp.$head.length > 0 ? tmp.$head : undefined;
1247
+ // JxHeadEntry[] is JSON document content by construction.
1248
+ mutateUpdateFrontmatter(tab, "$head", newHead as JsonValue);
1249
+ rerender();
1250
+ }
1251
+
1252
+ /**
1253
+ * Contribute the Page panel.
1254
+ *
1255
+ * `level: "document"` — title, description, social card and custom `$head` entries are the open
1256
+ * document's. The id is `page` now: "head" named an HTML element, and §3.2's DOCUMENT group calls
1257
+ * the surface Page. P3.10 moves these fields into the in-stage Document Header card; until then the
1258
+ * record is what keeps the surface addressable under one name.
1259
+ */
1260
+ export function registerPagePanel(): void {
1261
+ registerPanel({
1262
+ id: "page",
1263
+ title: "Page",
1264
+ level: "document",
1265
+ dock: "navigator",
1266
+ icon: "sp-icon-view-all-tags",
1267
+ requiresDocument: "Open a page to edit its title, description and social preview.",
1268
+ render: (ctx) => {
1269
+ const doc = ctx.doc!;
1270
+ const isContent = doc.mode === "content";
1271
+ const fm = doc.content?.frontmatter ?? {};
1272
+ // Through `deps`, not the local binding: `studio.ts` owns the wiring, and the Navigator has
1273
+ // Injected these renderers since before the registry existed.
1274
+ return ctx.deps.renderHeadTemplate({
1275
+ // The Navigator's Page panel IS an app-level surface: it shows the focused document by
1276
+ // Definition, so it spells that out at the call site now instead of leaving it to a helper
1277
+ // Two other surfaces share.
1278
+ applyMutation: isContent
1279
+ ? (fn) => applyContentMutation(activeTab.value, ctx.rerender, fn)
1280
+ : (fn) => {
1281
+ transact(activeTab.value, fn);
1282
+ },
1283
+ document: isContent ? buildHeadDoc(doc.document, fm) : doc.document,
1284
+ renderLeftPanel: ctx.rerender,
1285
+ });
1286
+ },
1287
+ });
1288
+ }