@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
@@ -0,0 +1,433 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import { AlertTriangle, Check, Copy, ExternalLink, Layers, Lock, Trash2 } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import {
7
+ restrictedCount,
8
+ robotsMetaLine,
9
+ type MaxImagePreview,
10
+ type RobotsPermissions,
11
+ } from '@prenta/core/seo/robots-directives'
12
+ import {
13
+ SeoAccordion,
14
+ SeoAccordionBadge,
15
+ SeoAccordionItem,
16
+ } from '../../components/seo/SeoAccordion.js'
17
+ import { AuthorPicker } from '../../components/page-tab/AuthorPicker.js'
18
+ import { DirectiveSwitch } from '../../components/page-tab/DirectiveSwitch.js'
19
+ import { pendingSlugChange } from '../../lib/page-redirect.js'
20
+ import type { EditorPage } from '../../lib/page-editor-service.js'
21
+
22
+ const INPUT =
23
+ 'border-border bg-input-background text-foreground focus-visible:border-ring focus-visible:ring-ring/50 w-full rounded-lg border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none'
24
+ const LABEL = 'text-foreground mb-1 block text-xs font-medium'
25
+
26
+ export interface PageTabProps {
27
+ page: EditorPage
28
+ canEdit: boolean
29
+ /** Path as it is on the live site, or null when never published. */
30
+ publishedPath: string | null
31
+ /** Site origin, for resolving the full URL. */
32
+ siteUrl: string
33
+ directives: RobotsPermissions
34
+ onDirectivesChange: (next: RobotsPermissions) => void
35
+ /** Whether a 301 should be created when the moved page is published. */
36
+ redirectOnPublish: boolean
37
+ onRedirectOnPublishChange: (next: boolean) => void
38
+ /**
39
+ * Whether this user can create redirects. `POST /redirects` is limited to
40
+ * publishers who may also publish the page; when false the URL-changed
41
+ * warning still shows, but the checkbox that would promise a 301 does not.
42
+ */
43
+ canCreateRedirect?: boolean
44
+ onPageChange: (patch: Partial<EditorPage>) => void
45
+ onDuplicate: () => void
46
+ onTrash: () => void
47
+ /** Template/schema binding, shown read-only. */
48
+ templateName?: string
49
+ schemaType?: string
50
+ /** Offered as the brand byline in the author picker. */
51
+ brandName?: string
52
+ }
53
+
54
+ /**
55
+ * Rail tab 3 — everything about the page that is not its content or its search
56
+ * copy: where it lives, when it goes live, what crawlers may do with it, what
57
+ * owns its layout, and how to get rid of it.
58
+ */
59
+ export function PageTab({
60
+ page,
61
+ canEdit,
62
+ publishedPath,
63
+ siteUrl,
64
+ directives,
65
+ onDirectivesChange,
66
+ redirectOnPublish,
67
+ onRedirectOnPublishChange,
68
+ canCreateRedirect = true,
69
+ onPageChange,
70
+ onDuplicate,
71
+ onTrash,
72
+ templateName,
73
+ schemaType,
74
+ brandName,
75
+ }: PageTabProps) {
76
+ const [copied, setCopied] = useState(false)
77
+ const [confirmTrash, setConfirmTrash] = useState(false)
78
+
79
+ const fullUrl = useMemo(() => {
80
+ const origin = siteUrl.replace(/\/+$/, '')
81
+ const path = page.path.startsWith('/') ? page.path : `/${page.path}`
82
+ return `${origin}${path}`
83
+ }, [siteUrl, page.path])
84
+
85
+ const move = pendingSlugChange(publishedPath, page.path)
86
+ const restricted = restrictedCount(directives)
87
+ const set = (patch: Partial<RobotsPermissions>) => onDirectivesChange({ ...directives, ...patch })
88
+
89
+ async function copyUrl() {
90
+ try {
91
+ await navigator.clipboard.writeText(fullUrl)
92
+ setCopied(true)
93
+ setTimeout(() => setCopied(false), 1600)
94
+ } catch {
95
+ toast.error('Could not copy — your browser blocked clipboard access.')
96
+ }
97
+ }
98
+
99
+ return (
100
+ <SeoAccordion defaultOpen={['address', 'publishing']}>
101
+ {/* ── Address ────────────────────────────────────────────────────── */}
102
+ <SeoAccordionItem
103
+ value="address"
104
+ title="Address"
105
+ badge={move ? <SeoAccordionBadge tone="warn">URL CHANGED</SeoAccordionBadge> : undefined}
106
+ >
107
+ <div>
108
+ <label className={LABEL} htmlFor="pt-slug">
109
+ URL slug
110
+ </label>
111
+ <input
112
+ id="pt-slug"
113
+ className={INPUT}
114
+ value={page.slug}
115
+ disabled={!canEdit}
116
+ onChange={(e) => {
117
+ // Path is the public URL the redirect detector and the displayed
118
+ // full link both read. Keep it in lockstep when it was derived
119
+ // from the previous slug — the same rule as PageSettingsModal.
120
+ // A custom path (e.g. `/team/about` while slug is `about`) is
121
+ // left alone; the editor must change that elsewhere on purpose.
122
+ const slug = e.target.value
123
+ const patch: Partial<EditorPage> = { slug }
124
+ if (!page.path || page.path === `/${page.slug}`) {
125
+ patch.path = slug ? `/${slug}` : '/'
126
+ }
127
+ onPageChange(patch)
128
+ }}
129
+ />
130
+ <div className="mt-1.5 flex items-center gap-1.5">
131
+ {/* `data-volatile` so visual baselines mask it: the origin comes
132
+ from `NEXT_PUBLIC_SITE_URL`, which is set in some environments
133
+ and not others, and a screenshot that bakes one in fails in the
134
+ other. */}
135
+ <code
136
+ data-volatile
137
+ className="text-muted-foreground min-w-0 flex-1 truncate font-mono text-[11px]"
138
+ >
139
+ {fullUrl}
140
+ </code>
141
+ <button
142
+ type="button"
143
+ onClick={copyUrl}
144
+ className="text-muted-foreground hover:text-foreground shrink-0 rounded p-1"
145
+ aria-label={copied ? 'Full URL copied' : 'Copy full URL'}
146
+ >
147
+ {copied ? (
148
+ <Check size={13} className="text-success" aria-hidden />
149
+ ) : (
150
+ <Copy size={13} aria-hidden />
151
+ )}
152
+ </button>
153
+ </div>
154
+ </div>
155
+
156
+ {/* Only for a page that is actually live. A slug change on a draft
157
+ breaks nothing, and warning about it would train editors to dismiss
158
+ the warning that matters. */}
159
+ {move && (
160
+ <div className="bg-warning/10 border-warning/30 space-y-2 rounded-lg border p-2.5">
161
+ <div className="flex gap-2">
162
+ <AlertTriangle size={14} className="text-warning mt-px shrink-0" aria-hidden />
163
+ <p className="text-[11.5px] leading-normal">
164
+ This page is live at <b className="font-medium">{move.from}</b>. Publishing moves
165
+ it, and every existing link to the old address will 404.
166
+ </p>
167
+ </div>
168
+ {canCreateRedirect ? (
169
+ <label className="flex cursor-pointer items-start gap-2 text-[11.5px]">
170
+ <input
171
+ type="checkbox"
172
+ checked={redirectOnPublish}
173
+ disabled={!canEdit}
174
+ onChange={(e) => onRedirectOnPublishChange(e.target.checked)}
175
+ className="accent-primary mt-0.5"
176
+ />
177
+ <span>
178
+ Create a 301 redirect on publish
179
+ <span className="text-muted-foreground mt-0.5 block font-mono text-[10.5px]">
180
+ {move.from} → {move.to}
181
+ </span>
182
+ </span>
183
+ </label>
184
+ ) : (
185
+ <p className="text-muted-foreground text-[11.5px] leading-normal">
186
+ Ask an admin to create a 301 from{' '}
187
+ <span className="font-mono text-[10.5px]">
188
+ {move.from} → {move.to}
189
+ </span>{' '}
190
+ so existing links keep working.
191
+ </p>
192
+ )}
193
+ </div>
194
+ )}
195
+
196
+ {page.status === 'PUBLISHED' && (
197
+ <a
198
+ href={fullUrl}
199
+ target="_blank"
200
+ rel="noreferrer"
201
+ className="border-border hover:bg-accent inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium"
202
+ >
203
+ <ExternalLink size={12} aria-hidden />
204
+ Open live page
205
+ </a>
206
+ )}
207
+ </SeoAccordionItem>
208
+
209
+ {/* ── Publishing ─────────────────────────────────────────────────── */}
210
+ <SeoAccordionItem
211
+ value="publishing"
212
+ title="Publishing"
213
+ badge={
214
+ <SeoAccordionBadge tone={page.status === 'PUBLISHED' ? 'good' : 'neutral'}>
215
+ {page.status}
216
+ </SeoAccordionBadge>
217
+ }
218
+ >
219
+ <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-[11.5px]">
220
+ <dt className="text-muted-foreground">Last edited</dt>
221
+ <dd data-volatile>{formatStamp(page.updatedAt) ?? '—'}</dd>
222
+ <dt className="text-muted-foreground">Published</dt>
223
+ <dd data-volatile>{formatStamp(page.publishedAt) ?? 'Not yet published'}</dd>
224
+ {page.scheduledAt && (
225
+ <>
226
+ <dt className="text-muted-foreground">Goes live</dt>
227
+ <dd data-volatile>{formatStamp(page.scheduledAt)}</dd>
228
+ </>
229
+ )}
230
+ {page.scheduledUnpublishAt && (
231
+ <>
232
+ <dt className="text-muted-foreground">Comes down</dt>
233
+ <dd data-volatile>{formatStamp(page.scheduledUnpublishAt)}</dd>
234
+ </>
235
+ )}
236
+ </dl>
237
+ <div>
238
+ <p className={LABEL}>Byline</p>
239
+ <AuthorPicker
240
+ value={{ name: page.authorName, id: page.authorId }}
241
+ onChange={(next) => onPageChange({ authorName: next.name, authorId: next.id })}
242
+ brandName={brandName}
243
+ disabled={!canEdit}
244
+ />
245
+ <p className="text-muted-foreground mt-1 text-[11px] leading-snug">
246
+ Shown on the page and in its structured data. Separate from who created the record,
247
+ which is not reassigned.
248
+ </p>
249
+ </div>
250
+
251
+ <p className="text-muted-foreground text-[11px] leading-snug">
252
+ Status is changed with Publish and Schedule in the toolbar, so there is one way to do it
253
+ rather than two that can disagree.
254
+ </p>
255
+ </SeoAccordionItem>
256
+
257
+ {/* ── Search directives ──────────────────────────────────────────── */}
258
+ <SeoAccordionItem
259
+ value="directives"
260
+ title="Search directives"
261
+ badge={
262
+ restricted === 0 ? (
263
+ <SeoAccordionBadge tone="good">OPEN</SeoAccordionBadge>
264
+ ) : (
265
+ <SeoAccordionBadge tone="warn">{restricted} RESTRICTED</SeoAccordionBadge>
266
+ )
267
+ }
268
+ >
269
+ <div className="divide-border divide-y">
270
+ <DirectiveSwitch
271
+ id="pt-index"
272
+ label="Show in search results"
273
+ hint="Off removes the page from Google and every other index."
274
+ checked={directives.index}
275
+ onChange={(v) => set({ index: v })}
276
+ permissiveToken="index"
277
+ restrictiveToken="noindex"
278
+ disabled={!canEdit}
279
+ />
280
+ <DirectiveSwitch
281
+ id="pt-follow"
282
+ label="Follow links on this page"
283
+ hint="Off stops crawlers passing authority to anything this page links to."
284
+ checked={directives.follow}
285
+ onChange={(v) => set({ follow: v })}
286
+ permissiveToken="follow"
287
+ restrictiveToken="nofollow"
288
+ disabled={!canEdit}
289
+ />
290
+ <DirectiveSwitch
291
+ id="pt-snippet"
292
+ label="Allow text snippets"
293
+ hint="Off blocks AI Overviews and chat citations entirely."
294
+ checked={directives.snippet}
295
+ onChange={(v) => set({ snippet: v })}
296
+ permissiveToken="snippet"
297
+ restrictiveToken="nosnippet"
298
+ disabled={!canEdit || !directives.index}
299
+ />
300
+ <DirectiveSwitch
301
+ id="pt-archive"
302
+ label="Allow cached copies"
303
+ checked={directives.archive}
304
+ onChange={(v) => set({ archive: v })}
305
+ permissiveToken="archive"
306
+ restrictiveToken="noarchive"
307
+ disabled={!canEdit || !directives.index}
308
+ />
309
+ </div>
310
+
311
+ <div>
312
+ <label className={LABEL} htmlFor="pt-img-preview">
313
+ Image preview size
314
+ </label>
315
+ <select
316
+ id="pt-img-preview"
317
+ className={INPUT}
318
+ value={directives.maxImagePreview}
319
+ disabled={!canEdit || !directives.index || !directives.snippet}
320
+ onChange={(e) => set({ maxImagePreview: e.target.value as MaxImagePreview })}
321
+ >
322
+ <option value="large">Large — full-width image in results</option>
323
+ <option value="standard">Standard — thumbnail</option>
324
+ <option value="none">None</option>
325
+ </select>
326
+ </div>
327
+
328
+ <div>
329
+ <p className={LABEL}>What ships in the page head</p>
330
+ {/* The literal line, composed by the same function the renderer uses.
331
+ An editor should never have to trust that this UI and the output
332
+ agree — they are the same code. */}
333
+ <code className="bg-muted text-muted-foreground block overflow-x-auto rounded-lg px-2.5 py-2 font-mono text-[10.5px] whitespace-pre">
334
+ {robotsMetaLine(directives)}
335
+ </code>
336
+ </div>
337
+
338
+ {!directives.index && (
339
+ <div className="bg-warning/10 border-warning/30 flex gap-2 rounded-lg border p-2.5">
340
+ <AlertTriangle size={14} className="text-warning mt-px shrink-0" aria-hidden />
341
+ <p className="text-[11.5px] leading-normal">
342
+ <b className="font-semibold">This page is hidden from search.</b> Publishing will not
343
+ make it findable.
344
+ </p>
345
+ </div>
346
+ )}
347
+ </SeoAccordionItem>
348
+
349
+ {/* ── Layout ─────────────────────────────────────────────────────── */}
350
+ <SeoAccordionItem
351
+ value="layout"
352
+ title="Layout"
353
+ badge={<SeoAccordionBadge tone="neutral">LOCKED</SeoAccordionBadge>}
354
+ >
355
+ <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-[11.5px]">
356
+ <dt className="text-muted-foreground">Template</dt>
357
+ <dd className="flex items-center gap-1.5">
358
+ <Layers size={12} className="text-muted-foreground" aria-hidden />
359
+ {templateName ?? 'Default'}
360
+ </dd>
361
+ <dt className="text-muted-foreground">Schema</dt>
362
+ <dd>{schemaType ?? 'WebPage'}</dd>
363
+ </dl>
364
+ <div className="text-muted-foreground flex gap-2 text-[11px] leading-snug">
365
+ <Lock size={13} className="mt-px shrink-0" aria-hidden />
366
+ <p>
367
+ Layout and styling are design-owned — built in code, not configured here. This tab edits
368
+ where the page lives and how it is indexed.
369
+ </p>
370
+ </div>
371
+ </SeoAccordionItem>
372
+
373
+ {/* ── Danger zone ────────────────────────────────────────────────── */}
374
+ <SeoAccordionItem value="danger" title="Danger zone">
375
+ <button
376
+ type="button"
377
+ onClick={onDuplicate}
378
+ disabled={!canEdit || !page.id}
379
+ className="border-border hover:border-destructive hover:bg-destructive/10 hover:text-destructive flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium transition-colors disabled:opacity-50"
380
+ >
381
+ <Copy size={13} aria-hidden />
382
+ Duplicate page
383
+ </button>
384
+
385
+ {confirmTrash ? (
386
+ <div className="border-destructive/40 bg-destructive/10 space-y-2 rounded-lg border p-2.5">
387
+ <p className="text-[11.5px] leading-normal">
388
+ Move <b className="font-semibold">{page.title || 'this page'}</b> to trash?
389
+ {page.status === 'PUBLISHED' && ' It is live, and will stop being served.'}
390
+ </p>
391
+ <div className="flex gap-2">
392
+ <button
393
+ type="button"
394
+ onClick={onTrash}
395
+ className="bg-destructive text-destructive-foreground rounded-md px-2.5 py-1 text-[11px] font-semibold"
396
+ >
397
+ Move to trash
398
+ </button>
399
+ <button
400
+ type="button"
401
+ onClick={() => setConfirmTrash(false)}
402
+ className="border-border rounded-md border px-2.5 py-1 text-[11px] font-medium"
403
+ >
404
+ Cancel
405
+ </button>
406
+ </div>
407
+ </div>
408
+ ) : (
409
+ <button
410
+ type="button"
411
+ onClick={() => setConfirmTrash(true)}
412
+ disabled={!canEdit || !page.id}
413
+ className="border-border hover:border-destructive hover:bg-destructive/10 hover:text-destructive flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium transition-colors disabled:opacity-50"
414
+ >
415
+ <Trash2 size={13} aria-hidden />
416
+ Move to trash
417
+ </button>
418
+ )}
419
+ </SeoAccordionItem>
420
+ </SeoAccordion>
421
+ )
422
+ }
423
+
424
+ /** `null` rather than a placeholder string, so callers choose their own dash. */
425
+ function formatStamp(value: string | null): string | null {
426
+ if (!value) return null
427
+ const d = new Date(value)
428
+ if (Number.isNaN(d.getTime())) return null
429
+ return d.toLocaleString(undefined, {
430
+ dateStyle: 'medium',
431
+ timeStyle: 'short',
432
+ })
433
+ }
@@ -18,11 +18,15 @@ import {
18
18
  verticalListSortingStrategy,
19
19
  } from '@dnd-kit/sortable'
20
20
  import { CSS } from '@dnd-kit/utilities'
21
- import { MediaPickerModal } from '../../components/MediaPickerModal.js'
21
+ import { MediaBlock } from '../../components/MediaBlock.js'
22
+ import { ReflowMeter } from '../../components/ReflowMeter.js'
22
23
  import { InspectorExpandButton, ResizableAside } from '../../components/ui/ResizableAside.js'
23
24
  import { Toggle } from '../../components/ui/Toggle.js'
24
25
  import { RichTextField } from '../../fields/RichTextField.js'
26
+ import { useReflowGuard } from '../../hooks/useReflowGuard.js'
27
+ import { baselineFieldText } from '../../lib/reflow-baseline.js'
25
28
  import type { PageSection } from '../../lib/page-editor-service.js'
29
+ import { useReflowContext } from './reflow-context.js'
26
30
  import type { ResolvedRepeater, SectionSettings } from './section-types.js'
27
31
  import {
28
32
  blankRepeaterRow,
@@ -328,12 +332,52 @@ export function SectionInspector({
328
332
 
329
333
  // ─── Field renderers ─────────────────────────────────────────────────
330
334
 
335
+ /**
336
+ * Field types the reflow guard measures.
337
+ *
338
+ * Text that wraps, and nothing else. A `select` has a fixed set of values the
339
+ * design already accounts for; a `boolean` has no text; `media` wraps nothing.
340
+ *
341
+ * `richText` is excluded deliberately. The probe establishes a baseline by
342
+ * writing the published string into the cloned element, which for rich text
343
+ * would mean injecting stored markup into the DOM — and the whole body is one
344
+ * marker, so a line count across several paragraphs answers no question an
345
+ * editor is asking.
346
+ */
347
+ const GUARDED_FIELD_TYPES = new Set(['text', 'textarea', 'url'])
348
+
349
+ /**
350
+ * Attaches the reflow readout under a field.
351
+ *
352
+ * Renders nothing at all when the guard has no rows — the section may be drawn
353
+ * by a consumer's renderer that carries no `data-cms-field` markers, and an
354
+ * empty row of dots would read as "measured, all fine" rather than "not
355
+ * measured".
356
+ */
357
+ function FieldReflow({ fieldPath, value }: { fieldPath: string; value: string }) {
358
+ const ctx = useReflowContext()
359
+ const guard = useReflowGuard({
360
+ canvas: ctx.canvas,
361
+ sectionId: ctx.sectionId ?? '',
362
+ fieldPath,
363
+ value,
364
+ baseline: baselineFieldText(ctx.baseline, ctx.sectionId ?? '', fieldPath),
365
+ enabled: Boolean(ctx.canvas && ctx.sectionId),
366
+ })
367
+
368
+ if (guard.rows.length === 0) return null
369
+ return (
370
+ <ReflowMeter guard={guard} length={value.length} device={ctx.device} onProof={ctx.onProof} />
371
+ )
372
+ }
373
+
331
374
  function ContentField({
332
375
  field,
333
376
  value,
334
377
  onChange,
335
378
  onPatch,
336
379
  id: idProp,
380
+ fieldPath,
337
381
  }: {
338
382
  field: SectionFieldDef
339
383
  value: unknown
@@ -345,9 +389,17 @@ function ContentField({
345
389
  onPatch?: (patch: Record<string, unknown>) => void
346
390
  /** Explicit input id (repeater rows pass a row-scoped id to stay unique). */
347
391
  id?: string
392
+ /**
393
+ * Dotted `data-cms-field` path for the reflow guard. Differs from `field.key`
394
+ * inside a repeater, where the marker is `stats.0.label`. Defaults to the
395
+ * key, which is right for every top-level field.
396
+ */
397
+ fieldPath?: string
348
398
  }) {
349
399
  const id = idProp ?? `insp-field-${field.key}`
400
+ const path = fieldPath ?? field.key
350
401
  const text = typeof value === 'string' ? value : ''
402
+ const guarded = GUARDED_FIELD_TYPES.has(field.type)
351
403
 
352
404
  const repeater = resolveRepeaterField(field)
353
405
  if (repeater) {
@@ -405,6 +457,7 @@ function ContentField({
405
457
  placeholder={field.placeholder}
406
458
  onChange={(e) => onChange(e.target.value)}
407
459
  />
460
+ {guarded && <FieldReflow fieldPath={path} value={text} />}
408
461
  {field.help && <p className="text-muted-foreground mt-1 text-xs">{field.help}</p>}
409
462
  </div>
410
463
  )
@@ -424,6 +477,7 @@ function ContentField({
424
477
  placeholder={field.placeholder}
425
478
  onChange={(e) => onChange(e.target.value)}
426
479
  />
480
+ {guarded && <FieldReflow fieldPath={path} value={text} />}
427
481
  {field.help && <p className="text-muted-foreground mt-1 text-xs">{field.help}</p>}
428
482
  </div>
429
483
  )
@@ -459,56 +513,42 @@ function MediaField({
459
513
  /**
460
514
  * When provided, picking from the library applies a multi-key patch (URL +
461
515
  * intrinsic width/height) in one update. Falls back to `onChange` (URL only)
462
- * for manual entry and when no patch sink is wired.
516
+ * when no patch sink is wired.
463
517
  */
464
518
  onPatch?: (patch: Record<string, unknown>) => void
465
519
  }) {
466
- const [open, setOpen] = useState(false)
467
520
  return (
468
521
  <div>
469
- <label className={LABEL} htmlFor={id}>
522
+ {/* Not `htmlFor` — the block is a composite of buttons, not a single
523
+ labellable control. The label names the group; each control inside
524
+ carries its own accessible name. */}
525
+ <span className={LABEL} id={`${id}-label`}>
470
526
  {field.label}
471
- </label>
472
- <div className="flex gap-2">
473
- <input
474
- id={id}
475
- className={`${INPUT} flex-1`}
476
- placeholder="https://…"
477
- value={value}
478
- onChange={(e) => onChange(e.target.value)}
479
- />
480
- <button
481
- type="button"
482
- onClick={() => setOpen(true)}
483
- className="bg-accent text-foreground border-border hover:bg-accent/80 rounded-md border px-3 py-1.5 text-xs font-medium"
484
- >
485
- Browse
486
- </button>
487
- </div>
488
- {value && (
489
- <div className="border-border bg-muted mt-2 overflow-hidden rounded-md border">
490
- <img src={value} alt="" className="h-28 w-full object-contain" />
491
- </div>
492
- )}
493
- <MediaPickerModal
494
- open={open}
495
- onClose={() => setOpen(false)}
496
- onSelectItem={(item) => {
497
- if (!onPatch) return
527
+ </span>
528
+ <MediaBlock
529
+ value={value}
530
+ label={field.label}
531
+ onChange={(url, item) => {
532
+ if (!onPatch) {
533
+ onChange(url)
534
+ return
535
+ }
536
+ // Persist the intrinsic dimensions alongside the URL so the public
537
+ // renderer can use the CLS-safe path without a read-time lookup.
498
538
  const { width: wKey, height: hKey } = companionDimensionKeys(field.key)
499
- const patch: Record<string, unknown> = { [field.key]: item.url }
500
- if (typeof item.width === 'number') patch[wKey] = item.width
501
- if (typeof item.height === 'number') patch[hKey] = item.height
539
+ const patch: Record<string, unknown> = { [field.key]: url }
540
+ if (typeof item?.width === 'number') patch[wKey] = item.width
541
+ if (typeof item?.height === 'number') patch[hKey] = item.height
542
+ // Clearing the slot clears the dimensions with it — leaving them
543
+ // behind would size the next image by the previous one's box.
544
+ if (!url) {
545
+ patch[wKey] = undefined
546
+ patch[hKey] = undefined
547
+ }
502
548
  onPatch(patch)
503
549
  }}
504
- onSelect={(url) => {
505
- // onSelectItem handles persistence when a patch sink is wired; only
506
- // fall back to a URL-only update when it isn't.
507
- if (!onPatch) onChange(url)
508
- setOpen(false)
509
- }}
510
- accept="image/*"
511
550
  />
551
+ {field.help && <p className="text-muted-foreground mt-1 text-xs">{field.help}</p>}
512
552
  </div>
513
553
  )
514
554
  }
@@ -796,6 +836,7 @@ function SortableRepeaterRow({
796
836
  <ContentField
797
837
  key={sub.key}
798
838
  id={`insp-field-${fieldKey}-${index}-${sub.key}`}
839
+ fieldPath={`${fieldKey}.${index}.${sub.key}`}
799
840
  field={sub}
800
841
  value={row[sub.key]}
801
842
  onChange={(v) => onPatch({ [sub.key]: v })}