@prenta/admin 0.92.0 → 0.94.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 (425) hide show
  1. package/CHANGELOG.md +663 -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__/lib/version-diff.test.d.ts +2 -0
  74. package/dist/__tests__/lib/version-diff.test.d.ts.map +1 -0
  75. package/dist/__tests__/lib/version-diff.test.js +230 -0
  76. package/dist/__tests__/lib/version-diff.test.js.map +1 -0
  77. package/dist/__tests__/lib/version-history.test.d.ts +2 -0
  78. package/dist/__tests__/lib/version-history.test.d.ts.map +1 -0
  79. package/dist/__tests__/lib/version-history.test.js +125 -0
  80. package/dist/__tests__/lib/version-history.test.js.map +1 -0
  81. package/dist/__tests__/styles/canvas-container-queries.test.d.ts +2 -0
  82. package/dist/__tests__/styles/canvas-container-queries.test.d.ts.map +1 -0
  83. package/dist/__tests__/styles/canvas-container-queries.test.js +87 -0
  84. package/dist/__tests__/styles/canvas-container-queries.test.js.map +1 -0
  85. package/dist/__tests__/styles/packaging.test.d.ts +2 -0
  86. package/dist/__tests__/styles/packaging.test.d.ts.map +1 -0
  87. package/dist/__tests__/styles/packaging.test.js +31 -0
  88. package/dist/__tests__/styles/packaging.test.js.map +1 -0
  89. package/dist/__tests__/styles/responsive-layer.test.d.ts +2 -0
  90. package/dist/__tests__/styles/responsive-layer.test.d.ts.map +1 -0
  91. package/dist/__tests__/styles/responsive-layer.test.js +127 -0
  92. package/dist/__tests__/styles/responsive-layer.test.js.map +1 -0
  93. package/dist/__tests__/styles/tokens-drift.test.d.ts +2 -0
  94. package/dist/__tests__/styles/tokens-drift.test.d.ts.map +1 -0
  95. package/dist/__tests__/styles/tokens-drift.test.js +92 -0
  96. package/dist/__tests__/styles/tokens-drift.test.js.map +1 -0
  97. package/dist/__tests__/views/admin-renderer-compat.test.d.ts +2 -0
  98. package/dist/__tests__/views/admin-renderer-compat.test.d.ts.map +1 -0
  99. package/dist/__tests__/views/admin-renderer-compat.test.js +43 -0
  100. package/dist/__tests__/views/admin-renderer-compat.test.js.map +1 -0
  101. package/dist/__tests__/views/admin-section-renderers.test.js +52 -4
  102. package/dist/__tests__/views/admin-section-renderers.test.js.map +1 -1
  103. package/dist/__tests__/views/canvas-review-mode.render.test.d.ts +2 -0
  104. package/dist/__tests__/views/canvas-review-mode.render.test.d.ts.map +1 -0
  105. package/dist/__tests__/views/canvas-review-mode.render.test.js +70 -0
  106. package/dist/__tests__/views/canvas-review-mode.render.test.js.map +1 -0
  107. package/dist/__tests__/views/editor-top-bar.render.test.js +30 -1
  108. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  109. package/dist/__tests__/views/page-tab.render.test.d.ts +2 -0
  110. package/dist/__tests__/views/page-tab.render.test.d.ts.map +1 -0
  111. package/dist/__tests__/views/page-tab.render.test.js +151 -0
  112. package/dist/__tests__/views/page-tab.render.test.js.map +1 -0
  113. package/dist/__tests__/views/section-class-parity.test.d.ts +2 -0
  114. package/dist/__tests__/views/section-class-parity.test.d.ts.map +1 -0
  115. package/dist/__tests__/views/section-class-parity.test.js +81 -0
  116. package/dist/__tests__/views/section-class-parity.test.js.map +1 -0
  117. package/dist/__tests__/views/sections-panel-design-owned.render.test.d.ts +2 -0
  118. package/dist/__tests__/views/sections-panel-design-owned.render.test.d.ts.map +1 -0
  119. package/dist/__tests__/views/sections-panel-design-owned.render.test.js +86 -0
  120. package/dist/__tests__/views/sections-panel-design-owned.render.test.js.map +1 -0
  121. package/dist/__tests__/views/version-history-panel.render.test.d.ts +2 -0
  122. package/dist/__tests__/views/version-history-panel.render.test.d.ts.map +1 -0
  123. package/dist/__tests__/views/version-history-panel.render.test.js +153 -0
  124. package/dist/__tests__/views/version-history-panel.render.test.js.map +1 -0
  125. package/dist/components/BreakpointPicker.d.ts +18 -0
  126. package/dist/components/BreakpointPicker.d.ts.map +1 -0
  127. package/dist/components/BreakpointPicker.js +31 -0
  128. package/dist/components/BreakpointPicker.js.map +1 -0
  129. package/dist/components/CanvasEditTip.d.ts +6 -0
  130. package/dist/components/CanvasEditTip.d.ts.map +1 -0
  131. package/dist/components/CanvasEditTip.js +22 -0
  132. package/dist/components/CanvasEditTip.js.map +1 -0
  133. package/dist/components/EditorRail.d.ts +36 -0
  134. package/dist/components/EditorRail.d.ts.map +1 -0
  135. package/dist/components/EditorRail.js +104 -0
  136. package/dist/components/EditorRail.js.map +1 -0
  137. package/dist/components/EditorRailTabs.d.ts +32 -0
  138. package/dist/components/EditorRailTabs.d.ts.map +1 -0
  139. package/dist/components/EditorRailTabs.js +45 -0
  140. package/dist/components/EditorRailTabs.js.map +1 -0
  141. package/dist/components/MediaBlock.d.ts +49 -0
  142. package/dist/components/MediaBlock.d.ts.map +1 -0
  143. package/dist/components/MediaBlock.js +50 -0
  144. package/dist/components/MediaBlock.js.map +1 -0
  145. package/dist/components/ReflowMeter.d.ts +28 -0
  146. package/dist/components/ReflowMeter.d.ts.map +1 -0
  147. package/dist/components/ReflowMeter.js +42 -0
  148. package/dist/components/ReflowMeter.js.map +1 -0
  149. package/dist/components/ZoomChip.d.ts +17 -0
  150. package/dist/components/ZoomChip.d.ts.map +1 -0
  151. package/dist/components/ZoomChip.js +17 -0
  152. package/dist/components/ZoomChip.js.map +1 -0
  153. package/dist/components/breakpoint-icons.d.ts +3 -0
  154. package/dist/components/breakpoint-icons.d.ts.map +1 -0
  155. package/dist/components/breakpoint-icons.js +16 -0
  156. package/dist/components/breakpoint-icons.js.map +1 -0
  157. package/dist/components/page-tab/AuthorPicker.d.ts +31 -0
  158. package/dist/components/page-tab/AuthorPicker.d.ts.map +1 -0
  159. package/dist/components/page-tab/AuthorPicker.js +95 -0
  160. package/dist/components/page-tab/AuthorPicker.js.map +1 -0
  161. package/dist/components/page-tab/DirectiveSwitch.d.ts +38 -0
  162. package/dist/components/page-tab/DirectiveSwitch.d.ts.map +1 -0
  163. package/dist/components/page-tab/DirectiveSwitch.js +33 -0
  164. package/dist/components/page-tab/DirectiveSwitch.js.map +1 -0
  165. package/dist/components/seo/EditorSeoScoreBadge.d.ts +8 -1
  166. package/dist/components/seo/EditorSeoScoreBadge.d.ts.map +1 -1
  167. package/dist/components/seo/EditorSeoScoreBadge.js +2 -2
  168. package/dist/components/seo/EditorSeoScoreBadge.js.map +1 -1
  169. package/dist/components/seo/PageAuditPanel.d.ts +14 -0
  170. package/dist/components/seo/PageAuditPanel.d.ts.map +1 -0
  171. package/dist/components/seo/PageAuditPanel.js +59 -0
  172. package/dist/components/seo/PageAuditPanel.js.map +1 -0
  173. package/dist/components/seo/SeoCharCount.d.ts +19 -7
  174. package/dist/components/seo/SeoCharCount.d.ts.map +1 -1
  175. package/dist/components/seo/SeoCharCount.js +30 -16
  176. package/dist/components/seo/SeoCharCount.js.map +1 -1
  177. package/dist/components/seo/SeoEditorPane.d.ts +15 -1
  178. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  179. package/dist/components/seo/SeoEditorPane.js +118 -21
  180. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  181. package/dist/components/seo/SerpPreview.d.ts +20 -0
  182. package/dist/components/seo/SerpPreview.d.ts.map +1 -0
  183. package/dist/components/seo/SerpPreview.js +6 -0
  184. package/dist/components/seo/SerpPreview.js.map +1 -0
  185. package/dist/hooks/usePageAudit.d.ts +41 -0
  186. package/dist/hooks/usePageAudit.d.ts.map +1 -0
  187. package/dist/hooks/usePageAudit.js +68 -0
  188. package/dist/hooks/usePageAudit.js.map +1 -0
  189. package/dist/hooks/useReflowGuard.d.ts +47 -0
  190. package/dist/hooks/useReflowGuard.d.ts.map +1 -0
  191. package/dist/hooks/useReflowGuard.js +52 -0
  192. package/dist/hooks/useReflowGuard.js.map +1 -0
  193. package/dist/layout/primitives/AdminShell.d.ts.map +1 -1
  194. package/dist/layout/primitives/AdminShell.js +2 -2
  195. package/dist/layout/primitives/AdminShell.js.map +1 -1
  196. package/dist/lib/breakpoints.d.ts +51 -0
  197. package/dist/lib/breakpoints.d.ts.map +1 -0
  198. package/dist/lib/breakpoints.js +84 -0
  199. package/dist/lib/breakpoints.js.map +1 -0
  200. package/dist/lib/canvas-edit-chrome.d.ts +22 -0
  201. package/dist/lib/canvas-edit-chrome.d.ts.map +1 -0
  202. package/dist/lib/canvas-edit-chrome.js +81 -0
  203. package/dist/lib/canvas-edit-chrome.js.map +1 -0
  204. package/dist/lib/media-format.d.ts +55 -0
  205. package/dist/lib/media-format.d.ts.map +1 -0
  206. package/dist/lib/media-format.js +93 -0
  207. package/dist/lib/media-format.js.map +1 -0
  208. package/dist/lib/page-audit-input.d.ts +45 -0
  209. package/dist/lib/page-audit-input.d.ts.map +1 -0
  210. package/dist/lib/page-audit-input.js +53 -0
  211. package/dist/lib/page-audit-input.js.map +1 -0
  212. package/dist/lib/page-editor-service.d.ts +12 -0
  213. package/dist/lib/page-editor-service.d.ts.map +1 -1
  214. package/dist/lib/page-editor-service.js +6 -0
  215. package/dist/lib/page-editor-service.js.map +1 -1
  216. package/dist/lib/page-redirect.d.ts +39 -0
  217. package/dist/lib/page-redirect.d.ts.map +1 -0
  218. package/dist/lib/page-redirect.js +38 -0
  219. package/dist/lib/page-redirect.js.map +1 -0
  220. package/dist/lib/rail-width.d.ts +23 -0
  221. package/dist/lib/rail-width.d.ts.map +1 -0
  222. package/dist/lib/rail-width.js +59 -0
  223. package/dist/lib/rail-width.js.map +1 -0
  224. package/dist/lib/reflow-baseline.d.ts +45 -0
  225. package/dist/lib/reflow-baseline.d.ts.map +1 -0
  226. package/dist/lib/reflow-baseline.js +38 -0
  227. package/dist/lib/reflow-baseline.js.map +1 -0
  228. package/dist/lib/reflow-probe.d.ts +159 -0
  229. package/dist/lib/reflow-probe.d.ts.map +1 -0
  230. package/dist/lib/reflow-probe.js +228 -0
  231. package/dist/lib/reflow-probe.js.map +1 -0
  232. package/dist/lib/seo-service.d.ts +10 -0
  233. package/dist/lib/seo-service.d.ts.map +1 -1
  234. package/dist/lib/seo-service.js.map +1 -1
  235. package/dist/lib/version-diff.d.ts +50 -0
  236. package/dist/lib/version-diff.d.ts.map +1 -0
  237. package/dist/lib/version-diff.js +294 -0
  238. package/dist/lib/version-diff.js.map +1 -0
  239. package/dist/lib/version-history.d.ts +73 -0
  240. package/dist/lib/version-history.d.ts.map +1 -0
  241. package/dist/lib/version-history.js +126 -0
  242. package/dist/lib/version-history.js.map +1 -0
  243. package/dist/prenta-admin.css +1 -1
  244. package/dist/views/page-editor/CanvasMetaLine.d.ts +19 -0
  245. package/dist/views/page-editor/CanvasMetaLine.d.ts.map +1 -0
  246. package/dist/views/page-editor/CanvasMetaLine.js +23 -0
  247. package/dist/views/page-editor/CanvasMetaLine.js.map +1 -0
  248. package/dist/views/page-editor/EditorCanvas.d.ts +47 -8
  249. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  250. package/dist/views/page-editor/EditorCanvas.js +68 -34
  251. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  252. package/dist/views/page-editor/EditorTopBar.d.ts +21 -2
  253. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  254. package/dist/views/page-editor/EditorTopBar.js +13 -20
  255. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  256. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  257. package/dist/views/page-editor/PageSectionEditor.js +383 -78
  258. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  259. package/dist/views/page-editor/PageTab.d.ts +36 -0
  260. package/dist/views/page-editor/PageTab.d.ts.map +1 -0
  261. package/dist/views/page-editor/PageTab.js +65 -0
  262. package/dist/views/page-editor/PageTab.js.map +1 -0
  263. package/dist/views/page-editor/ReviewBar.d.ts +21 -0
  264. package/dist/views/page-editor/ReviewBar.d.ts.map +1 -0
  265. package/dist/views/page-editor/ReviewBar.js +22 -0
  266. package/dist/views/page-editor/ReviewBar.js.map +1 -0
  267. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  268. package/dist/views/page-editor/SectionInspector.js +64 -20
  269. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  270. package/dist/views/page-editor/SectionsPanel.d.ts +30 -5
  271. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  272. package/dist/views/page-editor/SectionsPanel.js +38 -7
  273. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  274. package/dist/views/page-editor/SitePreviewFrame.d.ts.map +1 -1
  275. package/dist/views/page-editor/SitePreviewFrame.js +3 -0
  276. package/dist/views/page-editor/SitePreviewFrame.js.map +1 -1
  277. package/dist/views/page-editor/VersionHistoryPanel.d.ts +29 -0
  278. package/dist/views/page-editor/VersionHistoryPanel.d.ts.map +1 -0
  279. package/dist/views/page-editor/VersionHistoryPanel.js +81 -0
  280. package/dist/views/page-editor/VersionHistoryPanel.js.map +1 -0
  281. package/dist/views/page-editor/reflow-context.d.ts +28 -0
  282. package/dist/views/page-editor/reflow-context.d.ts.map +1 -0
  283. package/dist/views/page-editor/reflow-context.js +14 -0
  284. package/dist/views/page-editor/reflow-context.js.map +1 -0
  285. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts.map +1 -1
  286. package/dist/views/page-editor/sections/ByTheNumbersSection.js +1 -1
  287. package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -1
  288. package/dist/views/page-editor/sections/CtaBannerSection.d.ts.map +1 -1
  289. package/dist/views/page-editor/sections/CtaBannerSection.js +1 -1
  290. package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -1
  291. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts.map +1 -1
  292. package/dist/views/page-editor/sections/FeatureHighlightSection.js +2 -2
  293. package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -1
  294. package/dist/views/page-editor/sections/GallerySection.d.ts.map +1 -1
  295. package/dist/views/page-editor/sections/GallerySection.js +7 -3
  296. package/dist/views/page-editor/sections/GallerySection.js.map +1 -1
  297. package/dist/views/page-editor/sections/HeroBannerSection.js +1 -1
  298. package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -1
  299. package/dist/views/page-editor/sections/MissionSection.d.ts.map +1 -1
  300. package/dist/views/page-editor/sections/MissionSection.js +1 -1
  301. package/dist/views/page-editor/sections/MissionSection.js.map +1 -1
  302. package/dist/views/page-editor/sections/PullQuoteSection.d.ts.map +1 -1
  303. package/dist/views/page-editor/sections/PullQuoteSection.js +1 -1
  304. package/dist/views/page-editor/sections/PullQuoteSection.js.map +1 -1
  305. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts.map +1 -1
  306. package/dist/views/page-editor/sections/RelatedPostsSection.js +1 -1
  307. package/dist/views/page-editor/sections/RelatedPostsSection.js.map +1 -1
  308. package/dist/views/page-editor/sections/SectionRenderer.d.ts +13 -1
  309. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  310. package/dist/views/page-editor/sections/SectionRenderer.js +66 -26
  311. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  312. package/dist/views/page-editor/sections/admin-renderers.d.ts +47 -15
  313. package/dist/views/page-editor/sections/admin-renderers.d.ts.map +1 -1
  314. package/dist/views/page-editor/sections/admin-renderers.js +27 -15
  315. package/dist/views/page-editor/sections/admin-renderers.js.map +1 -1
  316. package/dist/views/page-editor/sections/parts.d.ts +0 -1
  317. package/dist/views/page-editor/sections/parts.d.ts.map +1 -1
  318. package/dist/views/page-editor/sections/parts.js +41 -16
  319. package/dist/views/page-editor/sections/parts.js.map +1 -1
  320. package/dist/views/page-editor/viewports.d.ts +7 -14
  321. package/dist/views/page-editor/viewports.d.ts.map +1 -1
  322. package/dist/views/page-editor/viewports.js +7 -12
  323. package/dist/views/page-editor/viewports.js.map +1 -1
  324. package/dist/views/post-editor/PostEditorCanvas.d.ts +2 -1
  325. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  326. package/dist/views/post-editor/PostEditorCanvas.js +5 -2
  327. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  328. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  329. package/dist/views/post-editor/PostSectionEditor.js +2 -20
  330. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  331. package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -1
  332. package/dist/views/post-editor/PostTemplateEditor.js +4 -1
  333. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -1
  334. package/package.json +5 -3
  335. package/src/AdminRoot.tsx +27 -6
  336. package/src/__tests__/components/breakpoint-controls.render.test.tsx +57 -0
  337. package/src/__tests__/components/canvas-edit-tip.render.test.tsx +41 -0
  338. package/src/__tests__/components/editor-rail.render.test.tsx +192 -0
  339. package/src/__tests__/components/media-block.render.test.tsx +126 -0
  340. package/src/__tests__/components/page-audit-panel.render.test.tsx +64 -0
  341. package/src/__tests__/components/reflow-meter.render.test.tsx +144 -0
  342. package/src/__tests__/components/seo-char-count.test.tsx +26 -6
  343. package/src/__tests__/components/serp-preview.render.test.tsx +32 -0
  344. package/src/__tests__/lib/breakpoints.test.ts +84 -0
  345. package/src/__tests__/lib/canvas-edit-chrome.test.ts +56 -0
  346. package/src/__tests__/lib/media-format.test.ts +89 -0
  347. package/src/__tests__/lib/page-audit-input.test.ts +144 -0
  348. package/src/__tests__/lib/page-editor-service.test.ts +2 -0
  349. package/src/__tests__/lib/page-redirect.test.ts +29 -0
  350. package/src/__tests__/lib/rail-width.test.ts +86 -0
  351. package/src/__tests__/lib/reflow-baseline.test.ts +64 -0
  352. package/src/__tests__/lib/reflow-probe.test.ts +95 -0
  353. package/src/__tests__/lib/version-diff.test.ts +278 -0
  354. package/src/__tests__/lib/version-history.test.ts +166 -0
  355. package/src/__tests__/styles/canvas-container-queries.test.ts +103 -0
  356. package/src/__tests__/styles/packaging.test.ts +38 -0
  357. package/src/__tests__/styles/responsive-layer.test.ts +152 -0
  358. package/src/__tests__/styles/tokens-drift.test.ts +110 -0
  359. package/src/__tests__/views/admin-renderer-compat.test.ts +48 -0
  360. package/src/__tests__/views/admin-section-renderers.test.tsx +70 -4
  361. package/src/__tests__/views/canvas-review-mode.render.test.tsx +94 -0
  362. package/src/__tests__/views/editor-top-bar.render.test.tsx +43 -1
  363. package/src/__tests__/views/page-tab.render.test.tsx +226 -0
  364. package/src/__tests__/views/section-class-parity.test.ts +98 -0
  365. package/src/__tests__/views/sections-panel-design-owned.render.test.tsx +104 -0
  366. package/src/__tests__/views/version-history-panel.render.test.tsx +195 -0
  367. package/src/components/BreakpointPicker.tsx +114 -0
  368. package/src/components/CanvasEditTip.tsx +46 -0
  369. package/src/components/EditorRail.tsx +219 -0
  370. package/src/components/EditorRailTabs.tsx +117 -0
  371. package/src/components/MediaBlock.tsx +223 -0
  372. package/src/components/ReflowMeter.tsx +125 -0
  373. package/src/components/ZoomChip.tsx +39 -0
  374. package/src/components/breakpoint-icons.ts +17 -0
  375. package/src/components/page-tab/AuthorPicker.tsx +240 -0
  376. package/src/components/page-tab/DirectiveSwitch.tsx +89 -0
  377. package/src/components/seo/EditorSeoScoreBadge.tsx +9 -1
  378. package/src/components/seo/PageAuditPanel.tsx +192 -0
  379. package/src/components/seo/SeoCharCount.tsx +55 -24
  380. package/src/components/seo/SeoEditorPane.tsx +213 -31
  381. package/src/components/seo/SerpPreview.tsx +41 -0
  382. package/src/hooks/usePageAudit.ts +98 -0
  383. package/src/hooks/useReflowGuard.ts +96 -0
  384. package/src/layout/primitives/AdminShell.tsx +13 -1
  385. package/src/lib/breakpoints.ts +107 -0
  386. package/src/lib/canvas-edit-chrome.ts +82 -0
  387. package/src/lib/media-format.ts +99 -0
  388. package/src/lib/page-audit-input.ts +104 -0
  389. package/src/lib/page-editor-service.ts +18 -0
  390. package/src/lib/page-redirect.ts +60 -0
  391. package/src/lib/rail-width.ts +58 -0
  392. package/src/lib/reflow-baseline.ts +70 -0
  393. package/src/lib/reflow-probe.ts +299 -0
  394. package/src/lib/seo-service.ts +10 -0
  395. package/src/lib/version-diff.ts +376 -0
  396. package/src/lib/version-history.ts +192 -0
  397. package/src/styles/prenta-tokens.css +120 -42
  398. package/src/styles/responsive.css +281 -0
  399. package/src/styles/theme.css +138 -0
  400. package/src/views/page-editor/CanvasMetaLine.tsx +50 -0
  401. package/src/views/page-editor/EditorCanvas.tsx +140 -54
  402. package/src/views/page-editor/EditorTopBar.tsx +55 -51
  403. package/src/views/page-editor/PageSectionEditor.tsx +575 -185
  404. package/src/views/page-editor/PageTab.tsx +433 -0
  405. package/src/views/page-editor/ReviewBar.tsx +76 -0
  406. package/src/views/page-editor/SectionInspector.tsx +84 -42
  407. package/src/views/page-editor/SectionsPanel.tsx +282 -141
  408. package/src/views/page-editor/SitePreviewFrame.tsx +3 -0
  409. package/src/views/page-editor/VersionHistoryPanel.tsx +302 -0
  410. package/src/views/page-editor/reflow-context.ts +43 -0
  411. package/src/views/page-editor/sections/ByTheNumbersSection.tsx +3 -2
  412. package/src/views/page-editor/sections/CtaBannerSection.tsx +3 -1
  413. package/src/views/page-editor/sections/FeatureHighlightSection.tsx +3 -2
  414. package/src/views/page-editor/sections/GallerySection.tsx +7 -3
  415. package/src/views/page-editor/sections/HeroBannerSection.tsx +2 -2
  416. package/src/views/page-editor/sections/MissionSection.tsx +4 -2
  417. package/src/views/page-editor/sections/PullQuoteSection.tsx +3 -1
  418. package/src/views/page-editor/sections/RelatedPostsSection.tsx +2 -1
  419. package/src/views/page-editor/sections/SectionRenderer.tsx +115 -34
  420. package/src/views/page-editor/sections/admin-renderers.tsx +54 -15
  421. package/src/views/page-editor/sections/parts.tsx +46 -18
  422. package/src/views/page-editor/viewports.ts +13 -26
  423. package/src/views/post-editor/PostEditorCanvas.tsx +18 -11
  424. package/src/views/post-editor/PostSectionEditor.tsx +1 -56
  425. package/src/views/post-editor/PostTemplateEditor.tsx +3 -0
@@ -0,0 +1,299 @@
1
+ import { BREAKPOINTS, type BreakpointId } from './breakpoints.js'
2
+
3
+ /**
4
+ * Measures how a field's copy wraps at every breakpoint, by laying it out for
5
+ * real rather than estimating.
6
+ *
7
+ * ── Why not a class chain ─────────────────────────────────────────────────
8
+ *
9
+ * The design handoff specifies rebuilding each probe from a hardcoded list of
10
+ * wrapper classes per guarded field, and warns twice that a width cap outside
11
+ * that list makes the guard lie — at wide breakpoints only, so it looks correct
12
+ * on mobile. By the handoff's own account that list drifted twice.
13
+ *
14
+ * A chain is a second source of truth that has to track the renderers' markup
15
+ * by hand. Worse here: the prototype had semantic classes (`.pv-hero-h`), while
16
+ * these components are styled with Tailwind utility strings that Prettier's
17
+ * plugin reorders on save. And a chain written in the admin cannot describe a
18
+ * consumer's own registered renderer at all.
19
+ *
20
+ * So the probe measures the REAL element: the caller renders the actual section
21
+ * into a hidden host, and this module finds the field by its `data-cms-field`
22
+ * marker — the same attribute the click-to-edit bridge uses. There is nothing
23
+ * to keep in sync, and a renderer refactor cannot silently invalidate it.
24
+ *
25
+ * ── Why one host, resized ─────────────────────────────────────────────────
26
+ *
27
+ * Six widths are measured by resizing a single rendered copy, not by rendering
28
+ * six. Layout is recomputed either way; React reconciliation is not.
29
+ */
30
+
31
+ /** A field's wrap count at one breakpoint. */
32
+ export interface FieldLineCount {
33
+ id: BreakpointId
34
+ lines: number
35
+ }
36
+
37
+ /**
38
+ * Line count from an element's laid-out height.
39
+ *
40
+ * Falls back to `fontSize * 1.25` when `line-height` computes to `normal`,
41
+ * which it does for any element that never set one. Rounding rather than
42
+ * flooring: sub-pixel line heights accumulate, and 2.97 lines is three.
43
+ */
44
+ export function linesFromMetrics(height: number, lineHeight: number, fontSize: number): number {
45
+ const effective = Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : fontSize * 1.25
46
+ if (!Number.isFinite(effective) || effective <= 0) return 1
47
+ // An empty field still occupies one line's worth of intent.
48
+ return Math.max(1, Math.round(height / effective))
49
+ }
50
+
51
+ /**
52
+ * How many lines an element's text actually occupies.
53
+ *
54
+ * Counts LINE BOXES via a Range over the element's contents, deduplicated by
55
+ * their top edge. Dividing the element's height by its line-height — the
56
+ * obvious approach, and what the prototype does — counts padding as text: a
57
+ * button reading "Get Started" measured 2 lines, because its vertical padding
58
+ * came to roughly another line's worth of height. Every padded element was
59
+ * over-reported, which is most CTAs and every boxed heading.
60
+ *
61
+ * Rects are deduplicated by rounded top edge because a range spanning nested
62
+ * inline elements (a `<strong>` mid-sentence, an icon in a button) yields one
63
+ * rect per fragment, not per line.
64
+ *
65
+ * Falls back to the height ratio when the range produces nothing — an element
66
+ * whose content is a single replaced node, or a layout engine that gives no
67
+ * rects for empty text.
68
+ */
69
+ export function countLines(el: HTMLElement): number {
70
+ const doc = el.ownerDocument
71
+ try {
72
+ const range = doc.createRange()
73
+ range.selectNodeContents(el)
74
+ const rects = Array.from(range.getClientRects()).filter((r) => r.height > 0)
75
+ range.detach?.()
76
+ if (rects.length > 0) {
77
+ const tops = new Set(rects.map((r) => Math.round(r.top)))
78
+ return Math.max(1, tops.size)
79
+ }
80
+ } catch {
81
+ // No Range support (or a detached document) — fall through to metrics.
82
+ }
83
+
84
+ const cs = getComputedStyle(el)
85
+ return linesFromMetrics(
86
+ el.getBoundingClientRect().height,
87
+ Number.parseFloat(cs.lineHeight),
88
+ Number.parseFloat(cs.fontSize),
89
+ )
90
+ }
91
+
92
+ /**
93
+ * The off-screen host.
94
+ *
95
+ * `left: -99999px`, never `display: none` — a hidden element has no layout, so
96
+ * every measurement would be zero. `visibility: hidden` would keep layout but
97
+ * also keep the element in the accessibility tree; moving it off-screen and
98
+ * marking it `aria-hidden` keeps it out of both the viewport and the a11y tree
99
+ * while still being laid out.
100
+ *
101
+ * MOUNT IT INSIDE `.prenta-admin`, never on `document.body`. Type tokens and
102
+ * the font stack are scoped to that root, so a host outside it lays text out in
103
+ * the document's default font and every measurement is taken at the wrong
104
+ * metrics. Caught by `e2e/reflow-probe.spec.ts` on its first run: a button
105
+ * measured 127px on the canvas and 139px in a body-mounted probe.
106
+ *
107
+ * Use `mountProbeHost` rather than appending by hand.
108
+ */
109
+ export function createProbeHost(doc: Document = document): HTMLElement {
110
+ const host = doc.createElement('div')
111
+ host.setAttribute('aria-hidden', 'true')
112
+ host.dataset.reflowProbe = 'true'
113
+ Object.assign(host.style, {
114
+ position: 'fixed',
115
+ left: '-99999px',
116
+ top: '0',
117
+ // Never inherit the rail's width — the probe sets its own per breakpoint.
118
+ width: 'auto',
119
+ pointerEvents: 'none',
120
+ })
121
+ return host
122
+ }
123
+
124
+ /**
125
+ * Attach a probe host in the only place it measures correctly.
126
+ *
127
+ * Returns a detach function. Falls back to `document.body` only when no admin
128
+ * root exists (it always does in the app) — and callers should treat a
129
+ * fallback mount as untrustworthy rather than silently measuring.
130
+ */
131
+ export function mountProbeHost(
132
+ host: HTMLElement,
133
+ doc: Document = document,
134
+ ): { detach: () => void; scoped: boolean } {
135
+ const root = doc.querySelector('.prenta-admin')
136
+ const parent = root ?? doc.body
137
+ parent.appendChild(host)
138
+ return { detach: () => host.remove(), scoped: Boolean(root) }
139
+ }
140
+
141
+ /**
142
+ * Measure one field across every breakpoint.
143
+ *
144
+ * `container` must carry the same class chain the canvas uses
145
+ * (`prenta-canvas`, which supplies the query container). Its width is set per
146
+ * breakpoint, so it must not be width-constrained by a parent.
147
+ *
148
+ * `scope` narrows to one section and is REQUIRED whenever the container holds
149
+ * more than one, because `data-cms-field` paths are unique only within a
150
+ * section — every section has a `heading` and a `body`. Without it a whole page
151
+ * silently measures the first section's field and reports it for all of them.
152
+ * (That mistake is what `e2e/reflow-probe.spec.ts` caught on its second run.)
153
+ *
154
+ * Returns `null` when the field cannot be found — the canvas is rendering a
155
+ * component without `data-cms-field` markers, so there is nothing to measure.
156
+ * Reporting a line count from the wrong element would be worse than reporting
157
+ * none: an editor reads a number as a fact.
158
+ */
159
+ export function measureFieldAcrossBreakpoints(
160
+ container: HTMLElement,
161
+ fieldPath: string,
162
+ scope?: string,
163
+ ): FieldLineCount[] | null {
164
+ const field = `[data-cms-field="${CSS.escape(fieldPath)}"]`
165
+ const selector = scope ? `${scope} ${field}` : field
166
+ if (!container.querySelector(selector)) return null
167
+
168
+ const previousWidth = container.style.width
169
+ const out: FieldLineCount[] = []
170
+
171
+ for (const bp of BREAKPOINTS) {
172
+ container.style.width = `${bp.width}px`
173
+ // Re-query inside the loop: a renderer may swap elements between layouts
174
+ // (a grid that becomes a stack can remount its children).
175
+ const el = container.querySelector(selector)
176
+ out.push({ id: bp.id, lines: el ? countLines(el as HTMLElement) : 1 })
177
+ }
178
+
179
+ container.style.width = previousWidth
180
+ return out
181
+ }
182
+
183
+ /** What a probe pass produces for one field. */
184
+ export interface FieldReflow {
185
+ current: FieldLineCount[]
186
+ baseline: FieldLineCount[]
187
+ }
188
+
189
+ /**
190
+ * Measure a field's wrapping now and as published, from the live canvas.
191
+ *
192
+ * The whole `.prenta-canvas` is cloned, not just the target section. Cloning
193
+ * the section alone would require rebuilding whatever sits between the canvas
194
+ * root and the section — which is the class-chain problem again, in a smaller
195
+ * disguise. `e2e/reflow-probe.spec.ts` asserts the whole-canvas clone
196
+ * reproduces every field's rendered width exactly; nothing asserts that for a
197
+ * hand-rebuilt wrapper.
198
+ *
199
+ * The baseline is measured by writing the published string into the SAME cloned
200
+ * element and re-measuring, rather than by rendering the published section. The
201
+ * two differ in exactly one field, so everything else — width caps, siblings,
202
+ * grid tracks — is held identical by construction rather than by hoping a second
203
+ * render matches.
204
+ *
205
+ * `baselineText` of `undefined` means the field has no published version (a new
206
+ * section), so there is no change to report and current is echoed as baseline.
207
+ *
208
+ * Returns `null` when the section or field is absent from the canvas — a
209
+ * consumer's renderer may carry no `data-cms-field` markers, and a line count
210
+ * read off the wrong element is worse than none.
211
+ */
212
+ export function probeFieldReflow(options: {
213
+ canvas: HTMLElement
214
+ sectionId: string
215
+ fieldPath: string
216
+ baselineText: string | undefined
217
+ doc?: Document
218
+ }): FieldReflow | null {
219
+ const { canvas, sectionId, fieldPath, baselineText, doc = document } = options
220
+
221
+ const sectionSel = `[data-section-id="${CSS.escape(sectionId)}"]`
222
+ const fieldSel = `${sectionSel} [data-cms-field="${CSS.escape(fieldPath)}"]`
223
+ if (!canvas.querySelector(fieldSel)) return null
224
+
225
+ const host = createProbeHost(doc)
226
+ const clone = canvas.cloneNode(true) as HTMLElement
227
+ // The canvas is scaled to fit its column. A transform does not change layout
228
+ // width, but it does scale the measured rects — and line height is read from
229
+ // a rect. Left on, every count would be off by the scale factor.
230
+ clone.style.transform = 'none'
231
+ clone.style.maxWidth = 'none'
232
+ host.appendChild(clone)
233
+
234
+ const { detach, scoped } = mountProbeHost(host, doc)
235
+ // Outside `.prenta-admin` the type tokens don't apply and every measurement
236
+ // is taken in the document's default font. Report nothing rather than that.
237
+ if (!scoped) {
238
+ detach()
239
+ return null
240
+ }
241
+
242
+ try {
243
+ const current = measureFieldAcrossBreakpoints(clone, fieldPath, sectionSel)
244
+ if (!current) return null
245
+ if (baselineText === undefined) return { current, baseline: current }
246
+
247
+ const el = clone.querySelector(fieldSel)
248
+ if (!el) return { current, baseline: current }
249
+ el.textContent = baselineText
250
+ const baseline = measureFieldAcrossBreakpoints(clone, fieldPath, sectionSel)
251
+ return { current, baseline: baseline ?? current }
252
+ } finally {
253
+ detach()
254
+ }
255
+ }
256
+
257
+ export type ReflowSeverity = 'same' | 'grew' | 'grew-badly'
258
+
259
+ /**
260
+ * Compare a field's current wrapping against its published baseline.
261
+ *
262
+ * Only growth is flagged. Copy that got shorter cannot break a layout that
263
+ * already held the longer version, so reporting it would be noise on exactly
264
+ * the edit an editor was asked to make.
265
+ */
266
+ export function reflowSeverity(current: number, baseline: number): ReflowSeverity {
267
+ const delta = current - baseline
268
+ if (delta <= 0) return 'same'
269
+ return delta === 1 ? 'grew' : 'grew-badly'
270
+ }
271
+
272
+ /**
273
+ * The breakpoint worth proofing on: the biggest jump, and on a tie the
274
+ * narrowest width. A tie means the same damage happens at several sizes, and
275
+ * the narrowest is where it is most likely to look broken.
276
+ */
277
+ export function worstBreakpoint(
278
+ current: FieldLineCount[],
279
+ baseline: FieldLineCount[],
280
+ ): BreakpointId | null {
281
+ const widthOf = (id: BreakpointId) => BREAKPOINTS.find((b) => b.id === id)?.width ?? 0
282
+ let worst: { id: BreakpointId; delta: number } | null = null
283
+
284
+ for (const c of current) {
285
+ const b = baseline.find((x) => x.id === c.id)
286
+ if (!b) continue
287
+ const delta = c.lines - b.lines
288
+ if (delta <= 0) continue
289
+ if (
290
+ !worst ||
291
+ delta > worst.delta ||
292
+ (delta === worst.delta && widthOf(c.id) < widthOf(worst.id))
293
+ ) {
294
+ worst = { id: c.id, delta }
295
+ }
296
+ }
297
+
298
+ return worst?.id ?? null
299
+ }
@@ -11,6 +11,7 @@
11
11
  import type { BrandAlignmentScoreResult } from '@prenta/core/brand-voice'
12
12
  import type { SEOAnalysisResult } from '@prenta/core/seo/analysis'
13
13
  import { cmsApi } from './api.js'
14
+ import type { MaxImagePreview } from '@prenta/core/seo/robots-directives'
14
15
 
15
16
  /**
16
17
  * `cmsApi` never throws — it returns `{ error, status }` on a network/HTTP
@@ -182,6 +183,12 @@ export interface SeoContentRecord {
182
183
  twitterImageId: string | null
183
184
  noindex: boolean
184
185
  nofollow: boolean
186
+ /** Blocks text snippets, and with them AI Overview and chat citations. */
187
+ nosnippet: boolean
188
+ /** Blocks cached copies. */
189
+ noarchive: boolean
190
+ /** Largest image preview a result may show. */
191
+ maxImagePreview: MaxImagePreview
185
192
  structuredDataType: string | null
186
193
  structuredData: Record<string, unknown> | null
187
194
  /** llms.txt curation: AUTO (heuristic), ALWAYS (pin), NEVER (exclude). */
@@ -232,6 +239,9 @@ export interface SeoFieldPayload {
232
239
  twitterImage?: string
233
240
  noindex?: boolean
234
241
  nofollow?: boolean
242
+ nosnippet?: boolean
243
+ noarchive?: boolean
244
+ maxImagePreview?: MaxImagePreview
235
245
  structuredDataType?: string
236
246
  structuredData?: Record<string, unknown>
237
247
  llmsInclude?: LlmsInclude
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Field-level diff between two page snapshots.
3
+ *
4
+ * One of the three parts of the page-builder handoff specified as real logic
5
+ * rather than mockup ("the version diff — field-level, computed from full
6
+ * snapshots, and its output drives the changed-section outlines on the
7
+ * canvas"). Nothing here is display-only: the same result feeds the history
8
+ * panel's per-field list AND the set of sections the canvas outlines while
9
+ * reviewing, so the two can never disagree about what changed.
10
+ *
11
+ * Pure over `PageSection[]`, with no React and no fetching, because the diff
12
+ * is asked for in three different places (panel rows, canvas outlines, the
13
+ * review bar's count) and recomputing it from the same inputs has to be free
14
+ * of side effects.
15
+ */
16
+ import {
17
+ getSectionType,
18
+ resolveRepeaterField,
19
+ type SectionFieldDef,
20
+ } from '../views/page-editor/section-types.js'
21
+ import type { PageSection } from './page-editor-service.js'
22
+
23
+ /** One changed field on one section. */
24
+ export interface FieldChange {
25
+ sectionId: string
26
+ /** Editor-facing section name, captured so the panel needn't re-look it up. */
27
+ sectionName: string
28
+ /** Human label for the field ("Heading"), from the section registry. */
29
+ field: string
30
+ /** Formatted previous value; `—` when absent. */
31
+ from: string
32
+ /** Formatted new value; `—` when absent. */
33
+ to: string
34
+ }
35
+
36
+ /** Changes for one section, in the order the sections appear on the page. */
37
+ export interface SectionChangeGroup {
38
+ sectionId: string
39
+ sectionName: string
40
+ changes: FieldChange[]
41
+ }
42
+
43
+ /**
44
+ * Labels for the keys that are not content fields and so have no entry in the
45
+ * section registry. Visibility earns its place here: hiding a section is the
46
+ * one structural change a design-owned CMS lets an editor make, so a history
47
+ * that silently dropped it would be missing the only structural edit there is.
48
+ */
49
+ const META_LABELS: Record<string, string> = {
50
+ visible: 'Visibility',
51
+ name: 'Section name',
52
+ }
53
+
54
+ const SETTING_LABELS: Record<string, string> = {
55
+ backgroundType: 'Background type',
56
+ backgroundColor: 'Background colour',
57
+ paddingY: 'Vertical padding',
58
+ anchorId: 'Anchor id',
59
+ cssClass: 'CSS class',
60
+ }
61
+
62
+ const EMPTY = '—'
63
+
64
+ /**
65
+ * Label a content key through the section registry — the same definitions the
66
+ * inspector renders from, so the diff reads in the words the editor typed
67
+ * under. Falls back to the raw key for content on a custom or unmanaged type
68
+ * the registry does not describe: a key is uglier than a label but it is true,
69
+ * whereas omitting the row would hide a real change.
70
+ */
71
+ function contentFieldLabel(sectionType: string, key: string): string {
72
+ const def = getSectionType(sectionType)
73
+ const field = def?.fields.find((f: SectionFieldDef) => f.key === key)
74
+ return field?.label ?? key
75
+ }
76
+
77
+ /**
78
+ * The repeater shape behind a content key, or null when the field is not one.
79
+ *
80
+ * Goes through `resolveRepeaterField` rather than reading `field.fields`
81
+ * directly: `stats` and `gallery` are shorthand presets whose sub-fields are
82
+ * supplied by the registry, not spelled out on the definition, so reading the
83
+ * raw property finds nothing and the diff falls back to raw keys ("value"
84
+ * instead of "Value") on the two repeaters that ship by default.
85
+ */
86
+ function repeaterFor(sectionType: string, key: string) {
87
+ const def = getSectionType(sectionType)
88
+ const field = def?.fields.find((f: SectionFieldDef) => f.key === key)
89
+ return field ? resolveRepeaterField(field) : null
90
+ }
91
+
92
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
94
+ }
95
+
96
+ /**
97
+ * Values that all render as `—` and must therefore compare as equal.
98
+ *
99
+ * Without this, tightening the comparison from formatted strings to structure
100
+ * would start reporting `'' → undefined` as a change, emitting rows that read
101
+ * "— → —". Blank is blank however it is spelled.
102
+ */
103
+ function isBlank(value: unknown): boolean {
104
+ if (value === null || value === undefined) return true
105
+ if (typeof value === 'string') return value.trim() === ''
106
+ if (Array.isArray(value)) return value.length === 0
107
+ if (isPlainObject(value)) return Object.keys(value).length === 0
108
+ return false
109
+ }
110
+
111
+ /**
112
+ * Structural equality, which is what decides whether a field changed.
113
+ *
114
+ * This deliberately does NOT go through {@link formatValue}. Comparing the
115
+ * display strings is the obvious shortcut and it silently loses real edits:
116
+ * `formatValue` collapses an array to "N items", so retyping a stats row while
117
+ * keeping the row count formats identically on both sides and the field reports
118
+ * no change — no row in the panel, and no outline on the canvas. That is
119
+ * precisely the edit the reviewer is looking for.
120
+ */
121
+ function deepEqual(a: unknown, b: unknown): boolean {
122
+ if (isBlank(a) && isBlank(b)) return true
123
+ if (a === b) return true
124
+ if (Array.isArray(a) && Array.isArray(b)) {
125
+ return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]))
126
+ }
127
+ if (isPlainObject(a) && isPlainObject(b)) {
128
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)])
129
+ for (const key of keys) if (!deepEqual(a[key], b[key])) return false
130
+ return true
131
+ }
132
+ return false
133
+ }
134
+
135
+ /**
136
+ * Render a value as the one line the diff shows.
137
+ *
138
+ * Lossy on purpose — a repeater row expanded in full would bury the change it
139
+ * is reporting. It is used for DISPLAY ONLY; {@link deepEqual} decides whether
140
+ * a field changed, and repeater rows are descended into by
141
+ * {@link compareRepeater} so a collapsed summary never has to stand in for a
142
+ * change it cannot show.
143
+ */
144
+ export function formatValue(value: unknown): string {
145
+ if (value === null || value === undefined) return EMPTY
146
+ if (typeof value === 'string') return value.trim() === '' ? EMPTY : value
147
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No'
148
+ if (typeof value === 'number') return String(value)
149
+ if (Array.isArray(value)) {
150
+ if (value.length === 0) return EMPTY
151
+ return `${value.length} item${value.length === 1 ? '' : 's'}`
152
+ }
153
+ if (typeof value === 'object') {
154
+ const record = value as Record<string, unknown>
155
+ for (const key of ['name', 'title', 'label', 'url', 'alt']) {
156
+ const candidate = record[key]
157
+ if (typeof candidate === 'string' && candidate.trim() !== '') return candidate
158
+ }
159
+ const count = Object.keys(record).length
160
+ return count === 0 ? EMPTY : `${count} field${count === 1 ? '' : 's'}`
161
+ }
162
+ return String(value)
163
+ }
164
+
165
+ /** Visibility reads as a state, not a boolean — "No" is not a useful diff. */
166
+ function formatVisible(value: unknown): string {
167
+ return value === false ? 'Hidden' : 'Shown'
168
+ }
169
+
170
+ function push(
171
+ out: FieldChange[],
172
+ section: Pick<PageSection, 'id' | 'name'>,
173
+ field: string,
174
+ from: string,
175
+ to: string,
176
+ ): void {
177
+ out.push({ sectionId: section.id, sectionName: section.name, field, from, to })
178
+ }
179
+
180
+ function compare(
181
+ out: FieldChange[],
182
+ section: Pick<PageSection, 'id' | 'name'>,
183
+ field: string,
184
+ before: unknown,
185
+ after: unknown,
186
+ format: (v: unknown) => string = formatValue,
187
+ ): void {
188
+ if (deepEqual(before, after)) return
189
+ push(out, section, field, format(before), format(after))
190
+ }
191
+
192
+ /**
193
+ * Diff a repeater (stats rows, gallery images, FAQ entries) row by row.
194
+ *
195
+ * A single "2 items → 2 items" row would be worse than useless: it is the one
196
+ * case where the summary cannot express the change, and the reviewer's next
197
+ * question — *which* row — has no answer. Descending gives
198
+ * "Stats · Row 2 · Value" and a real before/after.
199
+ */
200
+ function compareRepeater(
201
+ out: FieldChange[],
202
+ section: Pick<PageSection, 'id' | 'name'>,
203
+ sectionType: string,
204
+ key: string,
205
+ label: string,
206
+ before: readonly unknown[],
207
+ after: readonly unknown[],
208
+ ): void {
209
+ const repeater = repeaterFor(sectionType, key)
210
+ // The registry's own noun for a row ("Stat", "Image") when it has one.
211
+ const rowNoun = repeater?.rowLabel ?? 'Row'
212
+ const subLabel = (subKey: string) =>
213
+ repeater?.fields.find((f: SectionFieldDef) => f.key === subKey)?.label ?? subKey
214
+
215
+ for (let i = 0; i < Math.max(before.length, after.length); i++) {
216
+ const rowLabel = `${label} · ${rowNoun} ${i + 1}`
217
+ const prev = before[i]
218
+ const next = after[i]
219
+
220
+ if (prev === undefined) {
221
+ push(out, section, rowLabel, EMPTY, 'Added')
222
+ continue
223
+ }
224
+ if (next === undefined) {
225
+ push(out, section, rowLabel, 'Present', 'Removed')
226
+ continue
227
+ }
228
+ if (deepEqual(prev, next)) continue
229
+
230
+ // Rows are normally objects of sub-fields; anything else (a bare string in
231
+ // a legacy gallery, say) still gets a row rather than being skipped.
232
+ if (!isPlainObject(prev) || !isPlainObject(next)) {
233
+ push(out, section, rowLabel, formatValue(prev), formatValue(next))
234
+ continue
235
+ }
236
+
237
+ for (const subKey of new Set([...Object.keys(prev), ...Object.keys(next)])) {
238
+ compare(out, section, `${rowLabel} · ${subLabel(subKey)}`, prev[subKey], next[subKey])
239
+ }
240
+ }
241
+ }
242
+
243
+ /** One content field: descends into repeaters, otherwise a plain comparison. */
244
+ function compareContentField(
245
+ out: FieldChange[],
246
+ section: Pick<PageSection, 'id' | 'name'>,
247
+ sectionType: string,
248
+ key: string,
249
+ before: unknown,
250
+ after: unknown,
251
+ ): void {
252
+ if (deepEqual(before, after)) return
253
+
254
+ const label = contentFieldLabel(sectionType, key)
255
+ const beforeRows = Array.isArray(before) ? before : null
256
+ const afterRows = Array.isArray(after) ? after : null
257
+
258
+ if (beforeRows && afterRows) {
259
+ compareRepeater(out, section, sectionType, key, label, beforeRows, afterRows)
260
+ return
261
+ }
262
+
263
+ push(out, section, label, formatValue(before), formatValue(after))
264
+ }
265
+
266
+ /**
267
+ * Compute what changed going FROM `older` TO `newer`.
268
+ *
269
+ * Diffing version *i* against version *i+1* therefore yields what changed *in*
270
+ * version *i* — the framing the history panel needs, where each row answers
271
+ * "what did this save do?" rather than "how does this differ from now?".
272
+ *
273
+ * `older` is optional because the earliest version in a list has nothing
274
+ * before it. That case is no changes, not "everything was added": the oldest
275
+ * snapshot we happen to hold is not the moment the page came into existence,
276
+ * and reporting every field as new would say it was.
277
+ */
278
+ export function diffVersions(
279
+ newer: readonly PageSection[],
280
+ older: readonly PageSection[] | null | undefined,
281
+ ): FieldChange[] {
282
+ if (!older) return []
283
+
284
+ const out: FieldChange[] = []
285
+ const olderById = new Map(older.map((s) => [s.id, s]))
286
+
287
+ for (const next of newer) {
288
+ const prev = olderById.get(next.id)
289
+
290
+ if (!prev) {
291
+ out.push({
292
+ sectionId: next.id,
293
+ sectionName: next.name,
294
+ field: 'Section',
295
+ from: EMPTY,
296
+ to: 'Added',
297
+ })
298
+ continue
299
+ }
300
+
301
+ if (next.name !== prev.name) {
302
+ compare(out, next, META_LABELS.name!, prev.name, next.name)
303
+ }
304
+ compare(out, next, META_LABELS.visible!, prev.visible, next.visible, formatVisible)
305
+
306
+ // Union of both key sets: a field cleared to undefined only appears in the
307
+ // older snapshot, and clearing a heading is exactly the kind of edit a
308
+ // history is consulted about.
309
+ const contentKeys = new Set([
310
+ ...Object.keys(prev.content ?? {}),
311
+ ...Object.keys(next.content ?? {}),
312
+ ])
313
+ for (const key of contentKeys) {
314
+ compareContentField(
315
+ out,
316
+ next,
317
+ next.sectionType,
318
+ key,
319
+ prev.content?.[key],
320
+ next.content?.[key],
321
+ )
322
+ }
323
+
324
+ const settingKeys = new Set([
325
+ ...Object.keys(prev.settings ?? {}),
326
+ ...Object.keys(next.settings ?? {}),
327
+ ])
328
+ for (const key of settingKeys) {
329
+ compare(
330
+ out,
331
+ next,
332
+ SETTING_LABELS[key] ?? key,
333
+ (prev.settings as Record<string, unknown>)?.[key],
334
+ (next.settings as Record<string, unknown>)?.[key],
335
+ )
336
+ }
337
+ }
338
+
339
+ // Sections present before and gone after. Reported against the OLD section's
340
+ // name because the new snapshot has nothing to name it with.
341
+ const newerIds = new Set(newer.map((s) => s.id))
342
+ for (const gone of older) {
343
+ if (newerIds.has(gone.id)) continue
344
+ out.push({
345
+ sectionId: gone.id,
346
+ sectionName: gone.name,
347
+ field: 'Section',
348
+ from: 'Present',
349
+ to: 'Removed',
350
+ })
351
+ }
352
+
353
+ return out
354
+ }
355
+
356
+ /**
357
+ * Group changes by section, preserving first-seen order so the panel lists
358
+ * them top-to-bottom in the order they appear on the page.
359
+ */
360
+ export function groupChangesBySection(changes: readonly FieldChange[]): SectionChangeGroup[] {
361
+ const groups = new Map<string, SectionChangeGroup>()
362
+ for (const change of changes) {
363
+ let group = groups.get(change.sectionId)
364
+ if (!group) {
365
+ group = { sectionId: change.sectionId, sectionName: change.sectionName, changes: [] }
366
+ groups.set(change.sectionId, group)
367
+ }
368
+ group.changes.push(change)
369
+ }
370
+ return [...groups.values()]
371
+ }
372
+
373
+ /** The ids the canvas outlines while reviewing a version. */
374
+ export function changedSectionIds(changes: readonly FieldChange[]): Set<string> {
375
+ return new Set(changes.map((c) => c.sectionId))
376
+ }