@prenta/admin 0.92.0 → 0.93.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 (374) hide show
  1. package/CHANGELOG.md +552 -0
  2. package/design-system/tokens.css +173 -71
  3. package/design-system/tokens.json +244 -63
  4. package/design-system/tokens.scss +203 -98
  5. package/design-system/tokens.tailwind.cjs +93 -38
  6. package/dist/AdminRoot.d.ts +27 -6
  7. package/dist/AdminRoot.d.ts.map +1 -1
  8. package/dist/AdminRoot.js.map +1 -1
  9. package/dist/__tests__/components/breakpoint-controls.render.test.d.ts +2 -0
  10. package/dist/__tests__/components/breakpoint-controls.render.test.d.ts.map +1 -0
  11. package/dist/__tests__/components/breakpoint-controls.render.test.js +51 -0
  12. package/dist/__tests__/components/breakpoint-controls.render.test.js.map +1 -0
  13. package/dist/__tests__/components/canvas-edit-tip.render.test.d.ts +2 -0
  14. package/dist/__tests__/components/canvas-edit-tip.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/components/canvas-edit-tip.render.test.js +37 -0
  16. package/dist/__tests__/components/canvas-edit-tip.render.test.js.map +1 -0
  17. package/dist/__tests__/components/editor-rail.render.test.d.ts +2 -0
  18. package/dist/__tests__/components/editor-rail.render.test.d.ts.map +1 -0
  19. package/dist/__tests__/components/editor-rail.render.test.js +138 -0
  20. package/dist/__tests__/components/editor-rail.render.test.js.map +1 -0
  21. package/dist/__tests__/components/media-block.render.test.d.ts +2 -0
  22. package/dist/__tests__/components/media-block.render.test.d.ts.map +1 -0
  23. package/dist/__tests__/components/media-block.render.test.js +108 -0
  24. package/dist/__tests__/components/media-block.render.test.js.map +1 -0
  25. package/dist/__tests__/components/page-audit-panel.render.test.d.ts +2 -0
  26. package/dist/__tests__/components/page-audit-panel.render.test.d.ts.map +1 -0
  27. package/dist/__tests__/components/page-audit-panel.render.test.js +59 -0
  28. package/dist/__tests__/components/page-audit-panel.render.test.js.map +1 -0
  29. package/dist/__tests__/components/reflow-meter.render.test.d.ts +2 -0
  30. package/dist/__tests__/components/reflow-meter.render.test.d.ts.map +1 -0
  31. package/dist/__tests__/components/reflow-meter.render.test.js +94 -0
  32. package/dist/__tests__/components/reflow-meter.render.test.js.map +1 -0
  33. package/dist/__tests__/components/seo-char-count.test.js +24 -6
  34. package/dist/__tests__/components/seo-char-count.test.js.map +1 -1
  35. package/dist/__tests__/components/serp-preview.render.test.d.ts +2 -0
  36. package/dist/__tests__/components/serp-preview.render.test.d.ts.map +1 -0
  37. package/dist/__tests__/components/serp-preview.render.test.js +25 -0
  38. package/dist/__tests__/components/serp-preview.render.test.js.map +1 -0
  39. package/dist/__tests__/lib/breakpoints.test.d.ts +2 -0
  40. package/dist/__tests__/lib/breakpoints.test.d.ts.map +1 -0
  41. package/dist/__tests__/lib/breakpoints.test.js +67 -0
  42. package/dist/__tests__/lib/breakpoints.test.js.map +1 -0
  43. package/dist/__tests__/lib/canvas-edit-chrome.test.d.ts +2 -0
  44. package/dist/__tests__/lib/canvas-edit-chrome.test.d.ts.map +1 -0
  45. package/dist/__tests__/lib/canvas-edit-chrome.test.js +44 -0
  46. package/dist/__tests__/lib/canvas-edit-chrome.test.js.map +1 -0
  47. package/dist/__tests__/lib/media-format.test.d.ts +2 -0
  48. package/dist/__tests__/lib/media-format.test.d.ts.map +1 -0
  49. package/dist/__tests__/lib/media-format.test.js +70 -0
  50. package/dist/__tests__/lib/media-format.test.js.map +1 -0
  51. package/dist/__tests__/lib/page-audit-input.test.d.ts +2 -0
  52. package/dist/__tests__/lib/page-audit-input.test.d.ts.map +1 -0
  53. package/dist/__tests__/lib/page-audit-input.test.js +122 -0
  54. package/dist/__tests__/lib/page-audit-input.test.js.map +1 -0
  55. package/dist/__tests__/lib/page-editor-service.test.js +2 -0
  56. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
  57. package/dist/__tests__/lib/page-redirect.test.d.ts +2 -0
  58. package/dist/__tests__/lib/page-redirect.test.d.ts.map +1 -0
  59. package/dist/__tests__/lib/page-redirect.test.js +26 -0
  60. package/dist/__tests__/lib/page-redirect.test.js.map +1 -0
  61. package/dist/__tests__/lib/rail-width.test.d.ts +2 -0
  62. package/dist/__tests__/lib/rail-width.test.d.ts.map +1 -0
  63. package/dist/__tests__/lib/rail-width.test.js +69 -0
  64. package/dist/__tests__/lib/rail-width.test.js.map +1 -0
  65. package/dist/__tests__/lib/reflow-baseline.test.d.ts +2 -0
  66. package/dist/__tests__/lib/reflow-baseline.test.d.ts.map +1 -0
  67. package/dist/__tests__/lib/reflow-baseline.test.js +56 -0
  68. package/dist/__tests__/lib/reflow-baseline.test.js.map +1 -0
  69. package/dist/__tests__/lib/reflow-probe.test.d.ts +2 -0
  70. package/dist/__tests__/lib/reflow-probe.test.d.ts.map +1 -0
  71. package/dist/__tests__/lib/reflow-probe.test.js +73 -0
  72. package/dist/__tests__/lib/reflow-probe.test.js.map +1 -0
  73. package/dist/__tests__/styles/canvas-container-queries.test.d.ts +2 -0
  74. package/dist/__tests__/styles/canvas-container-queries.test.d.ts.map +1 -0
  75. package/dist/__tests__/styles/canvas-container-queries.test.js +87 -0
  76. package/dist/__tests__/styles/canvas-container-queries.test.js.map +1 -0
  77. package/dist/__tests__/styles/packaging.test.d.ts +2 -0
  78. package/dist/__tests__/styles/packaging.test.d.ts.map +1 -0
  79. package/dist/__tests__/styles/packaging.test.js +31 -0
  80. package/dist/__tests__/styles/packaging.test.js.map +1 -0
  81. package/dist/__tests__/styles/tokens-drift.test.d.ts +2 -0
  82. package/dist/__tests__/styles/tokens-drift.test.d.ts.map +1 -0
  83. package/dist/__tests__/styles/tokens-drift.test.js +92 -0
  84. package/dist/__tests__/styles/tokens-drift.test.js.map +1 -0
  85. package/dist/__tests__/views/admin-renderer-compat.test.d.ts +2 -0
  86. package/dist/__tests__/views/admin-renderer-compat.test.d.ts.map +1 -0
  87. package/dist/__tests__/views/admin-renderer-compat.test.js +43 -0
  88. package/dist/__tests__/views/admin-renderer-compat.test.js.map +1 -0
  89. package/dist/__tests__/views/admin-section-renderers.test.js +52 -4
  90. package/dist/__tests__/views/admin-section-renderers.test.js.map +1 -1
  91. package/dist/__tests__/views/editor-top-bar.render.test.js +1 -1
  92. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  93. package/dist/__tests__/views/page-tab.render.test.d.ts +2 -0
  94. package/dist/__tests__/views/page-tab.render.test.d.ts.map +1 -0
  95. package/dist/__tests__/views/page-tab.render.test.js +151 -0
  96. package/dist/__tests__/views/page-tab.render.test.js.map +1 -0
  97. package/dist/__tests__/views/section-class-parity.test.d.ts +2 -0
  98. package/dist/__tests__/views/section-class-parity.test.d.ts.map +1 -0
  99. package/dist/__tests__/views/section-class-parity.test.js +81 -0
  100. package/dist/__tests__/views/section-class-parity.test.js.map +1 -0
  101. package/dist/__tests__/views/sections-panel-design-owned.render.test.d.ts +2 -0
  102. package/dist/__tests__/views/sections-panel-design-owned.render.test.d.ts.map +1 -0
  103. package/dist/__tests__/views/sections-panel-design-owned.render.test.js +86 -0
  104. package/dist/__tests__/views/sections-panel-design-owned.render.test.js.map +1 -0
  105. package/dist/components/BreakpointPicker.d.ts +18 -0
  106. package/dist/components/BreakpointPicker.d.ts.map +1 -0
  107. package/dist/components/BreakpointPicker.js +31 -0
  108. package/dist/components/BreakpointPicker.js.map +1 -0
  109. package/dist/components/CanvasEditTip.d.ts +6 -0
  110. package/dist/components/CanvasEditTip.d.ts.map +1 -0
  111. package/dist/components/CanvasEditTip.js +22 -0
  112. package/dist/components/CanvasEditTip.js.map +1 -0
  113. package/dist/components/EditorRail.d.ts +36 -0
  114. package/dist/components/EditorRail.d.ts.map +1 -0
  115. package/dist/components/EditorRail.js +104 -0
  116. package/dist/components/EditorRail.js.map +1 -0
  117. package/dist/components/EditorRailTabs.d.ts +32 -0
  118. package/dist/components/EditorRailTabs.d.ts.map +1 -0
  119. package/dist/components/EditorRailTabs.js +45 -0
  120. package/dist/components/EditorRailTabs.js.map +1 -0
  121. package/dist/components/MediaBlock.d.ts +49 -0
  122. package/dist/components/MediaBlock.d.ts.map +1 -0
  123. package/dist/components/MediaBlock.js +50 -0
  124. package/dist/components/MediaBlock.js.map +1 -0
  125. package/dist/components/ReflowMeter.d.ts +28 -0
  126. package/dist/components/ReflowMeter.d.ts.map +1 -0
  127. package/dist/components/ReflowMeter.js +42 -0
  128. package/dist/components/ReflowMeter.js.map +1 -0
  129. package/dist/components/ZoomChip.d.ts +17 -0
  130. package/dist/components/ZoomChip.d.ts.map +1 -0
  131. package/dist/components/ZoomChip.js +17 -0
  132. package/dist/components/ZoomChip.js.map +1 -0
  133. package/dist/components/breakpoint-icons.d.ts +3 -0
  134. package/dist/components/breakpoint-icons.d.ts.map +1 -0
  135. package/dist/components/breakpoint-icons.js +16 -0
  136. package/dist/components/breakpoint-icons.js.map +1 -0
  137. package/dist/components/page-tab/AuthorPicker.d.ts +31 -0
  138. package/dist/components/page-tab/AuthorPicker.d.ts.map +1 -0
  139. package/dist/components/page-tab/AuthorPicker.js +95 -0
  140. package/dist/components/page-tab/AuthorPicker.js.map +1 -0
  141. package/dist/components/page-tab/DirectiveSwitch.d.ts +38 -0
  142. package/dist/components/page-tab/DirectiveSwitch.d.ts.map +1 -0
  143. package/dist/components/page-tab/DirectiveSwitch.js +29 -0
  144. package/dist/components/page-tab/DirectiveSwitch.js.map +1 -0
  145. package/dist/components/seo/EditorSeoScoreBadge.d.ts +8 -1
  146. package/dist/components/seo/EditorSeoScoreBadge.d.ts.map +1 -1
  147. package/dist/components/seo/EditorSeoScoreBadge.js +2 -2
  148. package/dist/components/seo/EditorSeoScoreBadge.js.map +1 -1
  149. package/dist/components/seo/PageAuditPanel.d.ts +14 -0
  150. package/dist/components/seo/PageAuditPanel.d.ts.map +1 -0
  151. package/dist/components/seo/PageAuditPanel.js +59 -0
  152. package/dist/components/seo/PageAuditPanel.js.map +1 -0
  153. package/dist/components/seo/SeoCharCount.d.ts +19 -7
  154. package/dist/components/seo/SeoCharCount.d.ts.map +1 -1
  155. package/dist/components/seo/SeoCharCount.js +30 -16
  156. package/dist/components/seo/SeoCharCount.js.map +1 -1
  157. package/dist/components/seo/SeoEditorPane.d.ts +15 -1
  158. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  159. package/dist/components/seo/SeoEditorPane.js +118 -21
  160. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  161. package/dist/components/seo/SerpPreview.d.ts +20 -0
  162. package/dist/components/seo/SerpPreview.d.ts.map +1 -0
  163. package/dist/components/seo/SerpPreview.js +6 -0
  164. package/dist/components/seo/SerpPreview.js.map +1 -0
  165. package/dist/hooks/usePageAudit.d.ts +41 -0
  166. package/dist/hooks/usePageAudit.d.ts.map +1 -0
  167. package/dist/hooks/usePageAudit.js +68 -0
  168. package/dist/hooks/usePageAudit.js.map +1 -0
  169. package/dist/hooks/useReflowGuard.d.ts +47 -0
  170. package/dist/hooks/useReflowGuard.d.ts.map +1 -0
  171. package/dist/hooks/useReflowGuard.js +52 -0
  172. package/dist/hooks/useReflowGuard.js.map +1 -0
  173. package/dist/lib/breakpoints.d.ts +51 -0
  174. package/dist/lib/breakpoints.d.ts.map +1 -0
  175. package/dist/lib/breakpoints.js +84 -0
  176. package/dist/lib/breakpoints.js.map +1 -0
  177. package/dist/lib/canvas-edit-chrome.d.ts +22 -0
  178. package/dist/lib/canvas-edit-chrome.d.ts.map +1 -0
  179. package/dist/lib/canvas-edit-chrome.js +81 -0
  180. package/dist/lib/canvas-edit-chrome.js.map +1 -0
  181. package/dist/lib/media-format.d.ts +55 -0
  182. package/dist/lib/media-format.d.ts.map +1 -0
  183. package/dist/lib/media-format.js +93 -0
  184. package/dist/lib/media-format.js.map +1 -0
  185. package/dist/lib/page-audit-input.d.ts +45 -0
  186. package/dist/lib/page-audit-input.d.ts.map +1 -0
  187. package/dist/lib/page-audit-input.js +53 -0
  188. package/dist/lib/page-audit-input.js.map +1 -0
  189. package/dist/lib/page-editor-service.d.ts +12 -0
  190. package/dist/lib/page-editor-service.d.ts.map +1 -1
  191. package/dist/lib/page-editor-service.js +6 -0
  192. package/dist/lib/page-editor-service.js.map +1 -1
  193. package/dist/lib/page-redirect.d.ts +39 -0
  194. package/dist/lib/page-redirect.d.ts.map +1 -0
  195. package/dist/lib/page-redirect.js +38 -0
  196. package/dist/lib/page-redirect.js.map +1 -0
  197. package/dist/lib/rail-width.d.ts +23 -0
  198. package/dist/lib/rail-width.d.ts.map +1 -0
  199. package/dist/lib/rail-width.js +59 -0
  200. package/dist/lib/rail-width.js.map +1 -0
  201. package/dist/lib/reflow-baseline.d.ts +45 -0
  202. package/dist/lib/reflow-baseline.d.ts.map +1 -0
  203. package/dist/lib/reflow-baseline.js +38 -0
  204. package/dist/lib/reflow-baseline.js.map +1 -0
  205. package/dist/lib/reflow-probe.d.ts +159 -0
  206. package/dist/lib/reflow-probe.d.ts.map +1 -0
  207. package/dist/lib/reflow-probe.js +228 -0
  208. package/dist/lib/reflow-probe.js.map +1 -0
  209. package/dist/lib/seo-service.d.ts +10 -0
  210. package/dist/lib/seo-service.d.ts.map +1 -1
  211. package/dist/lib/seo-service.js.map +1 -1
  212. package/dist/prenta-admin.css +1 -1
  213. package/dist/views/page-editor/CanvasMetaLine.d.ts +19 -0
  214. package/dist/views/page-editor/CanvasMetaLine.d.ts.map +1 -0
  215. package/dist/views/page-editor/CanvasMetaLine.js +23 -0
  216. package/dist/views/page-editor/CanvasMetaLine.js.map +1 -0
  217. package/dist/views/page-editor/EditorCanvas.d.ts +34 -8
  218. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  219. package/dist/views/page-editor/EditorCanvas.js +68 -34
  220. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  221. package/dist/views/page-editor/EditorTopBar.d.ts +14 -1
  222. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  223. package/dist/views/page-editor/EditorTopBar.js +8 -17
  224. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  225. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  226. package/dist/views/page-editor/PageSectionEditor.js +275 -53
  227. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  228. package/dist/views/page-editor/PageTab.d.ts +36 -0
  229. package/dist/views/page-editor/PageTab.d.ts.map +1 -0
  230. package/dist/views/page-editor/PageTab.js +65 -0
  231. package/dist/views/page-editor/PageTab.js.map +1 -0
  232. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  233. package/dist/views/page-editor/SectionInspector.js +63 -19
  234. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  235. package/dist/views/page-editor/SectionsPanel.d.ts +30 -5
  236. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  237. package/dist/views/page-editor/SectionsPanel.js +38 -7
  238. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  239. package/dist/views/page-editor/SitePreviewFrame.d.ts.map +1 -1
  240. package/dist/views/page-editor/SitePreviewFrame.js +3 -0
  241. package/dist/views/page-editor/SitePreviewFrame.js.map +1 -1
  242. package/dist/views/page-editor/reflow-context.d.ts +28 -0
  243. package/dist/views/page-editor/reflow-context.d.ts.map +1 -0
  244. package/dist/views/page-editor/reflow-context.js +14 -0
  245. package/dist/views/page-editor/reflow-context.js.map +1 -0
  246. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts.map +1 -1
  247. package/dist/views/page-editor/sections/ByTheNumbersSection.js +1 -1
  248. package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -1
  249. package/dist/views/page-editor/sections/CtaBannerSection.d.ts.map +1 -1
  250. package/dist/views/page-editor/sections/CtaBannerSection.js +1 -1
  251. package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -1
  252. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts.map +1 -1
  253. package/dist/views/page-editor/sections/FeatureHighlightSection.js +2 -2
  254. package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -1
  255. package/dist/views/page-editor/sections/GallerySection.d.ts.map +1 -1
  256. package/dist/views/page-editor/sections/GallerySection.js +7 -3
  257. package/dist/views/page-editor/sections/GallerySection.js.map +1 -1
  258. package/dist/views/page-editor/sections/HeroBannerSection.js +1 -1
  259. package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -1
  260. package/dist/views/page-editor/sections/MissionSection.d.ts.map +1 -1
  261. package/dist/views/page-editor/sections/MissionSection.js +1 -1
  262. package/dist/views/page-editor/sections/MissionSection.js.map +1 -1
  263. package/dist/views/page-editor/sections/PullQuoteSection.d.ts.map +1 -1
  264. package/dist/views/page-editor/sections/PullQuoteSection.js +1 -1
  265. package/dist/views/page-editor/sections/PullQuoteSection.js.map +1 -1
  266. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts.map +1 -1
  267. package/dist/views/page-editor/sections/RelatedPostsSection.js +1 -1
  268. package/dist/views/page-editor/sections/RelatedPostsSection.js.map +1 -1
  269. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  270. package/dist/views/page-editor/sections/SectionRenderer.js +49 -24
  271. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  272. package/dist/views/page-editor/sections/admin-renderers.d.ts +47 -15
  273. package/dist/views/page-editor/sections/admin-renderers.d.ts.map +1 -1
  274. package/dist/views/page-editor/sections/admin-renderers.js +27 -15
  275. package/dist/views/page-editor/sections/admin-renderers.js.map +1 -1
  276. package/dist/views/page-editor/sections/parts.d.ts +0 -1
  277. package/dist/views/page-editor/sections/parts.d.ts.map +1 -1
  278. package/dist/views/page-editor/sections/parts.js +41 -16
  279. package/dist/views/page-editor/sections/parts.js.map +1 -1
  280. package/dist/views/page-editor/viewports.d.ts +7 -14
  281. package/dist/views/page-editor/viewports.d.ts.map +1 -1
  282. package/dist/views/page-editor/viewports.js +7 -12
  283. package/dist/views/page-editor/viewports.js.map +1 -1
  284. package/dist/views/post-editor/PostEditorCanvas.d.ts +2 -1
  285. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  286. package/dist/views/post-editor/PostEditorCanvas.js +5 -2
  287. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  288. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  289. package/dist/views/post-editor/PostSectionEditor.js +2 -20
  290. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  291. package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -1
  292. package/dist/views/post-editor/PostTemplateEditor.js +4 -1
  293. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -1
  294. package/package.json +5 -3
  295. package/src/AdminRoot.tsx +27 -6
  296. package/src/__tests__/components/breakpoint-controls.render.test.tsx +57 -0
  297. package/src/__tests__/components/canvas-edit-tip.render.test.tsx +41 -0
  298. package/src/__tests__/components/editor-rail.render.test.tsx +192 -0
  299. package/src/__tests__/components/media-block.render.test.tsx +126 -0
  300. package/src/__tests__/components/page-audit-panel.render.test.tsx +64 -0
  301. package/src/__tests__/components/reflow-meter.render.test.tsx +144 -0
  302. package/src/__tests__/components/seo-char-count.test.tsx +26 -6
  303. package/src/__tests__/components/serp-preview.render.test.tsx +32 -0
  304. package/src/__tests__/lib/breakpoints.test.ts +84 -0
  305. package/src/__tests__/lib/canvas-edit-chrome.test.ts +56 -0
  306. package/src/__tests__/lib/media-format.test.ts +89 -0
  307. package/src/__tests__/lib/page-audit-input.test.ts +144 -0
  308. package/src/__tests__/lib/page-editor-service.test.ts +2 -0
  309. package/src/__tests__/lib/page-redirect.test.ts +29 -0
  310. package/src/__tests__/lib/rail-width.test.ts +86 -0
  311. package/src/__tests__/lib/reflow-baseline.test.ts +64 -0
  312. package/src/__tests__/lib/reflow-probe.test.ts +95 -0
  313. package/src/__tests__/styles/canvas-container-queries.test.ts +103 -0
  314. package/src/__tests__/styles/packaging.test.ts +38 -0
  315. package/src/__tests__/styles/tokens-drift.test.ts +110 -0
  316. package/src/__tests__/views/admin-renderer-compat.test.ts +48 -0
  317. package/src/__tests__/views/admin-section-renderers.test.tsx +70 -4
  318. package/src/__tests__/views/editor-top-bar.render.test.tsx +1 -1
  319. package/src/__tests__/views/page-tab.render.test.tsx +226 -0
  320. package/src/__tests__/views/section-class-parity.test.ts +98 -0
  321. package/src/__tests__/views/sections-panel-design-owned.render.test.tsx +104 -0
  322. package/src/components/BreakpointPicker.tsx +114 -0
  323. package/src/components/CanvasEditTip.tsx +46 -0
  324. package/src/components/EditorRail.tsx +219 -0
  325. package/src/components/EditorRailTabs.tsx +117 -0
  326. package/src/components/MediaBlock.tsx +223 -0
  327. package/src/components/ReflowMeter.tsx +125 -0
  328. package/src/components/ZoomChip.tsx +39 -0
  329. package/src/components/breakpoint-icons.ts +17 -0
  330. package/src/components/page-tab/AuthorPicker.tsx +240 -0
  331. package/src/components/page-tab/DirectiveSwitch.tsx +86 -0
  332. package/src/components/seo/EditorSeoScoreBadge.tsx +9 -1
  333. package/src/components/seo/PageAuditPanel.tsx +192 -0
  334. package/src/components/seo/SeoCharCount.tsx +55 -24
  335. package/src/components/seo/SeoEditorPane.tsx +213 -31
  336. package/src/components/seo/SerpPreview.tsx +41 -0
  337. package/src/hooks/usePageAudit.ts +98 -0
  338. package/src/hooks/useReflowGuard.ts +96 -0
  339. package/src/lib/breakpoints.ts +107 -0
  340. package/src/lib/canvas-edit-chrome.ts +82 -0
  341. package/src/lib/media-format.ts +99 -0
  342. package/src/lib/page-audit-input.ts +104 -0
  343. package/src/lib/page-editor-service.ts +18 -0
  344. package/src/lib/page-redirect.ts +60 -0
  345. package/src/lib/rail-width.ts +58 -0
  346. package/src/lib/reflow-baseline.ts +70 -0
  347. package/src/lib/reflow-probe.ts +299 -0
  348. package/src/lib/seo-service.ts +10 -0
  349. package/src/styles/prenta-tokens.css +120 -42
  350. package/src/styles/theme.css +130 -0
  351. package/src/views/page-editor/CanvasMetaLine.tsx +50 -0
  352. package/src/views/page-editor/EditorCanvas.tsx +109 -50
  353. package/src/views/page-editor/EditorTopBar.tsx +35 -43
  354. package/src/views/page-editor/PageSectionEditor.tsx +419 -148
  355. package/src/views/page-editor/PageTab.tsx +433 -0
  356. package/src/views/page-editor/SectionInspector.tsx +82 -41
  357. package/src/views/page-editor/SectionsPanel.tsx +281 -140
  358. package/src/views/page-editor/SitePreviewFrame.tsx +3 -0
  359. package/src/views/page-editor/reflow-context.ts +43 -0
  360. package/src/views/page-editor/sections/ByTheNumbersSection.tsx +3 -2
  361. package/src/views/page-editor/sections/CtaBannerSection.tsx +3 -1
  362. package/src/views/page-editor/sections/FeatureHighlightSection.tsx +3 -2
  363. package/src/views/page-editor/sections/GallerySection.tsx +7 -3
  364. package/src/views/page-editor/sections/HeroBannerSection.tsx +2 -2
  365. package/src/views/page-editor/sections/MissionSection.tsx +4 -2
  366. package/src/views/page-editor/sections/PullQuoteSection.tsx +3 -1
  367. package/src/views/page-editor/sections/RelatedPostsSection.tsx +2 -1
  368. package/src/views/page-editor/sections/SectionRenderer.tsx +69 -29
  369. package/src/views/page-editor/sections/admin-renderers.tsx +54 -15
  370. package/src/views/page-editor/sections/parts.tsx +46 -18
  371. package/src/views/page-editor/viewports.ts +13 -26
  372. package/src/views/post-editor/PostEditorCanvas.tsx +18 -11
  373. package/src/views/post-editor/PostSectionEditor.tsx +1 -56
  374. package/src/views/post-editor/PostTemplateEditor.tsx +3 -0
@@ -6,21 +6,17 @@ import { toast } from 'sonner'
6
6
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
7
7
  import { useNavigationBlocker } from '../../router/index.js'
8
8
  import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js'
9
- import { EditorSeoAside } from '../../components/EditorSeoAside.js'
9
+ import { SeoEditorPane } from '../../components/seo/SeoEditorPane.js'
10
+ import { EditorRail, RailHeader } from '../../components/EditorRail.js'
11
+ import { EditorRailTabs, type RailTab, type RailTabId } from '../../components/EditorRailTabs.js'
10
12
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
11
13
  import { VersionHistory } from '../../components/VersionHistory.js'
12
14
  import {
13
- addSection,
14
- createSection,
15
- duplicateSection,
16
15
  emptyPage,
17
16
  fetchPageForEditor,
18
17
  missingPageFields,
19
- moveSection,
20
18
  publishPage,
21
19
  PAGES_COLLECTION,
22
- removeSection,
23
- reorderSections,
24
20
  savePageDraft,
25
21
  toggleSectionVisibility,
26
22
  updateSectionContent,
@@ -38,20 +34,33 @@ import {
38
34
  createPreviewToken,
39
35
  } from '../../lib/preview-link.js'
40
36
  import { usePublicSiteUrl } from '../../hooks/usePublicSiteUrl.js'
41
- import type { SectionSettings, SectionTypeId } from './section-types.js'
37
+ import type { SectionSettings } from './section-types.js'
42
38
  import { DEFAULT_VIEWPORT, type ViewportId } from './viewports.js'
39
+ import type { Zoom } from '../../lib/breakpoints.js'
40
+ import { snapshotBaseline, type ReflowBaseline } from '../../lib/reflow-baseline.js'
41
+ import {
42
+ DEFAULT_DIRECTIVES,
43
+ directivesFromData,
44
+ type RobotsPermissions,
45
+ } from '@prenta/core/seo/robots-directives'
46
+ import { createPublishRedirect, pendingSlugChange } from '../../lib/page-redirect.js'
47
+ import { updateSeoFields } from '../../lib/seo-service.js'
48
+ import { PageTab } from './PageTab.js'
49
+ import { duplicatePage, deletePage } from '../../lib/pages-service.js'
50
+ import { scoreBand, type PageAuditResult } from '@prenta/core/seo/page-audit'
51
+ import { usePageAudit } from '../../hooks/usePageAudit.js'
43
52
  import { hasCanvasRenderer, useAdminSectionRenderers } from './sections/index.js'
44
53
  import { EditorTopBar, type SaveState } from './EditorTopBar.js'
45
- import { SectionsPanel } from './SectionsPanel.js'
54
+ import { SectionsList } from './SectionsPanel.js'
46
55
  import { EditorCanvas } from './EditorCanvas.js'
56
+ import { ReflowContext } from './reflow-context.js'
47
57
  import { SitePreviewFrame } from './SitePreviewFrame.js'
48
58
  import { useSitePreview } from './useSitePreview.js'
49
59
  import { SectionInspector } from './SectionInspector.js'
50
- import { AddSectionModal } from './AddSectionModal.js'
51
60
  import { PageSettingsModal } from './PageSettingsModal.js'
52
61
  import { ValidationSummary } from './ValidationSummary.js'
53
62
  import { resolveDefaultSitePreview } from '../../lib/editor-preview.js'
54
- import { computeLiveSeoScore } from '../../lib/editor-seo-content.js'
63
+ import { buildEditorContentHtml } from '../../lib/editor-seo-content.js'
55
64
  import { companionDimensionPaths, inspectorIdFromFieldPath } from '../../lib/preview-bridge.js'
56
65
 
57
66
  interface PageSectionEditorProps {
@@ -74,16 +83,47 @@ interface PageSectionEditorProps {
74
83
  onOpenPreview?: (path: string, prepare?: () => Promise<boolean | void> | boolean | void) => void
75
84
  }
76
85
 
77
- function deriveCan(session: any): { canEdit: boolean; canPublish: boolean } {
86
+ /**
87
+ * The lower of the two audit scores — the tab dot should reflect the half that
88
+ * needs work, not an average that hides it.
89
+ *
90
+ * Three-valued on purpose, and it must NOT fall back to another scorer:
91
+ * undefined — the audit has not reported yet (the SEO tab has never been
92
+ * opened). No dot beats a dot from a different model.
93
+ * null — both groups are gated off, so there is genuinely nothing to
94
+ * rate. An earlier version fell back to `computeLiveSeoScore`
95
+ * here, which put a number from the content analyser — different
96
+ * model, different bands — on a tab summarising the audit. That
97
+ * is the disagreement this wiring exists to remove.
98
+ */
99
+ function weakestAuditScore(audit: PageAuditResult | null): number | null | undefined {
100
+ if (!audit) return undefined
101
+ const scores = [audit.seo.score, audit.geo.score].filter((s): s is number => s !== null)
102
+ return scores.length ? Math.min(...scores) : null
103
+ }
104
+
105
+ function deriveCan(session: any): {
106
+ canEdit: boolean
107
+ canPublish: boolean
108
+ canCreateRedirect: boolean
109
+ } {
78
110
  const role: string | undefined = session?.user?.role
79
111
  // Default closed: when we can't identify a role, hide the edit/publish
80
112
  // affordances rather than exposing them. The server independently enforces
81
113
  // access on every write, so this is a UX guard — but it should fail safe.
82
114
  // (Matches the Post editor's behavior.)
83
- if (!role) return { canEdit: false, canPublish: false }
115
+ if (!role) return { canEdit: false, canPublish: false, canCreateRedirect: false }
84
116
  const editors = ['ADMIN', 'EDITOR', 'AUTHOR']
85
117
  const publishers = ['ADMIN', 'EDITOR']
86
- return { canEdit: editors.includes(role), canPublish: publishers.includes(role) }
118
+ // Same roles that may publish a page may create the 301 that follows a
119
+ // slug move. Offering the checkbox to anyone else promises a redirect
120
+ // `POST /redirects` will reject.
121
+ const redirectCreators = ['OWNER', 'ADMIN', 'EDITOR']
122
+ return {
123
+ canEdit: editors.includes(role),
124
+ canPublish: publishers.includes(role),
125
+ canCreateRedirect: redirectCreators.includes(role),
126
+ }
87
127
  }
88
128
 
89
129
  export function PageSectionEditor({
@@ -109,21 +149,84 @@ export function PageSectionEditor({
109
149
  fieldPath: string
110
150
  } | null>(null)
111
151
  const [viewport, setViewport] = useState<ViewportId>(DEFAULT_VIEWPORT)
152
+ const [zoom, setZoom] = useState<Zoom>('fit')
153
+ /**
154
+ * Live audit reported by the SEO pane, built from unsaved form state. Null
155
+ * until that pane has mounted — `audit` below falls back to the saved-record
156
+ * audit so the header chip and tab dot always have a number.
157
+ */
158
+ const [liveAudit, setLiveAudit] = useState<PageAuditResult | null>(null)
159
+ /** Scale the canvas measured — reported upward so the zoom chip shows a real %. */
160
+ const [canvasScale, setCanvasScale] = useState(1)
161
+ /**
162
+ * The live canvas surface, for the reflow guard to clone. State rather than a
163
+ * ref because the context value has to update when it mounts — the canvas
164
+ * unmounts whenever the site-preview frame takes over.
165
+ */
166
+ const [canvasEl, setCanvasEl] = useState<HTMLElement | null>(null)
167
+ /**
168
+ * Section content as it is on the live site. Captured whenever the editor
169
+ * takes delivery of a PUBLISHED page — on load, and after each save and
170
+ * publish, both of which write straight through to the live content.
171
+ */
172
+ const [baseline, setBaseline] = useState<ReflowBaseline | null>(null)
173
+ /**
174
+ * Path as it is on the live site, captured with the reflow baseline and for
175
+ * the same reason: a slug change only breaks links if something is already
176
+ * live at the old address.
177
+ */
178
+ const [publishedPath, setPublishedPath] = useState<string | null>(null)
179
+ const [directives, setDirectives] = useState<RobotsPermissions>(DEFAULT_DIRECTIVES)
180
+ /**
181
+ * Defaults ON. The editor changing a live page's slug almost always wants the
182
+ * redirect, and the cost of an unwanted one is a rule they can delete, while
183
+ * the cost of a missing one is 404s discovered by visitors.
184
+ */
185
+ const [redirectOnPublish, setRedirectOnPublish] = useState(true)
186
+
187
+ // The reflow guard's inputs, gathered in one place. Memoised because a new
188
+ // object identity on every render would restart the debounce in every guarded
189
+ // field's effect — the measurement would then never settle while typing,
190
+ // which is precisely when it is wanted.
191
+ const reflowContext = useMemo(
192
+ () => ({
193
+ canvas: canvasEl,
194
+ sectionId: selectedId,
195
+ baseline,
196
+ device: viewport,
197
+ onProof: setViewport,
198
+ }),
199
+ [canvasEl, selectedId, baseline, viewport],
200
+ )
201
+
202
+ /**
203
+ * Take delivery of server state.
204
+ *
205
+ * Every path that receives a fresh page from the API goes through here so the
206
+ * reflow baseline is re-captured with it. Saving a PUBLISHED page writes
207
+ * straight through to the live content — there is no draft/published split —
208
+ * so a save moves the baseline exactly as a publish does. Missing one would
209
+ * leave the guard comparing against content that is no longer on the site.
210
+ */
211
+ const acceptPage = useCallback((p: EditorPage) => {
212
+ setPage(p)
213
+ setBaseline(snapshotBaseline(p.sections, p.status))
214
+ setPublishedPath(p.status === 'PUBLISHED' ? p.path : null)
215
+ }, [])
112
216
  const [dirty, setDirty] = useState(false)
113
217
  const [saveState, setSaveState] = useState<SaveState>('idle')
114
218
  const [publishing, setPublishing] = useState(false)
115
219
 
116
- const [addOpen, setAddOpen] = useState(false)
117
220
  const [settingsOpen, setSettingsOpen] = useState(false)
118
- const [seoOpen, setSeoOpen] = useState(false)
221
+ /** Active rail tab. Replaces the old boolean `seoOpen` right-hand aside. */
222
+ const [railTab, setRailTab] = useState<RailTabId>('sections')
119
223
  const [historyOpen, setHistoryOpen] = useState(false)
120
- const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
121
224
  const [confirmPublish, setConfirmPublish] = useState(false)
122
225
  const [scheduleOpen, setScheduleOpen] = useState(false)
123
226
  const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
124
227
  const [validation, setValidation] = useState<ValidationResult | null>(null)
125
228
 
126
- const { canEdit, canPublish } = useMemo(() => deriveCan(session), [session])
229
+ const { canEdit, canPublish, canCreateRedirect } = useMemo(() => deriveCan(session), [session])
127
230
  const customRenderers = useAdminSectionRenderers()
128
231
  const { siteUrl: publicSiteUrl } = usePublicSiteUrl(config?.seo?.siteUrl)
129
232
 
@@ -148,18 +251,80 @@ export function PageSectionEditor({
148
251
  )
149
252
  const publishReady = Boolean(page) && publishCheck.errors.length === 0
150
253
 
151
- const liveSeoScore = useMemo(() => {
152
- if (!page) return 0
153
- return computeLiveSeoScore({
154
- title: page.title,
155
- slug: page.slug,
156
- seoTitle: page.seoTitle,
157
- seoDescription: page.seoDescription,
158
- sections: page.sections,
159
- })
160
- }, [page])
161
254
  const publishDisabledReason = publishCheck.errors[0]
162
255
 
256
+ // Saved-record audit, available from first paint. Both this and the pane's
257
+ // live audit go through `buildPageAuditInput`, so they can differ only in
258
+ // saved-versus-unsaved metadata — never in how a check is decided.
259
+ const {
260
+ audit: savedAudit,
261
+ record: seoRecord,
262
+ reload: reloadAudit,
263
+ } = usePageAudit({
264
+ entityType: 'page',
265
+ entityId: page?.id ?? null,
266
+ sections: page?.sections ?? [],
267
+ slug: page?.slug ?? '',
268
+ fallback: { title: page?.seoTitle, description: page?.seoDescription },
269
+ })
270
+
271
+ // Seed directives from the saved record. Keyed on the record's identity, not
272
+ // on its fields — re-running on every field change would discard whatever the
273
+ // editor had just toggled and not yet saved.
274
+ useEffect(() => {
275
+ if (!seoRecord) return
276
+ setDirectives(
277
+ directivesFromData({
278
+ noindex: seoRecord.noindex,
279
+ nofollow: seoRecord.nofollow,
280
+ nosnippet: seoRecord.nosnippet,
281
+ noarchive: seoRecord.noarchive,
282
+ maxImagePreview: seoRecord.maxImagePreview,
283
+ }),
284
+ )
285
+ }, [seoRecord?.id])
286
+
287
+ // Unsaved edits win: while the SEO pane is open its number is the truthful
288
+ // one, and every surface reads this single value.
289
+ const audit = liveAudit ?? savedAudit
290
+
291
+ // Content the SEO pane analyses. Hoisted out of the old aside wrapper so the
292
+ // rail can render the pane directly — the aside only ever existed to supply
293
+ // a resizable container, which the rail now is.
294
+ const seoContentHtml = useMemo(
295
+ () => buildEditorContentHtml(page?.sections ?? []),
296
+ [page?.sections],
297
+ )
298
+
299
+ const railTabs = useMemo<RailTab[]>(
300
+ () => [
301
+ {
302
+ id: 'sections',
303
+ label: 'Sections',
304
+ title: 'Page content',
305
+ count: page?.sections.length ?? 0,
306
+ },
307
+ {
308
+ id: 'seo',
309
+ label: 'SEO & GEO',
310
+ title: 'Search & AI readiness',
311
+ // The weaker of the two: a page can be perfectly crawlable and still
312
+ // invisible to answer engines, and a dot that averaged them would hide
313
+ // whichever half is failing. `null` means a gate is off, which the tab
314
+ // reports as unrated rather than as a low score.
315
+ score: weakestAuditScore(audit),
316
+ },
317
+ {
318
+ id: 'page',
319
+ label: 'Page',
320
+ title: 'Page settings',
321
+ },
322
+ ],
323
+ [page?.sections.length, audit],
324
+ )
325
+
326
+ const activeRailTab = railTabs.find((tab) => tab.id === railTab) ?? railTabs[0]!
327
+
163
328
  // ─── Load ──────────────────────────────────────────────────────────
164
329
  useEffect(() => {
165
330
  let cancelled = false
@@ -175,7 +340,7 @@ export function PageSectionEditor({
175
340
  }
176
341
 
177
342
  if (!documentId) {
178
- setPage(emptyPage(initialParentId ?? null))
343
+ acceptPage(emptyPage(initialParentId ?? null))
179
344
  setLoading(false)
180
345
  // Start a brand-new page the way WordPress does: name it first. The
181
346
  // settings modal opens with the title focused and the slug/path
@@ -187,7 +352,7 @@ export function PageSectionEditor({
187
352
  fetchPageForEditor(documentId)
188
353
  .then((p) => {
189
354
  if (cancelled) return
190
- setPage(p)
355
+ acceptPage(p)
191
356
  // Default to a clean canvas (no selection / no inspector) to match
192
357
  // the editor's default composition; the inspector opens only once
193
358
  // the user picks a section to edit.
@@ -218,38 +383,11 @@ export function PageSectionEditor({
218
383
  [],
219
384
  )
220
385
 
221
- const handleAdd = useCallback(
222
- (type: SectionTypeId) => {
223
- const section = createSection(type)
224
- mutateSections((s) => addSection(s, section))
225
- setSelectedId(section.id)
226
- setAddOpen(false)
227
- },
228
- [mutateSections],
229
- )
230
-
231
- const handleReorder = useCallback(
232
- (orderedIds: string[]) => mutateSections((s) => reorderSections(s, orderedIds)),
233
- [mutateSections],
234
- )
235
-
236
386
  const handleToggleVisibility = useCallback(
237
387
  (id: string) => mutateSections((s) => toggleSectionVisibility(s, id)),
238
388
  [mutateSections],
239
389
  )
240
390
 
241
- const handleDuplicate = useCallback(
242
- (id: string) => mutateSections((s) => duplicateSection(s, id)),
243
- [mutateSections],
244
- )
245
-
246
- const confirmDelete = useCallback(() => {
247
- if (!deleteTargetId) return
248
- mutateSections((s) => removeSection(s, deleteTargetId))
249
- setSelectedId((cur) => (cur === deleteTargetId ? null : cur))
250
- setDeleteTargetId(null)
251
- }, [deleteTargetId, mutateSections])
252
-
253
391
  const handleContentChange = useCallback(
254
392
  (patch: Record<string, unknown>) => {
255
393
  if (!selectedId) return
@@ -282,6 +420,35 @@ export function PageSectionEditor({
282
420
  }, [])
283
421
 
284
422
  // ─── Save / publish ──────────────────────────────────────────────────
423
+ /**
424
+ * Persist robots directives. They live on the SEO record, not the document
425
+ * body, so they need their own write — and it has to happen on both save and
426
+ * publish, or a toggle would appear to stick until the next reload.
427
+ *
428
+ * Reported on failure rather than swallowed: a directive that silently failed
429
+ * to save is the same defect as one that never reached the head.
430
+ */
431
+ const saveDirectives = useCallback(
432
+ async (pageId: string) => {
433
+ const { error } = await updateSeoFields('page', pageId, {
434
+ noindex: !directives.index,
435
+ nofollow: !directives.follow,
436
+ nosnippet: !directives.snippet,
437
+ noarchive: !directives.archive,
438
+ maxImagePreview: directives.maxImagePreview,
439
+ })
440
+ if (error) {
441
+ toast.error(`Search directives were not saved: ${error}`)
442
+ return false
443
+ }
444
+ // The header chip and rail dot read the saved SEO record. Without a
445
+ // reload they keep the pre-save score after noindex/nosnippet flips.
446
+ await reloadAudit()
447
+ return true
448
+ },
449
+ [directives, reloadAudit],
450
+ )
451
+
285
452
  const handleSaveDraft = useCallback(async (): Promise<boolean> => {
286
453
  if (!page) return false
287
454
  const result = validatePage(page, false)
@@ -294,7 +461,8 @@ export function PageSectionEditor({
294
461
  setSaveState('saving')
295
462
  try {
296
463
  const saved = await savePageDraft(page)
297
- setPage(saved)
464
+ if (saved.id) await saveDirectives(saved.id)
465
+ acceptPage(saved)
298
466
  setDirty(false)
299
467
  setSaveState('saved')
300
468
  setValidation(null)
@@ -306,7 +474,7 @@ export function PageSectionEditor({
306
474
  toast.error(err instanceof Error ? err.message : 'Failed to save draft')
307
475
  return false
308
476
  }
309
- }, [page, documentId, onNavigate])
477
+ }, [page, documentId, onNavigate, saveDirectives, acceptPage])
310
478
 
311
479
  // Inline branded preview — Next builder canvas (same origin as admin), not
312
480
  // the Astro public apex. "View on site" still uses publicSiteUrl below.
@@ -375,8 +543,27 @@ export function PageSectionEditor({
375
543
  if (!page) return
376
544
  setPublishing(true)
377
545
  try {
546
+ // The move has to be read BEFORE publishing — `acceptPage` moves the
547
+ // published path to the new one, after which there is nothing to detect.
548
+ const move =
549
+ redirectOnPublish && canCreateRedirect ? pendingSlugChange(publishedPath, page.path) : null
550
+
378
551
  const published = await publishPage(page)
379
- setPage(published)
552
+ if (published.id) await saveDirectives(published.id)
553
+
554
+ if (move) {
555
+ const { error } = await createPublishRedirect(move)
556
+ if (error) {
557
+ // Loud, and naming both paths: the page has already moved, so a
558
+ // silently-failed redirect leaves exactly the broken links the switch
559
+ // promised to prevent.
560
+ toast.error(`Published, but the redirect ${move.from} → ${move.to} failed: ${error}`)
561
+ } else {
562
+ toast.success(`Redirect created: ${move.from} → ${move.to}`)
563
+ }
564
+ }
565
+
566
+ acceptPage(published)
380
567
  setDirty(false)
381
568
  setSaveState('saved')
382
569
  setValidation(null)
@@ -387,7 +574,41 @@ export function PageSectionEditor({
387
574
  } finally {
388
575
  setPublishing(false)
389
576
  }
390
- }, [page, documentId, onNavigate])
577
+ }, [
578
+ page,
579
+ documentId,
580
+ onNavigate,
581
+ saveDirectives,
582
+ redirectOnPublish,
583
+ canCreateRedirect,
584
+ publishedPath,
585
+ acceptPage,
586
+ ])
587
+
588
+ const handleDuplicate = useCallback(async () => {
589
+ if (!page?.id) return
590
+ const { id, error } = await duplicatePage(page.id)
591
+ if (error || !id) {
592
+ toast.error(error ?? 'Could not duplicate the page')
593
+ return
594
+ }
595
+ toast.success('Page duplicated')
596
+ // Navigate to the copy rather than staying put. The duplicate is a draft
597
+ // named "(Copy)", and leaving the editor on the original makes it look as
598
+ // though nothing happened.
599
+ onNavigate(`/pages/${id}/edit`)
600
+ }, [page?.id, onNavigate])
601
+
602
+ const handleTrash = useCallback(async () => {
603
+ if (!page?.id) return
604
+ const { error } = await deletePage(page.id)
605
+ if (error) {
606
+ toast.error(error)
607
+ return
608
+ }
609
+ toast.success('Page moved to trash')
610
+ onNavigate('/pages')
611
+ }, [page?.id, onNavigate])
391
612
 
392
613
  // After a version restore the server has already written the restored
393
614
  // data — refetch so the editor reflects it instead of the stale state.
@@ -395,9 +616,9 @@ export function PageSectionEditor({
395
616
  if (!page?.id) return
396
617
  fetchPageForEditor(page.id)
397
618
  .then((p) => {
398
- setPage(p)
619
+ acceptPage(p)
399
620
  setSelectedId(null)
400
- setSeoOpen(false)
621
+ setRailTab('sections')
401
622
  setDirty(false)
402
623
  setSaveState('idle')
403
624
  setValidation(null)
@@ -408,23 +629,29 @@ export function PageSectionEditor({
408
629
  }, [page?.id])
409
630
 
410
631
  const handleSeoSaved = useCallback(() => {
632
+ reloadAudit()
411
633
  if (!page?.id) return
412
634
  fetchPageForEditor(page.id)
413
- .then((p) => setPage(p))
635
+ .then((p) => acceptPage(p))
414
636
  .catch(() => {
415
637
  /* SEO API saved; editor meta will sync on next full load */
416
638
  })
417
- }, [page?.id])
639
+ }, [page?.id, reloadAudit])
418
640
 
641
+ // The header's SEO chip is a shortcut to the rail's SEO tab. Toggling back
642
+ // returns to Sections rather than leaving the rail on a tab the chip no
643
+ // longer appears to be showing.
419
644
  const handleToggleSeo = useCallback(() => {
420
- setSeoOpen((open) => {
421
- if (!open) setSelectedId(null)
422
- return !open
645
+ setRailTab((tab) => {
646
+ if (tab !== 'seo') setSelectedId(null)
647
+ return tab === 'seo' ? 'sections' : 'seo'
423
648
  })
424
649
  }, [])
425
650
 
426
651
  const handleSelectSection = useCallback((id: string | null, fieldPath?: string) => {
427
- setSeoOpen(false)
652
+ // Selecting on the canvas is a request to edit that section — show the
653
+ // outline it belongs to, whichever tab was open.
654
+ setRailTab('sections')
428
655
  setSelectedId(id)
429
656
  setPendingFocusPath(fieldPath ?? null)
430
657
  }, [])
@@ -620,6 +847,11 @@ export function PageSectionEditor({
620
847
  publishDisabledReason={publishDisabledReason}
621
848
  viewport={viewport}
622
849
  onViewport={setViewport}
850
+ {...(sitePreview.active
851
+ ? // The site preview is an iframe of the real front end, not a
852
+ // scaled canvas — a zoom percentage there would be a lie.
853
+ {}
854
+ : { zoom, canvasScale, onZoom: setZoom })}
623
855
  onBack={() => guardedNavigate('/pages')}
624
856
  onPreview={handlePreview}
625
857
  onViewOnSite={page.id ? handleViewOnSite : undefined}
@@ -628,8 +860,14 @@ export function PageSectionEditor({
628
860
  onHistory={page.id ? () => setHistoryOpen(true) : undefined}
629
861
  onOpenSettings={() => setSettingsOpen(true)}
630
862
  onOpenSeo={handleToggleSeo}
631
- seoOpen={seoOpen}
632
- seoScore={liveSeoScore}
863
+ seoOpen={railTab === 'seo'}
864
+ // The audit's SEO score, banded by the audit's own scale. Previously
865
+ // this chip showed `computeLiveSeoScore` — a different model — so the
866
+ // header read 53 while the rings beside it read 0 and 57.
867
+ seoScore={audit?.seo.score ?? undefined}
868
+ seoScoreBand={
869
+ typeof audit?.seo.score === 'number' ? scoreBand(audit.seo.score) : undefined
870
+ }
633
871
  onSaveDraft={handleSaveDraft}
634
872
  onPublish={requestPublish}
635
873
  onSchedule={page.id ? requestSchedule : undefined}
@@ -687,78 +925,121 @@ export function PageSectionEditor({
687
925
  />
688
926
  )}
689
927
 
690
- <div className="relative flex flex-1 overflow-hidden">
691
- <SectionsPanel
692
- sections={page.sections}
693
- selectedId={selectedId}
694
- canEdit={effectiveCanEdit}
695
- onSelect={handleSelectSection}
696
- onReorder={handleReorder}
697
- onToggleVisibility={handleToggleVisibility}
698
- onDuplicate={handleDuplicate}
699
- onRequestDelete={(id) => setDeleteTargetId(id)}
700
- onAdd={() => setAddOpen(true)}
701
- />
702
-
703
- <div className="flex-1 overflow-hidden">
704
- {sitePreview.active ? (
705
- <SitePreviewFrame
706
- src={sitePreview.url}
707
- viewport={viewport}
708
- reloadKey={sitePreview.reloadKey}
709
- title={`Site preview: ${page.title || 'Untitled page'}`}
710
- selectedId={selectedId}
711
- sections={page.sections}
712
- suppressSectionsEcho={suppressSectionsEcho}
713
- onSelectSection={handleSelectSection}
714
- onCanvasEdit={handleCanvasEdit}
715
- onCanvasRequestMedia={handleCanvasRequestMedia}
716
- />
717
- ) : (
718
- <EditorCanvas
719
- sections={page.sections}
720
- selectedId={selectedId}
721
- viewport={viewport}
722
- onSelect={handleSelectSection}
723
- onAddSection={() => setAddOpen(true)}
724
- structuralTypes={structuralTypes}
725
- sitePreviewAvailable={sitePreviewAvailable && !sitePreview.active}
928
+ <ReflowContext.Provider value={reflowContext}>
929
+ <div className="relative flex flex-1 overflow-hidden">
930
+ <EditorRail collapsedLabel={activeRailTab.label}>
931
+ {({ width, dragging, collapse }) => (
932
+ <>
933
+ <EditorRailTabs tabs={railTabs} active={railTab} onChange={setRailTab} />
934
+ <RailHeader
935
+ title={activeRailTab.title}
936
+ width={width}
937
+ dragging={dragging}
938
+ onCollapse={collapse}
939
+ />
940
+
941
+ <div
942
+ role="tabpanel"
943
+ id={`rail-panel-${railTab}`}
944
+ aria-labelledby={`rail-tab-${railTab}`}
945
+ className="flex min-h-0 flex-1 flex-col"
946
+ >
947
+ {railTab === 'sections' ? (
948
+ <SectionsList
949
+ sections={page.sections}
950
+ selectedId={selectedId}
951
+ canEdit={effectiveCanEdit}
952
+ onSelect={handleSelectSection}
953
+ onToggleVisibility={handleToggleVisibility}
954
+ />
955
+ ) : railTab === 'page' ? (
956
+ <div className="min-h-0 flex-1 overflow-y-auto">
957
+ <PageTab
958
+ page={page}
959
+ canEdit={effectiveCanEdit}
960
+ publishedPath={publishedPath}
961
+ siteUrl={publicSiteUrl}
962
+ directives={directives}
963
+ onDirectivesChange={(next) => {
964
+ setDirectives(next)
965
+ setDirty(true)
966
+ }}
967
+ redirectOnPublish={redirectOnPublish}
968
+ onRedirectOnPublishChange={setRedirectOnPublish}
969
+ canCreateRedirect={canCreateRedirect}
970
+ onPageChange={handlePageSettings}
971
+ onDuplicate={handleDuplicate}
972
+ onTrash={handleTrash}
973
+ schemaType={seoRecord?.structuredDataType ?? undefined}
974
+ brandName={config?.seo?.siteName ?? undefined}
975
+ />
976
+ </div>
977
+ ) : (
978
+ <SeoEditorPane
979
+ entityType="page"
980
+ entityId={page.id}
981
+ slug={page.slug}
982
+ auditSections={page.sections}
983
+ onAuditChange={setLiveAudit}
984
+ contentHtml={seoContentHtml}
985
+ draftMeta={{
986
+ seoTitle: page.seoTitle,
987
+ seoDescription: page.seoDescription,
988
+ }}
989
+ onDraftChange={handlePageSettings}
990
+ onClose={() => setRailTab('sections')}
991
+ onSaved={handleSeoSaved}
992
+ />
993
+ )}
994
+ </div>
995
+ </>
996
+ )}
997
+ </EditorRail>
998
+
999
+ <div className="flex-1 overflow-hidden">
1000
+ {sitePreview.active ? (
1001
+ <SitePreviewFrame
1002
+ src={sitePreview.url}
1003
+ viewport={viewport}
1004
+ reloadKey={sitePreview.reloadKey}
1005
+ title={`Site preview: ${page.title || 'Untitled page'}`}
1006
+ selectedId={selectedId}
1007
+ sections={page.sections}
1008
+ suppressSectionsEcho={suppressSectionsEcho}
1009
+ onSelectSection={handleSelectSection}
1010
+ onCanvasEdit={handleCanvasEdit}
1011
+ onCanvasRequestMedia={handleCanvasRequestMedia}
1012
+ />
1013
+ ) : (
1014
+ <EditorCanvas
1015
+ sections={page.sections}
1016
+ selectedId={selectedId}
1017
+ breakpoint={viewport}
1018
+ zoom={zoom}
1019
+ onScaleChange={setCanvasScale}
1020
+ onCanvasElement={setCanvasEl}
1021
+ onSelect={handleSelectSection}
1022
+ structuralTypes={structuralTypes}
1023
+ sitePreviewAvailable={sitePreviewAvailable && !sitePreview.active}
1024
+ />
1025
+ )}
1026
+ </div>
1027
+
1028
+ {selected && railTab === 'sections' && (
1029
+ <SectionInspector
1030
+ section={selected}
1031
+ canEdit={effectiveCanEdit}
1032
+ onClose={() => setSelectedId(null)}
1033
+ onContentChange={handleContentChange}
1034
+ onSettingsChange={handleSettingsChange}
1035
+ onMetaChange={handleMetaChange}
1036
+ onToggleVisibility={() => handleToggleVisibility(selected.id)}
726
1037
  />
727
1038
  )}
728
1039
  </div>
729
-
730
- {selected && !seoOpen && (
731
- <SectionInspector
732
- section={selected}
733
- canEdit={effectiveCanEdit}
734
- onClose={() => setSelectedId(null)}
735
- onContentChange={handleContentChange}
736
- onSettingsChange={handleSettingsChange}
737
- onMetaChange={handleMetaChange}
738
- onToggleVisibility={() => handleToggleVisibility(selected.id)}
739
- />
740
- )}
741
-
742
- {seoOpen && (
743
- <EditorSeoAside
744
- entityType="page"
745
- entityId={page.id}
746
- title={page.title}
747
- slug={page.slug}
748
- seoTitle={page.seoTitle}
749
- seoDescription={page.seoDescription}
750
- sections={page.sections}
751
- canEdit={effectiveCanEdit}
752
- onClose={() => setSeoOpen(false)}
753
- onDraftChange={handlePageSettings}
754
- onSaved={handleSeoSaved}
755
- />
756
- )}
757
- </div>
1040
+ </ReflowContext.Provider>
758
1041
  </div>
759
1042
 
760
- <AddSectionModal open={addOpen} onClose={() => setAddOpen(false)} onAdd={handleAdd} />
761
-
762
1043
  {page.id && (
763
1044
  <VersionHistory
764
1045
  collectionSlug="pages"
@@ -777,16 +1058,6 @@ export function PageSectionEditor({
777
1058
  onSave={handlePageSettings}
778
1059
  />
779
1060
 
780
- <ConfirmDialog
781
- open={deleteTargetId !== null}
782
- onClose={() => setDeleteTargetId(null)}
783
- onConfirm={confirmDelete}
784
- title="Delete section?"
785
- description="This removes the section from the page. You can undo by leaving without saving."
786
- confirmLabel="Delete"
787
- destructive
788
- />
789
-
790
1061
  <ConfirmDialog
791
1062
  open={confirmPublish}
792
1063
  onClose={() => setConfirmPublish(false)}