@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,39 @@
1
+ 'use client'
2
+
3
+ import type { Zoom } from '../lib/breakpoints.js'
4
+
5
+ export interface ZoomChipProps {
6
+ /** Current zoom mode. */
7
+ zoom: Zoom
8
+ /** Actual rendered scale, 0–1 — what the canvas measured, not the mode. */
9
+ scale: number
10
+ onChange: (zoom: Zoom) => void
11
+ }
12
+
13
+ /**
14
+ * Reads out the canvas scale and toggles fit ↔ 100%.
15
+ *
16
+ * It shows the *measured* scale rather than the mode, because "fit" is not a
17
+ * number an editor can reason about — at 1920px in a narrow rail it might be
18
+ * 38%, and that is the fact that explains why the preview looks small.
19
+ */
20
+ export function ZoomChip({ zoom, scale, onChange }: ZoomChipProps) {
21
+ const percent = Math.round(scale * 100)
22
+ const next: Zoom = zoom === 'fit' ? 1 : 'fit'
23
+
24
+ return (
25
+ <button
26
+ type="button"
27
+ onClick={() => onChange(next)}
28
+ aria-label={
29
+ zoom === 'fit'
30
+ ? `Canvas scaled to ${percent} percent to fit. Switch to actual size.`
31
+ : 'Canvas at actual size. Switch to fit.'
32
+ }
33
+ title={zoom === 'fit' ? 'Fit to window — click for 100%' : 'Actual size — click to fit'}
34
+ className="border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground h-[34px] min-w-[54px] rounded-lg border px-2 text-[11.5px] font-semibold tabular-nums transition-colors"
35
+ >
36
+ {percent}%
37
+ </button>
38
+ )
39
+ }
@@ -0,0 +1,17 @@
1
+ import { Laptop, Monitor, Smartphone, Tablet, type LucideIcon } from 'lucide-react'
2
+
3
+ /**
4
+ * Maps a breakpoint's `icon` name to its component. Kept out of
5
+ * `lib/breakpoints.ts` so that module stays free of React imports — it is pure
6
+ * data and arithmetic, and its tests run without a DOM.
7
+ */
8
+ const ICONS: Record<string, LucideIcon> = {
9
+ monitor: Monitor,
10
+ laptop: Laptop,
11
+ tablet: Tablet,
12
+ smartphone: Smartphone,
13
+ }
14
+
15
+ export function breakpointIcon(name: string): LucideIcon {
16
+ return ICONS[name] ?? Monitor
17
+ }
@@ -0,0 +1,240 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { ChevronDown, Globe } from 'lucide-react'
5
+ import { cmsApi } from '../../lib/api.js'
6
+
7
+ interface CmsUser {
8
+ id: string
9
+ name?: string | null
10
+ email?: string | null
11
+ role?: string | null
12
+ }
13
+
14
+ export interface AuthorValue {
15
+ /** Displayed byline. Empty means no byline is set. */
16
+ name: string
17
+ /** CMS user id, when the byline is one. Empty for generic and guest. */
18
+ id: string
19
+ }
20
+
21
+ export interface AuthorPickerProps {
22
+ value: AuthorValue
23
+ onChange: (next: AuthorValue) => void
24
+ /** Brand byline offered under GENERIC — usually the site or org name. */
25
+ brandName?: string
26
+ disabled?: boolean
27
+ }
28
+
29
+ /**
30
+ * Byline picker: a CMS user, the brand, or a one-off guest name.
31
+ *
32
+ * ── What this does and does not change ────────────────────────────────────
33
+ *
34
+ * It sets the page's DISPLAYED author, which reaches the page's structured
35
+ * data. It does not reassign the document's `createdById` — that records who
36
+ * created the record, and trading an audit trail for a label is not a swap
37
+ * worth making silently. A page written by a staff editor and bylined to the
38
+ * brand is a normal thing; a page whose history says someone else made it is
39
+ * not.
40
+ *
41
+ * The menu renders inline below the trigger rather than floating. The rail is
42
+ * narrow and scrollable, and a floating menu inside a scroll container either
43
+ * clips or detaches from its trigger on scroll.
44
+ */
45
+ export function AuthorPicker({ value, onChange, brandName, disabled = false }: AuthorPickerProps) {
46
+ const [open, setOpen] = useState(false)
47
+ const [users, setUsers] = useState<CmsUser[]>([])
48
+ const [guest, setGuest] = useState('')
49
+
50
+ useEffect(() => {
51
+ if (!open || users.length > 0) return
52
+ let cancelled = false
53
+ cmsApi<CmsUser[] | { users?: CmsUser[] }>('/users').then((res) => {
54
+ if (cancelled || !res.data) return
55
+ const list = Array.isArray(res.data) ? res.data : (res.data.users ?? [])
56
+ setUsers(list)
57
+ })
58
+ return () => {
59
+ cancelled = true
60
+ }
61
+ }, [open, users.length])
62
+
63
+ const isGeneric = Boolean(brandName) && value.name === brandName && !value.id
64
+ const label = value.name || 'No byline'
65
+ const sub = value.id
66
+ ? (users.find((u) => u.id === value.id)?.role ?? 'CMS user')
67
+ : isGeneric
68
+ ? 'Brand byline'
69
+ : value.name
70
+ ? 'Guest byline'
71
+ : 'None set'
72
+
73
+ function pick(next: AuthorValue) {
74
+ onChange(next)
75
+ setOpen(false)
76
+ }
77
+
78
+ return (
79
+ <div>
80
+ <button
81
+ type="button"
82
+ onClick={() => setOpen((v) => !v)}
83
+ disabled={disabled}
84
+ aria-expanded={open}
85
+ className="border-border bg-card hover:border-ring flex w-full items-center gap-2.5 rounded-[7px] border-[1.5px] px-2.5 py-[7px] text-left transition-colors disabled:opacity-50"
86
+ >
87
+ <Avatar name={value.name} generic={isGeneric} />
88
+ <span className="min-w-0 flex-1">
89
+ <span className="text-foreground block truncate text-[12.5px] font-semibold">
90
+ {label}
91
+ </span>
92
+ <span className="text-muted-foreground block text-[10.5px]">{sub}</span>
93
+ </span>
94
+ <ChevronDown
95
+ size={14}
96
+ aria-hidden
97
+ className={`text-muted-foreground shrink-0 motion-safe:transition-transform ${open ? 'rotate-180' : ''}`}
98
+ />
99
+ </button>
100
+
101
+ {open && (
102
+ <div className="border-border mt-1.5 space-y-2 rounded-[9px] border p-2">
103
+ <Group label="CMS users">
104
+ {users.length === 0 ? (
105
+ <p className="text-muted-foreground px-1 py-1.5 text-[11px]">Loading…</p>
106
+ ) : (
107
+ users.map((u) => {
108
+ const name = u.name || u.email || 'Unnamed user'
109
+ return (
110
+ <Row
111
+ key={u.id}
112
+ selected={value.id === u.id}
113
+ onClick={() => pick({ name, id: u.id })}
114
+ >
115
+ <Avatar name={name} />
116
+ <span className="min-w-0">
117
+ <span className="block truncate text-[12px] font-medium">{name}</span>
118
+ <span className="text-muted-foreground block text-[10px]">
119
+ {u.role ?? 'Author'}
120
+ </span>
121
+ </span>
122
+ </Row>
123
+ )
124
+ })
125
+ )}
126
+ </Group>
127
+
128
+ {brandName && (
129
+ <Group label="Generic">
130
+ <Row selected={isGeneric} onClick={() => pick({ name: brandName, id: '' })}>
131
+ <Avatar name={brandName} generic />
132
+ <span className="text-[12px] font-medium">{brandName}</span>
133
+ </Row>
134
+ </Group>
135
+ )}
136
+
137
+ <Group label="Guest byline">
138
+ <div className="flex gap-1.5 px-1">
139
+ <input
140
+ value={guest}
141
+ onChange={(e) => setGuest(e.target.value)}
142
+ placeholder="Name to display"
143
+ aria-label="Guest byline name"
144
+ className="border-border bg-input-background focus-visible:border-ring min-w-0 flex-1 rounded-md border px-2 py-1 text-[11.5px] focus-visible:outline-none"
145
+ />
146
+ <button
147
+ type="button"
148
+ // Disabled until non-empty: an empty guest byline would clear
149
+ // the author while looking like it set one.
150
+ disabled={guest.trim().length === 0}
151
+ onClick={() => {
152
+ pick({ name: guest.trim(), id: '' })
153
+ setGuest('')
154
+ }}
155
+ className="bg-primary text-primary-foreground rounded-md px-2 py-1 text-[11px] font-semibold disabled:opacity-40"
156
+ >
157
+ Add
158
+ </button>
159
+ </div>
160
+ </Group>
161
+ </div>
162
+ )}
163
+ </div>
164
+ )
165
+ }
166
+
167
+ function Group({ label, children }: { label: string; children: React.ReactNode }) {
168
+ return (
169
+ <div>
170
+ <p className="text-muted-foreground px-1 pb-1 text-[9.5px] font-bold tracking-wide uppercase">
171
+ {label}
172
+ </p>
173
+ <div className="space-y-0.5">{children}</div>
174
+ </div>
175
+ )
176
+ }
177
+
178
+ function Row({
179
+ selected,
180
+ onClick,
181
+ children,
182
+ }: {
183
+ selected: boolean
184
+ onClick: () => void
185
+ children: React.ReactNode
186
+ }) {
187
+ return (
188
+ <button
189
+ type="button"
190
+ onClick={onClick}
191
+ aria-current={selected || undefined}
192
+ className={`flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left transition-colors ${
193
+ selected ? 'bg-accent' : 'hover:bg-accent/60'
194
+ }`}
195
+ >
196
+ {children}
197
+ </button>
198
+ )
199
+ }
200
+
201
+ /**
202
+ * Initials on a tint derived from the name, so the same person is the same
203
+ * colour everywhere without storing one. A globe replaces initials for the
204
+ * brand byline — "Ex" for "Example Inc" reads as a person, which is the one
205
+ * thing the generic option is not.
206
+ */
207
+ function Avatar({ name, generic = false }: { name: string; generic?: boolean }) {
208
+ if (generic) {
209
+ return (
210
+ <span className="bg-muted text-muted-foreground flex h-[27px] w-[27px] shrink-0 items-center justify-center rounded-full">
211
+ <Globe size={14} aria-hidden />
212
+ </span>
213
+ )
214
+ }
215
+
216
+ const initials =
217
+ name
218
+ .trim()
219
+ .split(/\s+/)
220
+ .slice(0, 2)
221
+ .map((w) => w[0] ?? '')
222
+ .join('')
223
+ .toUpperCase() || '—'
224
+
225
+ let hash = 0
226
+ for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) % 360
227
+
228
+ return (
229
+ <span
230
+ aria-hidden
231
+ className="flex h-[27px] w-[27px] shrink-0 items-center justify-center rounded-full text-[10px] font-bold"
232
+ style={{
233
+ backgroundColor: `oklch(0.92 0.05 ${hash})`,
234
+ color: `oklch(0.42 0.12 ${hash})`,
235
+ }}
236
+ >
237
+ {initials}
238
+ </span>
239
+ )
240
+ }
@@ -0,0 +1,86 @@
1
+ 'use client'
2
+
3
+ import * as Switch from '@radix-ui/react-switch'
4
+
5
+ export interface DirectiveSwitchProps {
6
+ id: string
7
+ /** Human label, e.g. "Show in search results". */
8
+ label: string
9
+ /** One line on what turning it off actually costs. */
10
+ hint?: string
11
+ /** `true` = permissive. */
12
+ checked: boolean
13
+ onChange: (next: boolean) => void
14
+ /** Directive token when permitted, e.g. `index`. */
15
+ permissiveToken: string
16
+ /** Directive token when restricted, e.g. `noindex`. */
17
+ restrictiveToken: string
18
+ disabled?: boolean
19
+ }
20
+
21
+ /**
22
+ * A robots-directive switch.
23
+ *
24
+ * ── Why this is not the ordinary Toggle ───────────────────────────────────
25
+ *
26
+ * The admin's `Toggle` turns brand purple when on, because "on" is the state
27
+ * the product is nudging you towards. That reading is wrong here. These
28
+ * switches are not on/off, they are permissive/restrictive — and the meaningful
29
+ * distinction is not "did I enable something" but "is this page still allowed
30
+ * to be found". Purple would say "enabled" about a switch whose enabled state
31
+ * is simply the default, and say nothing about the one state worth noticing.
32
+ *
33
+ * So: green when permitted, red when restricted. And the literal directive
34
+ * token sits beside it, because the failure this UI has to prevent is an editor
35
+ * flipping something whose consequence they could not name. `noindex` next to
36
+ * the switch turns an abstract toggle into the thing it actually writes into
37
+ * the page's head.
38
+ *
39
+ * Colour never carries it alone — the token chip is text, and the switch's
40
+ * accessible name states the label and the token it will produce.
41
+ */
42
+ export function DirectiveSwitch({
43
+ id,
44
+ label,
45
+ hint,
46
+ checked,
47
+ onChange,
48
+ permissiveToken,
49
+ restrictiveToken,
50
+ disabled = false,
51
+ }: DirectiveSwitchProps) {
52
+ const token = checked ? permissiveToken : restrictiveToken
53
+
54
+ return (
55
+ <div className="flex items-start justify-between gap-3 py-1.5">
56
+ <div className="min-w-0">
57
+ <label htmlFor={id} className="text-foreground block text-[12.5px] font-medium">
58
+ {label}
59
+ </label>
60
+ {hint && <p className="text-muted-foreground mt-0.5 text-[11px] leading-snug">{hint}</p>}
61
+ </div>
62
+
63
+ <div className="flex shrink-0 items-center gap-2">
64
+ <code
65
+ className={`rounded px-1.5 py-0.5 font-mono text-[9.5px] font-bold ${
66
+ checked ? 'bg-success/12 text-success' : 'bg-destructive/12 text-destructive'
67
+ }`}
68
+ >
69
+ {token}
70
+ </code>
71
+ <Switch.Root
72
+ id={id}
73
+ checked={checked}
74
+ onCheckedChange={onChange}
75
+ disabled={disabled}
76
+ aria-label={`${label} — currently ${token}`}
77
+ className={`focus-visible:ring-ring relative h-[19px] w-[34px] shrink-0 rounded-full transition-colors duration-(--mo-base) focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50 ${
78
+ checked ? 'bg-success' : 'bg-destructive/55'
79
+ }`}
80
+ >
81
+ <Switch.Thumb className="block h-3.5 w-3.5 translate-x-[2.5px] rounded-full bg-white shadow-[0_1px_3px_rgba(0,0,0,0.25)] transition-transform duration-(--mo-base) data-[state=checked]:translate-x-[18px]" />
82
+ </Switch.Root>
83
+ </div>
84
+ </div>
85
+ )
86
+ }
@@ -5,13 +5,21 @@ import { gradeFromScore, gradeMeta } from './primitives.js'
5
5
  /** Compact color-coded score for the page/post editor toolbar (`77/100` left of "SEO"). */
6
6
  export function EditorSeoScoreBadge({
7
7
  score,
8
+ band,
8
9
  inverted = false,
9
10
  }: {
10
11
  score: number
12
+ /**
13
+ * Band for the colour. Supplied by the caller because different scores use
14
+ * different scales: the page audit bands at 80/55, `analyzeContent` at
15
+ * 80/60/1. Deriving it here would colour one model's number with another
16
+ * model's thresholds — they disagree between 55 and 59.
17
+ */
18
+ band?: 'good' | 'fair' | 'poor'
11
19
  /** When the SEO toggle uses `bg-primary`, render score in primary-foreground. */
12
20
  inverted?: boolean
13
21
  }) {
14
- const meta = gradeMeta(gradeFromScore(score))
22
+ const meta = gradeMeta(band ?? gradeFromScore(score))
15
23
  return (
16
24
  <span
17
25
  className={`inline-flex items-baseline gap-0.5 text-xs font-medium tabular-nums ${
@@ -0,0 +1,192 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { ChevronDown, Search, Sparkles } from 'lucide-react'
5
+ import {
6
+ scoreBand,
7
+ type AuditCheck,
8
+ type AuditGroupResult,
9
+ type PageAuditResult,
10
+ } from '@prenta/core/seo/page-audit'
11
+
12
+ const BAND_TEXT = { good: 'text-success', fair: 'text-warning', poor: 'text-destructive' } as const
13
+ const BAND_STROKE = {
14
+ good: 'stroke-success',
15
+ fair: 'stroke-warning',
16
+ poor: 'stroke-destructive',
17
+ } as const
18
+
19
+ /**
20
+ * A 52px score ring.
21
+ *
22
+ * `null` is a real state, not an error: a page held back from search has no
23
+ * search performance to rate. It renders as a dash on a neutral track rather
24
+ * than an alarming zero.
25
+ */
26
+ function ScoreRing({ score, label }: { score: number | null; label: string }) {
27
+ const R = 21
28
+ const CIRCUMFERENCE = 2 * Math.PI * R
29
+ const band = score === null ? null : scoreBand(score)
30
+ const dash = score === null ? 0 : (score / 100) * CIRCUMFERENCE
31
+
32
+ return (
33
+ <svg
34
+ width="52"
35
+ height="52"
36
+ viewBox="0 0 52 52"
37
+ role="img"
38
+ aria-label={score === null ? `${label}: not rated` : `${label} score: ${score} of 100`}
39
+ >
40
+ <circle cx="26" cy="26" r={R} className="stroke-border" strokeWidth="5" fill="none" />
41
+ {score !== null && (
42
+ <circle
43
+ cx="26"
44
+ cy="26"
45
+ r={R}
46
+ className={`${BAND_STROKE[band!]} motion-safe:transition-[stroke-dasharray,stroke] motion-safe:duration-350`}
47
+ strokeWidth="5"
48
+ fill="none"
49
+ strokeLinecap="round"
50
+ strokeDasharray={`${dash} ${CIRCUMFERENCE - dash}`}
51
+ // Start the arc at twelve o'clock instead of three.
52
+ transform="rotate(-90 26 26)"
53
+ />
54
+ )}
55
+ <text
56
+ x="26"
57
+ y="26"
58
+ textAnchor="middle"
59
+ dominantBaseline="central"
60
+ className={`fill-current text-[15px] font-bold tabular-nums ${
61
+ band ? BAND_TEXT[band] : 'text-muted-foreground'
62
+ }`}
63
+ >
64
+ {score === null ? '—' : score}
65
+ </text>
66
+ </svg>
67
+ )
68
+ }
69
+
70
+ function CheckRow({ check }: { check: AuditCheck }) {
71
+ const na = check.outcome === 'not-applicable'
72
+ const pass = check.outcome === 'pass'
73
+
74
+ return (
75
+ <li className="border-border flex gap-2 border-b py-[7px] last:border-b-0">
76
+ <span
77
+ aria-hidden
78
+ className={`mt-0.5 h-4 w-4 shrink-0 rounded ${
79
+ na ? 'bg-muted' : pass ? 'bg-success/15 text-success' : 'bg-warning/15 text-warning'
80
+ }`}
81
+ />
82
+ <div className="min-w-0">
83
+ <p className={`text-xs font-medium ${na ? 'text-muted-foreground' : 'text-foreground'}`}>
84
+ {check.label}
85
+ {/* The outcome must not rest on the colour of the square alone. */}
86
+ <span className="sr-only">
87
+ {na ? ' — not applicable' : pass ? ' — passing' : ' — needs attention'}
88
+ </span>
89
+ </p>
90
+ {/* A not-applicable check explains itself; a failing one says what to do.
91
+ A passing one needs neither. */}
92
+ {na ? (
93
+ <p className="text-muted-foreground text-[10.5px]">{check.naReason}</p>
94
+ ) : pass ? null : (
95
+ <p className="text-muted-foreground text-[10.5px]">{check.hint}</p>
96
+ )}
97
+ </div>
98
+ </li>
99
+ )
100
+ }
101
+
102
+ function CheckGroup({ result, title }: { result: AuditGroupResult; title: string }) {
103
+ const [open, setOpen] = useState(true)
104
+ const band = result.score === null ? null : scoreBand(result.score)
105
+
106
+ return (
107
+ <section className="border-border border-b">
108
+ <button
109
+ type="button"
110
+ onClick={() => setOpen((o) => !o)}
111
+ aria-expanded={open}
112
+ className="hover:bg-accent flex w-full items-center justify-between px-3.5 py-2.5 transition-colors"
113
+ >
114
+ <span className="text-muted-foreground text-[10px] font-bold tracking-[0.7px] uppercase">
115
+ {title}
116
+ </span>
117
+ <span className="flex items-center gap-1.5">
118
+ {/* The pill counts SCORED checks. Gates and not-applicable checks are
119
+ listed below but excluded, so the denominator matches the score. */}
120
+ <span
121
+ className={`rounded-full px-1.5 text-[10px] font-bold tabular-nums ${
122
+ band === 'good'
123
+ ? 'bg-success/15 text-success'
124
+ : band === 'fair'
125
+ ? 'bg-warning/15 text-warning'
126
+ : band === 'poor'
127
+ ? 'bg-destructive/15 text-destructive'
128
+ : 'bg-muted text-muted-foreground'
129
+ }`}
130
+ >
131
+ {result.score === null ? 'n/a' : `${result.passed}/${result.applicable}`}
132
+ </span>
133
+ <ChevronDown
134
+ className={`text-muted-foreground h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`}
135
+ aria-hidden
136
+ />
137
+ </span>
138
+ </button>
139
+ {open && (
140
+ <ul className="px-3.5 pb-3.5">
141
+ {result.checks.map((c) => (
142
+ <CheckRow key={c.id} check={c} />
143
+ ))}
144
+ </ul>
145
+ )}
146
+ </section>
147
+ )
148
+ }
149
+
150
+ export interface PageAuditPanelProps {
151
+ audit: PageAuditResult
152
+ }
153
+
154
+ /**
155
+ * Twin score rings over the SEO and GEO check lists.
156
+ *
157
+ * Both scores are reported the same way and neither is folded into a single
158
+ * headline: classic search and AI answer visibility fail for different reasons
159
+ * and are fixed by different edits, so averaging them would hide whichever is
160
+ * worse.
161
+ */
162
+ export function PageAuditPanel({ audit }: PageAuditPanelProps) {
163
+ const { seo, geo } = audit
164
+
165
+ return (
166
+ <div className="border-border border-b">
167
+ <div className="flex items-start justify-around gap-3 px-3.5 py-4">
168
+ {[
169
+ { r: seo, icon: Search, label: 'SEO', sub: 'Classic search & crawlability' },
170
+ { r: geo, icon: Sparkles, label: 'GEO', sub: 'AI answer visibility' },
171
+ ].map(({ r, icon: Icon, label, sub }) => (
172
+ <div key={label} className="flex min-w-0 flex-col items-center gap-1.5 text-center">
173
+ <ScoreRing score={r.score} label={label} />
174
+ <span className="text-foreground flex items-center gap-1 text-xs font-bold">
175
+ <Icon className="h-3 w-3" aria-hidden />
176
+ {label}
177
+ </span>
178
+ <span className="text-muted-foreground text-[10.5px] leading-tight">{sub}</span>
179
+ {/* Names the denominator, so a score that moves because the set of
180
+ applicable checks changed is explainable rather than mysterious. */}
181
+ <span className="text-muted-foreground text-[10px] tabular-nums">
182
+ {r.score === null ? 'not rated' : `${r.applicable} of ${r.total} apply`}
183
+ </span>
184
+ </div>
185
+ ))}
186
+ </div>
187
+
188
+ <CheckGroup result={seo} title="SEO checks" />
189
+ <CheckGroup result={geo} title="GEO checks" />
190
+ </div>
191
+ )
192
+ }